// ============================================ // STATE & VARIABLES (additional to state.js) // ============================================ let manualStartTimePicker = null; // Additional timer state (beyond state.js) 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 // Current view state let currentView = 'monthly'; // 'monthly' or 'filter' let currentFilterFrom = null; let currentFilterTo = null; // Settings state let currentBundesland = 'BW'; // Default: Baden-Württemberg let totalVacationDays = 30; // Default vacation days per year // ============================================ // UTILITY FUNCTIONS // ============================================ /** * Get day of week abbreviation in German */ function getDayOfWeek(date) { const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; return days[date.getDay()]; } /** * Get month name in German */ function getMonthName(month) { const months = [ 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember' ]; return months[month]; } /** * Update month display */ function updateMonthDisplay() { const monthName = getMonthName(displayMonth); const displayText = `${monthName} ${displayYear}`; document.getElementById('currentMonthDisplay').textContent = displayText; // Always show next month button (allow navigation to future) const nextBtn = document.getElementById('btnNextMonth'); nextBtn.style.visibility = 'visible'; } /** * Navigate to previous month */ function handlePrevMonth() { if (displayMonth === 0) { displayMonth = 11; displayYear--; } else { displayMonth--; } loadMonthlyView(); } /** * Navigate to next month */ function handleNextMonth() { // Allow navigation to future months if (displayMonth === 11) { displayMonth = 0; displayYear++; } else { displayMonth++; } loadMonthlyView(); } // ============================================ // TIMER FUNCTIONS // ============================================ /** * Update timer status text and show/hide metrics */ function setTimerStatus(text, showMetrics = false) { const statusTextEl = document.getElementById('timerStatusText'); const metricsEl = document.getElementById('timerMetrics'); const manualTimeLink = document.getElementById('manualTimeLink'); if (statusTextEl) { statusTextEl.textContent = text; } if (metricsEl) { if (showMetrics) { metricsEl.classList.remove('hidden'); if (manualTimeLink) manualTimeLink.classList.add('hidden'); } else { metricsEl.classList.add('hidden'); if (manualTimeLink) manualTimeLink.classList.remove('hidden'); } } } /** * Check if timer is running (on page load) */ async function checkRunningTimer() { const today = getTodayISO(); const entries = await fetchEntries(today, today); if (entries.length > 0) { const todayEntry = entries[0]; // Check if entry has start but same start and end (running timer) if (todayEntry.startTime === todayEntry.endTime) { // Timer is running currentEntryId = todayEntry.id; // Calculate start time from DB const [hours, minutes] = todayEntry.startTime.split(':').map(Number); const startDate = new Date(); startDate.setHours(hours, minutes, 0, 0); timerStartTime = startDate.getTime(); timerStartTimeString = todayEntry.startTime; // Update UI const startBtn = document.getElementById('btnStartWork'); const stopBtn = document.getElementById('btnStopWork'); startBtn.disabled = true; startBtn.classList.add('opacity-50', 'cursor-not-allowed'); startBtn.classList.remove('hover:bg-green-700'); stopBtn.disabled = false; stopBtn.classList.remove('opacity-50', 'cursor-not-allowed'); stopBtn.classList.add('hover:bg-red-700'); setTimerStatus('Läuft seit ' + todayEntry.startTime, true); // 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); setTimerStatus(`Läuft seit ${todayEntry.startTime} - Pause (${Math.ceil(remainingPause / 60)} Min)`, true); // Schedule end of pause pauseTimeout = setTimeout(() => { timerPausedDuration = thirtyMinutes; isPaused = false; pauseStartElapsed = 0; pauseEndTime = 0; setTimerStatus('Läuft seit ' + todayEntry.startTime, true); }, 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); setTimerStatus(`Läuft seit ${todayEntry.startTime} - Pause (${Math.ceil(remainingPause / 60)} Min)`, true); // Schedule end of pause pauseTimeout = setTimeout(() => { timerPausedDuration = thirtyMinutes + fifteenMinutes; isPaused = false; pauseStartElapsed = 0; pauseEndTime = 0; setTimerStatus('Läuft seit ' + todayEntry.startTime, true); }, 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 schedulePausesWithOffset(elapsed); } } } /** * Start work timer */ async function startWork() { const now = new Date(); const roundedStart = roundDownTo15Min(new Date(now)); const startTime = formatTime(roundedStart); const today = getTodayISO(); // Create entry with same start and end time (indicates running) const entry = await createEntry(formatDateDisplay(today), startTime, startTime, null); if (!entry) { return; } currentEntryId = entry.id; timerStartTime = roundedStart.getTime(); timerStartTimeString = startTime; timerPausedDuration = 0; isPaused = false; // Save current target hours await setSetting('targetHours', targetHours); // Update UI const startBtn = document.getElementById('btnStartWork'); const stopBtn = document.getElementById('btnStopWork'); startBtn.disabled = true; startBtn.classList.add('opacity-50', 'cursor-not-allowed'); startBtn.classList.remove('hover:bg-green-700'); stopBtn.disabled = false; stopBtn.classList.remove('opacity-50', 'cursor-not-allowed'); stopBtn.classList.add('hover:bg-red-700'); setTimerStatus('Läuft seit ' + startTime, true); // Start timer interval timerInterval = setInterval(updateTimer, 1000); // Schedule automatic pauses schedulePausesWithOffset(0); // Reload view to show entry loadMonthlyView(); } /** * Stop work timer */ async function stopWork() { if (!currentEntryId) { return; } clearInterval(timerInterval); if (pauseTimeout) clearTimeout(pauseTimeout); const now = new Date(); const roundedEnd = roundUpTo15Min(new Date(now)); const endTime = formatTime(roundedEnd); const today = getTodayISO(); // Fetch the entry to get start time const entries = await fetchEntries(today, today); const entry = entries.find(e => e.id === currentEntryId); if (entry) { // Update entry with end time await updateEntry(currentEntryId, formatDateDisplay(today), entry.startTime, endTime, null); } // Reset timer state currentEntryId = null; timerStartTime = null; timerPausedDuration = 0; isPaused = false; // Reset target hours to 8 and clear saved setting targetHours = 8; document.getElementById('targetHoursSelect').value = 8; await setSetting('targetHours', null); // Update UI const startBtn = document.getElementById('btnStartWork'); const stopBtn = document.getElementById('btnStopWork'); startBtn.disabled = false; startBtn.classList.remove('opacity-50', 'cursor-not-allowed'); startBtn.classList.add('hover:bg-green-700'); stopBtn.disabled = true; stopBtn.classList.add('opacity-50', 'cursor-not-allowed'); stopBtn.classList.remove('hover:bg-red-700'); document.getElementById('timerDisplay').textContent = '00:00:00'; setTimerStatus('Nicht gestartet', false); // Reload monthly view loadMonthlyView(); } /** * Update timer display */ function updateTimer() { if (!timerStartTime) return; 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); setTimerStatus(`Läuft seit ${timerStartTimeString} - Pause (${remainingMinutes} Min)`, true); } else { elapsed = Math.floor((now - timerStartTime) / 1000) - timerPausedDuration; // Check if 10h net time reached - auto stop timer const netHours = elapsed / 3600; if (netHours >= 10) { showNotification('🛑 Maximale Arbeitszeit (10h netto) erreicht. Timer wird automatisch gestoppt.', 'warning'); stopWork(); return; } } document.getElementById('timerDisplay').textContent = formatDuration(elapsed); // Calculate and display additional timer metrics updateTimerMetrics(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 balance in the table for current day const balanceCell = document.getElementById('current-day-balance'); if (balanceCell) { const netHours = elapsed / 3600; // Convert seconds to hours // Always use 8h standard target for balance calculation (not the dropdown target) const deviation = netHours - 8.0; const baseBalance = parseFloat(balanceCell.dataset.baseBalance || 0); const currentBalance = baseBalance + deviation; const balanceColor = currentBalance >= 0 ? 'text-green-400' : 'text-red-400'; const balanceSign = currentBalance >= 0 ? '+' : ''; balanceCell.className = `px-4 py-3 whitespace-nowrap text-sm font-semibold ${balanceColor}`; balanceCell.textContent = `${balanceSign}${currentBalance.toFixed(2)}h`; } // 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; } } /** * Calculate and update additional timer metrics */ function updateTimerMetrics(netElapsedSeconds) { const targetReachedTimeSpan = document.getElementById('targetReachedTime'); const timeUntilTargetSpan = document.getElementById('timeUntilTarget'); const balanceAtTargetSpan = document.getElementById('balanceAtTarget'); if (!timerStartTime) { return; } // Target hours from user selection (default 8) const targetSeconds = targetHours * 60 * 60; // Calculate total pause time: 30 min after 6h + 15 min after 9h const pauseDuration30Min = 30 * 60; // 30 minutes in seconds const pauseDuration15Min = 15 * 60; // 15 minutes in seconds // Time needed including pauses // After 6h work -> 30 min pause // After 9h work -> 15 min pause // Total gross time = target work hours + 30min pause + 15min pause const totalGrossTimeNeeded = targetSeconds + pauseDuration30Min + pauseDuration15Min; // Calculate when target will be reached (clock time) const targetReachedTimestamp = new Date(timerStartTime + totalGrossTimeNeeded * 1000); const targetHoursTime = String(targetReachedTimestamp.getHours()).padStart(2, '0'); const targetMinutesDisplay = String(targetReachedTimestamp.getMinutes()).padStart(2, '0'); targetReachedTimeSpan.textContent = `${targetHoursTime}:${targetMinutesDisplay}`; // Calculate countdown to target (remaining net work time) const remainingNetSeconds = Math.max(0, targetSeconds - netElapsedSeconds); if (remainingNetSeconds === 0) { timeUntilTargetSpan.textContent = '00:00:00'; timeUntilTargetSpan.classList.add('text-green-400'); } else { // Format as HH:MM:SS const hours = Math.floor(remainingNetSeconds / 3600); const minutes = Math.floor((remainingNetSeconds % 3600) / 60); const seconds = remainingNetSeconds % 60; timeUntilTargetSpan.textContent = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; timeUntilTargetSpan.classList.remove('text-green-400'); } // Calculate balance when target is reached // Get base balance from current day row (balance before today) const balanceCell = document.getElementById('current-day-balance'); if (balanceCell && balanceAtTargetSpan) { const baseBalance = parseFloat(balanceCell.dataset.baseBalance || 0); // Deviation when target hours are reached: targetHours - 8h (standard) const targetNetHours = targetHours; const deviation = targetNetHours - 8.0; const balanceAtTarget = baseBalance + deviation; const balanceColor = balanceAtTarget >= 0 ? 'text-green-400' : 'text-red-400'; const balanceSign = balanceAtTarget >= 0 ? '+' : ''; balanceAtTargetSpan.className = `font-semibold ${balanceColor}`; balanceAtTargetSpan.textContent = `${balanceSign}${balanceAtTarget.toFixed(2)}h`; } // Reinitialize Lucide icons for the new metrics if (typeof lucide !== 'undefined' && lucide.createIcons) { lucide.createIcons(); } } /** * Schedule automatic pauses at 6h and 9h with offset for existing elapsed time */ function schedulePausesWithOffset(elapsedMs) { const sixHoursMs = 6 * 60 * 60 * 1000; const nineHoursMs = 9 * 60 * 60 * 1000; // Pause at 6 hours for 30 minutes if (elapsedMs < sixHoursMs) { setTimeout(() => { if (timerStartTime && !isPaused) { pauseTimer(30 * 60); // 30 minutes showNotification('⏸️ Automatische Pause: 30 Minuten (nach 6 Stunden)', 'info'); } }, sixHoursMs - elapsedMs); } // Additional pause at 9 hours for 15 minutes if (elapsedMs < nineHoursMs) { setTimeout(() => { if (timerStartTime && !isPaused) { pauseTimer(15 * 60); // 15 minutes showNotification('⏸️ Automatische Pause: 15 Minuten (nach 9 Stunden)', 'info'); } }, nineHoursMs - elapsedMs); } } /** * Pause timer for specified duration */ function pauseTimer(durationSeconds) { isPaused = true; 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; pauseEndTime = 0; setTimerStatus('Läuft seit ' + timerStartTimeString, true); }, durationSeconds * 1000); } // ============================================ // SETTINGS API // ============================================ /** * Get a setting by key */ async function getSetting(key) { try { const response = await fetch(`/api/settings/${key}`); if (!response.ok) { if (response.status === 404) { return null; // Setting doesn't exist yet } throw new Error('Failed to get setting'); } const data = await response.json(); return data.value; } catch (error) { console.error('Error getting setting:', error); return null; } } /** * Set a setting */ async function setSetting(key, value) { try { const response = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value }) }); if (!response.ok) { throw new Error('Failed to set setting'); } return true; } catch (error) { console.error('Error setting setting:', error); showNotification('Fehler beim Speichern der Einstellung', 'error'); return false; } } /** * Export entries as CSV */ async function exportEntries(fromDate = null, toDate = null) { try { let url = '/api/export'; const params = new URLSearchParams(); if (fromDate) params.append('from', fromDate); if (toDate) params.append('to', toDate); if (params.toString()) { url += '?' + params.toString(); } const response = await fetch(url); if (!response.ok) { throw new Error('Failed to export entries'); } const blob = await response.blob(); const downloadUrl = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = downloadUrl; a.download = 'zeiterfassung.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(downloadUrl); showNotification('Export erfolgreich', 'success'); } catch (error) { console.error('Error exporting entries:', error); showNotification('Fehler beim Exportieren', 'error'); } } // ============================================ // UI FUNCTIONS // ============================================ /** * Render entries in the table */ function renderEntries(entries) { const tbody = document.getElementById('entriesTableBody'); const emptyState = document.getElementById('emptyState'); tbody.innerHTML = ''; if (entries.length === 0) { emptyState.classList.remove('hidden'); return; } emptyState.classList.add('hidden'); // Running balance accumulator let runningBalance = 0; entries.forEach(entry => { const row = document.createElement('tr'); const dateObj = new Date(entry.date + 'T00:00:00'); const dayOfWeek = getDayOfWeek(dateObj); const weekend = isWeekendOrHoliday(dateObj); const location = entry.location || 'office'; // Row color based on location let rowClass = 'hover:bg-gray-700'; if (location === 'home') { rowClass = 'hover:bg-green-900 bg-green-950'; } else if (weekend) { rowClass = 'hover:bg-gray-700 bg-gray-700'; } row.className = rowClass; row.dataset.id = entry.id; // Location icon and text const locationIcon = location === 'home' ? '' : ''; const locationText = location === 'home' ? 'Home' : 'Büro'; // Checkbox column (always present for consistent layout) const checkboxCell = bulkEditMode ? ` ` : ''; // Calculate balance: for work and flextime entries (excluding vacation) const entryType = entry.entryType || 'work'; let balanceCell = ''; if (entryType !== 'vacation') { // For all workdays (including flextime): Actual - Target (8h) // Flextime has netHours = 0, so deviation will be -8h const deviation = entry.netHours - 8.0; runningBalance += deviation; const balanceColor = runningBalance >= 0 ? 'text-green-400' : 'text-red-400'; const balanceSign = runningBalance >= 0 ? '+' : ''; balanceCell = `${balanceSign}${runningBalance.toFixed(2)}h`; } else { balanceCell = '-'; } row.innerHTML = checkboxCell + ` ${dayOfWeek} ${formatDateDisplay(entry.date)} ${entry.startTime} ${entry.endTime} ${entry.pauseMinutes} ${entry.netHours.toFixed(2)} ${balanceCell} ${locationIcon} ${locationText}
`; tbody.appendChild(row); }); // Reinitialize Lucide icons if (typeof lucide !== 'undefined' && lucide.createIcons) { lucide.createIcons(); } // Add event listeners attachInlineEditListeners(); document.querySelectorAll('.btn-edit').forEach(btn => { btn.addEventListener('click', async () => { const id = parseInt(btn.dataset.id); const entries = await fetchEntries(); const entry = entries.find(e => e.id === id); if (entry) { openModal(entry); } }); }); document.querySelectorAll('.btn-delete').forEach(btn => { btn.addEventListener('click', () => handleDelete(parseInt(btn.dataset.id))); }); // Checkbox event listeners for bulk edit document.querySelectorAll('.entry-checkbox').forEach(checkbox => { checkbox.addEventListener('change', () => { toggleEntrySelection(parseInt(checkbox.dataset.id)); }); }); } /** * Render monthly view with all days */ function renderMonthlyView(entries) { const tbody = document.getElementById('entriesTableBody'); const emptyState = document.getElementById('emptyState'); tbody.innerHTML = ''; emptyState.classList.add('hidden'); const today = new Date(); const todayYear = today.getFullYear(); const todayMonth = today.getMonth(); const todayDay = today.getDate(); // Show all days of the month const lastDay = new Date(displayYear, displayMonth + 1, 0).getDate(); // Create a map of entries by date const entriesMap = {}; entries.forEach(entry => { entriesMap[entry.date] = entry; }); // Running balance accumulator let runningBalance = 0; // Render all days from 1st to lastDay for (let day = 1; day <= lastDay; day++) { const dateObj = new Date(displayYear, displayMonth, day); const dateISO = `${displayYear}-${String(displayMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const entry = entriesMap[dateISO]; const dayOfWeek = getDayOfWeek(dateObj); const weekend = isWeekendOrHoliday(dateObj); // Check if this is today const isToday = dateISO === getTodayISO(); const row = document.createElement('tr'); if (entry) { // Day has entry const location = entry.location || 'office'; const entryType = entry.entryType || 'work'; // Row color based on entry type and location let rowClass = 'hover:bg-slate-600/40 transition-colors'; if (entryType === 'vacation') { rowClass = 'hover:bg-yellow-800/50 bg-yellow-900/30 transition-colors'; } else if (entryType === 'flextime') { rowClass = 'hover:bg-cyan-800/50 bg-cyan-900/30 transition-colors'; } else if (location === 'home') { rowClass = 'hover:bg-green-800/50 bg-green-900/30 transition-colors'; } else if (weekend) { rowClass = 'hover:bg-slate-600/50 bg-slate-700/30 transition-colors'; } row.className = rowClass; row.dataset.id = entry.id; // 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 let displayIcon, displayText, displayTimes; if (entryType === 'vacation') { displayIcon = ''; displayText = 'Urlaub'; displayTimes = ` Urlaub `; } else if (entryType === 'flextime') { displayIcon = ''; displayText = 'Gleitzeit'; displayTimes = ` Gleittag (8h) `; } else { displayIcon = location === 'home' ? '' : ''; displayText = location === 'home' ? 'Home' : 'Büro'; // Check if timer is running (start == end time) const isTimerRunning = entry.startTime === entry.endTime; const endTimeDisplay = isTimerRunning ? '' : entry.endTime; displayTimes = ` ${entry.startTime} ${endTimeDisplay} ${entry.pauseMinutes} `; } // Checkbox column (always present for consistent layout) const checkboxCell = bulkEditMode ? ` ` : ''; // Calculate balance: only for past days (excluding vacation) const isPastDay = dateObj < today || (dateObj.getFullYear() === todayYear && dateObj.getMonth() === todayMonth && day <= todayDay); let balanceCell = ''; if (isPastDay && entryType !== 'vacation') { // For all workdays (including flextime): Actual - Target (8h default) // Flextime has netHours = 0, so deviation will be -8h // For current day: store balance before today, then use custom target hours in live update const balanceBeforeToday = runningBalance; const deviation = entry.netHours - 8.0; runningBalance += deviation; const balanceColor = runningBalance >= 0 ? 'text-green-400' : 'text-red-400'; const balanceSign = runningBalance >= 0 ? '+' : ''; // Add ID for current day to enable live updates // Store balance before today, so live update can add current day's deviation with custom target const balanceId = isToday && entryType === 'work' ? 'id="current-day-balance"' : ''; const balanceDataAttr = isToday && entryType === 'work' ? `data-base-balance="${balanceBeforeToday}"` : ''; balanceCell = `${balanceSign}${runningBalance.toFixed(2)}h`; } else { balanceCell = '-'; } row.innerHTML = checkboxCell + ` ${dayOfWeek} ${formatDateDisplay(entry.date)} ${displayTimes} ${entry.netHours.toFixed(2)} ${balanceCell} ${displayIcon} ${displayText}
${entryType === 'work' ? ` ` : ''}
`; } else { // Day has no entry - show add options const holidayName = getHolidayName(dateObj); const displayText = holidayName || 'Kein Eintrag'; // Don't mark future days as red, only past workdays without entries const isFutureDay = dateObj > today; let emptyRowClass = weekend ? 'hover:bg-slate-600/50 bg-slate-700/30 transition-colors' : isFutureDay ? 'hover:bg-slate-600/40 transition-colors' : 'hover:bg-slate-600/40 bg-red-900/30 transition-colors'; row.className = emptyRowClass; row.dataset.date = dateISO; // Store date for bulk operations // Add inline border style for today if (isToday) { row.style.borderLeft = '4px solid #3b82f6'; // blue-500 } // Checkbox cell for empty days (in bulk edit mode) const checkboxCell = bulkEditMode ? ` ` : ''; const colspan = bulkEditMode ? '6' : '6'; row.innerHTML = checkboxCell + ` ${dayOfWeek} ${formatDateDisplay(dateISO)} ${displayText}
${!holidayName ? ` ` : ''} ${!weekend ? ` ` : ''}
`; } tbody.appendChild(row); } // Reinitialize Lucide icons if (typeof lucide !== 'undefined' && lucide.createIcons) { lucide.createIcons(); } // Add event listeners attachInlineEditListeners(); document.querySelectorAll('.btn-edit').forEach(btn => { btn.addEventListener('click', async () => { const id = parseInt(btn.dataset.id); const entries = await fetchEntries(); const entry = entries.find(e => e.id === id); if (entry) { openModal(entry); } }); }); document.querySelectorAll('.btn-delete').forEach(btn => { btn.addEventListener('click', () => handleDelete(parseInt(btn.dataset.id))); }); // Add work entry for a specific date document.querySelectorAll('.btn-add-work').forEach(btn => { btn.addEventListener('click', () => { const dateISO = btn.dataset.date; openModalForDate(dateISO); }); }); // Add vacation entry for a specific date document.querySelectorAll('.btn-add-vacation').forEach(btn => { btn.addEventListener('click', async () => { const dateISO = btn.dataset.date; await addSpecialEntry(dateISO, 'vacation'); }); }); // Add flextime entry for a specific date document.querySelectorAll('.btn-add-flextime').forEach(btn => { btn.addEventListener('click', async () => { const dateISO = btn.dataset.date; await addSpecialEntry(dateISO, 'flextime'); }); }); // Checkbox event listeners for bulk edit (existing entries) document.querySelectorAll('.entry-checkbox').forEach(checkbox => { checkbox.addEventListener('change', () => { toggleEntrySelection(parseInt(checkbox.dataset.id)); }); }); // Checkbox event listeners for bulk edit (empty days) document.querySelectorAll('.empty-day-checkbox').forEach(checkbox => { checkbox.addEventListener('change', () => { toggleEmptyDaySelection(checkbox.dataset.date); }); }); } /** * Add a special entry (vacation or flextime) for a specific date */ async function addSpecialEntry(dateISO, entryType) { const typeName = entryType === 'vacation' ? 'Urlaub' : 'Gleittag'; // Check if date is a public holiday const dateObj = new Date(dateISO + 'T00:00:00'); const holidayName = getHolidayName(dateObj); if (holidayName) { showNotification(`❌ An Feiertagen (${holidayName}) kann kein ${typeName} eingetragen werden`, 'error'); return; } if (!confirm(`${typeName} für ${formatDateDisplay(dateISO)} eintragen?`)) { return; } try { const response = await fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: dateISO, entryType: entryType }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Fehler beim Erstellen'); } const message = entryType === 'vacation' ? '✅ Urlaub eingetragen (kein Arbeitstag)' : '✅ Gleittag eingetragen (8h Soll, 0h Ist)'; showNotification(message, 'success'); loadMonthlyView(); } catch (error) { showNotification(error.message, 'error'); } } /** * Load and display entries */ async function loadEntries(fromDate = null, toDate = null) { const entries = await fetchEntries(fromDate, toDate); renderEntries(entries); } /** * Load and display monthly view */ async function loadMonthlyView() { currentView = 'monthly'; showMonthNavigation(); // First day of display month const fromDate = `${displayYear}-${String(displayMonth + 1).padStart(2, '0')}-01`; // Last day of display month const lastDay = new Date(displayYear, displayMonth + 1, 0).getDate(); const toDate = `${displayYear}-${String(displayMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; const entries = await fetchEntries(fromDate, toDate); renderMonthlyView(entries); updateMonthDisplay(); updateStatistics(entries); updateBridgeDaysDisplay(); // Show/hide PDF export button based on whether last day has complete times const pdfButton = document.getElementById('btnExportPDF'); const pdfButtonMobile = document.getElementById('btnExportPDFMobile'); // Check if last day of month has start and end time (and they're different) const lastDayDate = `${displayYear}-${String(displayMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; const lastDayEntry = entries.find(e => e.date === lastDayDate); const isLastDayComplete = lastDayEntry && lastDayEntry.startTime && lastDayEntry.endTime && lastDayEntry.startTime !== lastDayEntry.endTime; if (isLastDayComplete) { pdfButton.style.display = ''; if (pdfButtonMobile) pdfButtonMobile.style.display = ''; } else { pdfButton.style.display = 'none'; if (pdfButtonMobile) pdfButtonMobile.style.display = 'none'; } } /** * Reload the current view (monthly or filter) */ async function reloadView() { if (currentView === 'filter') { const entries = await fetchEntries(currentFilterFrom, currentFilterTo); renderEntries(entries); } else { await loadMonthlyView(); } } /** * Show month navigation controls */ function showMonthNavigation() { const nav = document.getElementById('monthNavigation'); if (nav) nav.classList.remove('hidden'); } /** * Hide month navigation controls */ function hideMonthNavigation() { const nav = document.getElementById('monthNavigation'); if (nav) nav.classList.add('hidden'); } /** * Calculate and update statistics for the current month */ async function updateStatistics(entries) { const today = new Date(); const currentYear = displayYear; const currentMonth = displayMonth; const lastDay = new Date(currentYear, currentMonth + 1, 0).getDate(); // Count workdays (excluding weekends and holidays, only up to today) let workdaysCount = 0; let workdaysPassed = 0; let totalWorkdaysInMonth = 0; // For statistics display // Count vacation days to exclude from workdays const vacationDays = new Set( entries .filter(e => e.entryType === 'vacation') .map(e => e.date) ); // Count flextime days (they are workdays with 0 hours worked) const flextimeDays = new Set( entries .filter(e => e.entryType === 'flextime') .map(e => e.date) ); let futureFlextimeDays = 0; // Count future flextime days in current month for (let day = 1; day <= lastDay; day++) { const dateObj = new Date(currentYear, currentMonth, day); const year = dateObj.getFullYear(); const month = String(dateObj.getMonth() + 1).padStart(2, '0'); const dayStr = String(dateObj.getDate()).padStart(2, '0'); const dateISO = `${year}-${month}-${dayStr}`; const isVacation = vacationDays.has(dateISO); const isFlextime = flextimeDays.has(dateISO); const isWeekendHoliday = isWeekendOrHoliday(dateObj); if (!isWeekendHoliday && !isVacation && !isFlextime) { // Normal workday (excluding vacation and flextime days) totalWorkdaysInMonth++; workdaysCount++; if (dateObj <= today) { workdaysPassed++; } } else if (!isWeekendHoliday && !isVacation && isFlextime) { // Flextime on a workday - still counts as workday in calendar totalWorkdaysInMonth++; workdaysCount++; if (dateObj <= today) { workdaysPassed++; } else { // Future flextime in current month const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth(); if (isCurrentMonth) { futureFlextimeDays++; } } } 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 } // Calculate target hours (8h per workday passed, no reduction) const targetHours = workdaysPassed * 8; // Calculate actual hours worked (only up to today) let actualHours = entries .filter(e => new Date(e.date) <= today) .reduce((sum, entry) => sum + entry.netHours, 0); // 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; } // Calculate balance for current month let balance = actualHours - targetHours; // Subtract future flextime days from balance (they consume flextime) balance -= (futureFlextimeDays * 8); // Calculate previous month balance const previousBalance = await calculatePreviousBalance(); // 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 = `${workEntriesCount}/${totalWorkdaysInMonth}`; // Show/hide flextime hint icons const balanceFlextimeHint = document.getElementById('balanceFlextimeHint'); const totalBalanceFlextimeHint = document.getElementById('totalBalanceFlextimeHint'); if (futureFlextimeDays > 0) { const tooltipText = `Inkl. ${futureFlextimeDays} geplanter Gleitzeittag${futureFlextimeDays > 1 ? 'e' : ''} (-${futureFlextimeDays * 8}h)`; balanceFlextimeHint.classList.remove('hidden'); totalBalanceFlextimeHint.classList.remove('hidden'); // Set title attribute before re-initializing icons balanceFlextimeHint.setAttribute('title', tooltipText); totalBalanceFlextimeHint.setAttribute('title', tooltipText); // Re-initialize icons if (typeof lucide !== 'undefined' && lucide.createIcons) { lucide.createIcons(); } // Re-apply title after icon initialization (in case it was cleared) balanceFlextimeHint.setAttribute('title', tooltipText); totalBalanceFlextimeHint.setAttribute('title', tooltipText); } else { balanceFlextimeHint.classList.add('hidden'); totalBalanceFlextimeHint.classList.add('hidden'); } // Current month balance const balanceElement = document.getElementById('statBalance'); balanceElement.textContent = (balance >= 0 ? '+' : '') + balance.toFixed(1) + 'h'; if (balance > 0) { balanceElement.className = 'text-2xl font-bold text-green-400'; } else if (balance < 0) { balanceElement.className = 'text-2xl font-bold text-red-400'; } else { balanceElement.className = 'text-2xl font-bold text-gray-100'; } // Previous month balance const prevBalanceElement = document.getElementById('statPreviousBalance'); prevBalanceElement.textContent = (previousBalance >= 0 ? '+' : '') + previousBalance.toFixed(1) + 'h'; // Total balance const totalBalanceElement = document.getElementById('statTotalBalance'); totalBalanceElement.textContent = (totalBalance >= 0 ? '+' : '') + totalBalance.toFixed(1) + 'h'; if (totalBalance > 0) { totalBalanceElement.className = 'text-3xl font-bold text-green-400'; } else if (totalBalance < 0) { totalBalanceElement.className = 'text-3xl font-bold text-red-400'; } else { totalBalanceElement.className = 'text-3xl font-bold text-gray-100'; } // Update vacation statistics await updateVacationStatistics(); } /** * Update vacation statistics for the year */ async function updateVacationStatistics() { const currentYear = displayYear; // Use displayed year, not current calendar year const today = new Date(); // Fetch all entries for the displayed year const fromDate = `${currentYear}-01-01`; const toDate = `${currentYear}-12-31`; const allEntries = await fetchEntries(fromDate, toDate); // Filter vacation entries const vacationEntries = allEntries.filter(e => e.entryType === 'vacation'); // Calculate taken (past and today) and planned (future) vacation days const vacationTaken = vacationEntries.filter(e => new Date(e.date) <= today).length; const vacationPlanned = vacationEntries.filter(e => new Date(e.date) > today).length; const vacationRemaining = totalVacationDays - vacationTaken - vacationPlanned; // Update UI with dynamic year const vacationLabel = document.getElementById('vacationYearLabel'); vacationLabel.innerHTML = ` Urlaub ${currentYear}`; if (typeof lucide !== 'undefined' && lucide.createIcons) { lucide.createIcons(); } document.getElementById('statVacationTaken').textContent = vacationTaken; document.getElementById('statVacationPlanned').textContent = vacationPlanned; document.getElementById('statVacationRemaining').textContent = `${vacationRemaining} / ${totalVacationDays}`; } /** * Update bridge days display for current month */ function updateBridgeDaysDisplay() { const container = document.getElementById('bridgeDaysContainer'); const list = document.getElementById('bridgeDaysList'); // Calculate bridge days for current month const recommendations = calculateBridgeDays(displayYear, displayMonth, currentBundesland); if (recommendations.length === 0) { container.classList.add('hidden'); return; } // Show container and populate list container.classList.remove('hidden'); list.innerHTML = ''; recommendations.forEach((rec, index) => { const startDate = new Date(rec.startDate); const endDate = new Date(rec.endDate); const item = document.createElement('div'); item.className = 'flex items-start gap-3 p-3 bg-gray-800/50 rounded-lg border border-cyan-600/20 hover:border-cyan-600/40 transition-colors'; // Format date range const startStr = formatDateDisplay(rec.startDate); const endStr = formatDateDisplay(rec.endDate); // Create vacation days list const vacDaysList = rec.vacationDays.map(d => { const date = new Date(d); return `${date.getDate()}.${String(date.getMonth() + 1).padStart(2, '0')}.`; }).join(', '); item.innerHTML = `
${Math.round(rec.ratio)}x
${startStr} - ${endStr}
${rec.vacationDaysNeeded} Urlaubstag${rec.vacationDaysNeeded > 1 ? 'e' : ''} (${vacDaysList}) für ${rec.totalFreeDays} freie Tage
${rec.holidays.length > 0 ? `
${rec.holidays.join(', ')}
` : ''}
`; list.appendChild(item); }); // Re-initialize icons if (typeof lucide !== 'undefined' && lucide.createIcons) { lucide.createIcons(); } // Add event listeners for quick add buttons document.querySelectorAll('.btn-add-bridge-days').forEach(btn => { btn.addEventListener('click', async () => { const days = JSON.parse(btn.dataset.days); await addBridgeDaysAsVacation(days); }); }); } /** * Add bridge days as vacation entries */ async function addBridgeDaysAsVacation(days) { let created = 0; let skipped = 0; for (const dateStr of days) { try { const response = await fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: dateStr, entryType: 'vacation' }) }); if (response.ok) { created++; } else { skipped++; } } catch (error) { console.error('Error creating vacation entry:', error); skipped++; } } await reloadView(); showNotification(`✓ ${created} Urlaubstag${created > 1 ? 'e' : ''} eingetragen${skipped > 0 ? `, ${skipped} übersprungen` : ''}`, 'success'); } /** * Calculate balance from all previous months (starting from first month with entries) */ async function calculatePreviousBalance() { const currentYear = displayYear; const currentMonth = displayMonth; // Find the first month with any entries by checking all entries const allEntries = await fetchEntries(); if (allEntries.length === 0) { return 0; } // Find earliest date const earliestDate = allEntries.reduce((earliest, entry) => { const entryDate = new Date(entry.date); return !earliest || entryDate < earliest ? entryDate : earliest; }, null); if (!earliestDate) { return 0; } const firstYear = earliestDate.getFullYear(); const firstMonth = earliestDate.getMonth(); // Calculate balance from first month to previous month (not including current displayed month) let totalBalance = 0; let checkYear = firstYear; let checkMonth = firstMonth; const today = new Date(); // Loop through all months from first entry until previous month of displayed month while (checkYear < currentYear || (checkYear === currentYear && checkMonth < currentMonth)) { const firstDay = `${checkYear}-${String(checkMonth + 1).padStart(2, '0')}-01`; const lastDay = new Date(checkYear, checkMonth + 1, 0).getDate(); const lastDayStr = `${checkYear}-${String(checkMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; const entries = await fetchEntries(firstDay, lastDayStr); // For past months, use full month. For current month (if displayed), limit to today const monthEnd = new Date(checkYear, checkMonth + 1, 0); const limitDate = monthEnd; // Always use full month for previous balance calculation let workdaysPassed = 0; const monthLastDay = new Date(checkYear, checkMonth + 1, 0).getDate(); // Count vacation days to exclude from workdays const vacationDays = new Set( entries .filter(e => e.entryType === 'vacation') .map(e => e.date) ); // Count flextime days (they are workdays with 0 hours worked) const flextimeDays = new Set( entries .filter(e => e.entryType === 'flextime') .map(e => e.date) ); for (let day = 1; day <= monthLastDay; day++) { const dateObj = new Date(checkYear, checkMonth, day); const year = dateObj.getFullYear(); const month = String(dateObj.getMonth() + 1).padStart(2, '0'); const dayStr = String(dateObj.getDate()).padStart(2, '0'); const dateISO = `${year}-${month}-${dayStr}`; if (!isWeekendOrHoliday(dateObj)) { // Exclude vacation days from workdays count if (!vacationDays.has(dateISO)) { workdaysPassed++; } } else if (flextimeDays.has(dateISO)) { // Flextime on weekend/holiday counts as workday workdaysPassed++; } } const targetHours = workdaysPassed * 8; const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0); const monthBalance = actualHours - targetHours; totalBalance += monthBalance; // Move to next month checkMonth++; if (checkMonth > 11) { checkMonth = 0; checkYear++; } } return totalBalance; } /** * Open modal for adding/editing entry */ function openModal(entry = null) { const modal = document.getElementById('entryModal'); const modalTitle = document.getElementById('modalTitle'); const dateInput = document.getElementById('modalDate'); const startTimeInput = document.getElementById('modalStartTime'); const endTimeInput = document.getElementById('modalEndTime'); const pauseInput = document.getElementById('modalPause'); const locationInput = document.getElementById('modalLocation'); if (entry) { // Edit mode modalTitle.textContent = 'Eintrag bearbeiten'; currentEditingId = entry.id; dateInput.value = formatDateDisplay(entry.date); startTimeInput.value = entry.startTime; endTimeInput.value = entry.endTime; pauseInput.value = entry.pauseMinutes; locationInput.value = entry.location || 'office'; updateLocationButtons(entry.location || 'office'); } else { // Add mode modalTitle.textContent = 'Neuer Eintrag'; currentEditingId = null; dateInput.value = ''; startTimeInput.value = ''; endTimeInput.value = ''; pauseInput.value = ''; locationInput.value = 'office'; updateLocationButtons('office'); } modal.classList.remove('hidden'); } /** * Open modal with pre-filled date */ function openModalForDate(dateISO) { const modal = document.getElementById('entryModal'); const modalTitle = document.getElementById('modalTitle'); const dateInput = document.getElementById('modalDate'); const startTimeInput = document.getElementById('modalStartTime'); const endTimeInput = document.getElementById('modalEndTime'); const pauseInput = document.getElementById('modalPause'); const locationInput = document.getElementById('modalLocation'); modalTitle.textContent = 'Neuer Eintrag'; currentEditingId = null; dateInput.value = formatDateDisplay(dateISO); startTimeInput.value = '09:00'; endTimeInput.value = '17:30'; pauseInput.value = ''; locationInput.value = 'office'; updateLocationButtons('office'); modal.classList.remove('hidden'); } /** * Update location button states */ function updateLocationButtons(location) { const officeBtn = document.getElementById('btnLocationOffice'); const homeBtn = document.getElementById('btnLocationHome'); if (location === 'home') { officeBtn.className = 'flex-1 px-4 py-3 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 font-medium flex items-center justify-center gap-2'; homeBtn.className = 'flex-1 px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all duration-200 font-medium flex items-center justify-center gap-2 shadow-sm'; } else { officeBtn.className = 'flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium flex items-center justify-center gap-2 shadow-sm'; homeBtn.className = 'flex-1 px-4 py-3 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 font-medium flex items-center justify-center gap-2'; } // Reinitialize Lucide icons after DOM update if (typeof lucide !== 'undefined' && lucide.createIcons) { lucide.createIcons(); } } /** * Close modal */ function closeModal() { const modal = document.getElementById('entryModal'); modal.classList.add('hidden'); currentEditingId = null; } /** * Handle form submission */ async function handleFormSubmit(e) { e.preventDefault(); const date = document.getElementById('modalDate').value; const startTime = document.getElementById('modalStartTime').value; const endTime = document.getElementById('modalEndTime').value; const pauseInput = document.getElementById('modalPause').value; const location = document.getElementById('modalLocation').value; // Convert empty string or "0" to null for auto-calculation const pauseMinutes = (pauseInput === '' || pauseInput === '0' || parseInt(pauseInput) === 0) ? null : parseInt(pauseInput); if (!date || !startTime || !endTime) { showNotification('Bitte alle Felder ausfüllen', 'error'); return; } // Check if date is a public holiday const dateISO = formatDateISO(date); const dateObj = new Date(dateISO + 'T00:00:00'); const holidayName = getHolidayName(dateObj); if (holidayName && !currentEditingId) { showNotification(`❌ An Feiertagen (${holidayName}) können keine Arbeitszeiten eingetragen werden`, 'error'); return; } // Validate max 10h net time const netHours = calculateNetHours(startTime, endTime, pauseMinutes); if (netHours > 10) { showNotification('❌ Maximale Arbeitszeit überschritten! Netto-Arbeitszeit darf maximal 10,0h betragen.', 'error'); return; } if (currentEditingId) { // Update existing entry const success = await updateEntry(currentEditingId, date, startTime, endTime, pauseMinutes, location); if (success) { showNotification('Eintrag aktualisiert', 'success'); closeModal(); loadMonthlyView(); } } else { // Create new entry const success = await createEntry(date, startTime, endTime, pauseMinutes, location); if (success) { showNotification('Eintrag erstellt', 'success'); closeModal(); loadMonthlyView(); } } } /** * Calculate net hours from times and pause */ function calculateNetHours(startTime, endTime, pauseMinutes) { const [startHour, startMin] = startTime.split(':').map(Number); const [endHour, endMin] = endTime.split(':').map(Number); const startMinutes = startHour * 60 + startMin; const endMinutes = endHour * 60 + endMin; let grossMinutes = endMinutes - startMinutes; if (grossMinutes < 0) grossMinutes += 24 * 60; // Handle overnight const grossHours = grossMinutes / 60; // Calculate pause if not provided let pause = pauseMinutes || 0; if (pauseMinutes === null || pauseMinutes === undefined) { if (grossHours >= 9) { pause = 45; } else if (grossHours >= 6) { pause = 30; } } const netHours = grossHours - (pause / 60); return netHours; } /** * Handle delete button click */ async function handleDelete(id) { if (confirm('Möchten Sie diesen Eintrag wirklich löschen?')) { const success = await deleteEntry(id); if (success) { showNotification('Eintrag gelöscht', 'success'); loadMonthlyView(); } } } /** * Handle quick time button click * Calculates end time based on start time and target net hours */ function handleQuickTimeButton(netHours) { const startTimeInput = document.getElementById('modalStartTime'); const endTimeInput = document.getElementById('modalEndTime'); // Get start time (default to 8:30 if empty) let startTime = startTimeInput.value || '08:30'; startTimeInput.value = startTime; // Parse start time const [startHour, startMin] = startTime.split(':').map(Number); const startMinutes = startHour * 60 + startMin; // Calculate required gross hours including pause let pauseMinutes = 0; let grossHours = netHours; if (netHours >= 9) { pauseMinutes = 45; grossHours = netHours + (pauseMinutes / 60); } else if (netHours >= 6) { pauseMinutes = 30; grossHours = netHours + (pauseMinutes / 60); } // Calculate end time const endMinutes = startMinutes + (grossHours * 60); const endHour = Math.floor(endMinutes / 60); const endMin = Math.round(endMinutes % 60); const endTime = `${String(endHour).padStart(2, '0')}:${String(endMin).padStart(2, '0')}`; endTimeInput.value = endTime; } /** * Auto-fill missing entries for current month with 8h net (9:00-17:30) */ async function handleAutoFillMonth() { if (!confirm('Möchten Sie alle fehlenden Arbeitstage im aktuellen Monat mit 8h (9:00-17:30) ausfüllen?\n\nWochenenden und Feiertage werden übersprungen.')) { return; } const today = new Date(); const currentYear = displayYear; const currentMonth = displayMonth; // Get last day of current month const lastDay = new Date(currentYear, currentMonth + 1, 0).getDate(); // Get existing entries for the month const fromDate = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-01`; const toDate = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; const existingEntries = await fetchEntries(fromDate, toDate); // Create a set of dates that already have entries const existingDates = new Set(existingEntries.map(e => e.date)); let created = 0; let skipped = 0; // Iterate through all days in the month for (let day = 1; day <= lastDay; day++) { const dateObj = new Date(currentYear, currentMonth, day); const dateISO = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; // Skip if entry already exists if (existingDates.has(dateISO)) { continue; } // Skip weekends and holidays if (isWeekendOrHoliday(dateObj)) { skipped++; continue; } // Skip future dates if (dateObj > today) { skipped++; continue; } // Create entry: 9:00-17:30 (8h net with 30min pause) const success = await createEntry(formatDateDisplay(dateISO), '09:00', '17:30', null); if (success) { created++; } } // Reload view await reloadView(); showNotification(`✓ ${created} Einträge erstellt, ${skipped} Tage übersprungen`, 'success'); } // ============================================ // BULK EDIT // ============================================ /** * Toggle bulk edit mode */ function toggleBulkEditMode() { bulkEditMode = !bulkEditMode; selectedEntries.clear(); const bulkEditBar = document.getElementById('bulkEditBar'); const checkboxHeader = document.getElementById('checkboxHeader'); const toggleBtn = document.getElementById('btnToggleBulkEdit'); if (bulkEditMode) { bulkEditBar.classList.remove('hidden'); checkboxHeader.classList.remove('hidden'); toggleBtn.className = 'inline-flex items-center gap-2 px-4 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-all duration-200 font-medium shadow-sm hover:shadow'; toggleBtn.title = 'Mehrfachauswahl deaktivieren'; } else { bulkEditBar.classList.add('hidden'); checkboxHeader.classList.add('hidden'); toggleBtn.className = 'inline-flex items-center gap-2 px-4 py-2.5 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 font-medium shadow-sm hover:shadow'; toggleBtn.title = 'Mehrfachauswahl aktivieren'; } // Reload view to show/hide checkboxes reloadView(); } /** * Toggle entry selection */ function toggleEntrySelection(id) { if (selectedEntries.has(id)) { selectedEntries.delete(id); } else { selectedEntries.add(id); } updateSelectedCount(); updateCheckboxes(); } /** * Toggle empty day selection (for dates without entries) */ function toggleEmptyDaySelection(date) { if (selectedEntries.has(date)) { selectedEntries.delete(date); } else { selectedEntries.add(date); } updateSelectedCount(); updateCheckboxes(); } /** * Select all visible entries and empty days */ function selectAllEntries() { // Select existing entries document.querySelectorAll('.entry-checkbox').forEach(checkbox => { const id = parseInt(checkbox.dataset.id); selectedEntries.add(id); checkbox.checked = true; }); // Select empty days document.querySelectorAll('.empty-day-checkbox').forEach(checkbox => { const date = checkbox.dataset.date; selectedEntries.add(date); checkbox.checked = true; }); updateSelectedCount(); } /** * Deselect all entries */ function deselectAllEntries() { selectedEntries.clear(); document.querySelectorAll('.entry-checkbox').forEach(checkbox => { checkbox.checked = false; }); document.querySelectorAll('.empty-day-checkbox').forEach(checkbox => { checkbox.checked = false; }); updateSelectedCount(); } /** * Update selected count display */ function updateSelectedCount() { document.getElementById('selectedCount').textContent = `${selectedEntries.size} ausgewählt`; // Update master checkbox state const masterCheckbox = document.getElementById('masterCheckbox'); const allCheckboxes = document.querySelectorAll('.entry-checkbox, .empty-day-checkbox'); if (allCheckboxes.length === 0) { masterCheckbox.checked = false; masterCheckbox.indeterminate = false; } else if (selectedEntries.size === allCheckboxes.length) { masterCheckbox.checked = true; masterCheckbox.indeterminate = false; } else if (selectedEntries.size > 0) { masterCheckbox.checked = false; masterCheckbox.indeterminate = true; } else { masterCheckbox.checked = false; masterCheckbox.indeterminate = false; } } /** * Update checkbox states */ function updateCheckboxes() { // Update entry checkboxes (ID-based) document.querySelectorAll('.entry-checkbox').forEach(checkbox => { const id = parseInt(checkbox.dataset.id); checkbox.checked = selectedEntries.has(id); }); // Update empty day checkboxes (date-based) document.querySelectorAll('.empty-day-checkbox').forEach(checkbox => { const date = checkbox.dataset.date; checkbox.checked = selectedEntries.has(date); }); updateSelectedCount(); } /** * Handle master checkbox toggle */ function handleMasterCheckboxToggle() { const masterCheckbox = document.getElementById('masterCheckbox'); if (masterCheckbox.checked) { selectAllEntries(); } else { deselectAllEntries(); } } /** * Bulk set location to office */ async function bulkSetLocation(location) { if (selectedEntries.size === 0) { showNotification('Keine Einträge ausgewählt', 'error'); return; } const locationText = location === 'home' ? 'Home Office' : 'Präsenz'; if (!confirm(`Möchten Sie ${selectedEntries.size} Eintrag/Einträge auf "${locationText}" setzen?`)) { return; } let updated = 0; const entries = await fetchEntries(); for (const id of selectedEntries) { const entry = entries.find(e => e.id === id); if (entry) { // Convert date from YYYY-MM-DD to DD.MM.YYYY for updateEntry const formattedDate = formatDateDisplay(entry.date); const success = await updateEntry(id, formattedDate, entry.startTime, entry.endTime, entry.pauseMinutes, location); if (success) { updated++; } } } selectedEntries.clear(); updateSelectedCount(); // Update counter to 0 await reloadView(); showNotification(`✓ ${updated} Eintrag/Einträge aktualisiert`, 'success'); toggleBulkEditMode(); // Close bulk edit mode } /** * Bulk delete entries */ async function bulkDeleteEntries() { if (selectedEntries.size === 0) { showNotification('Keine Einträge ausgewählt', 'error'); return; } if (!confirm(`Möchten Sie wirklich ${selectedEntries.size} Eintrag/Einträge löschen?`)) { return; } let deleted = 0; for (const id of selectedEntries) { const success = await deleteEntry(id); if (success) { deleted++; } } selectedEntries.clear(); updateSelectedCount(); // Update counter to 0 await reloadView(); showNotification(`${deleted} Eintrag/Einträge gelöscht`, 'success'); toggleBulkEditMode(); // Close bulk edit mode } /** * Generate PDF with common template * @param {Object} options - PDF generation options */ async function generatePDF(options) { const { title = 'Zeiterfassung', subtitle, tableData, statistics, additionalInfo = {}, fileName } = options; const { targetHours, totalNetHours, balance } = statistics; const { vacationDays = 0, flextimeDays = 0 } = additionalInfo; // Get employee data from settings const employeeName = await getSetting('employeeName') || ''; const employeeId = await getSetting('employeeId') || ''; // Get jsPDF from window const { jsPDF } = window.jspdf; // Create PDF const doc = new jsPDF('p', 'mm', 'a4'); // Header with statistics doc.setFillColor(15, 23, 42); doc.rect(0, 0, 210, 35, 'F'); // Title and subtitle doc.setTextColor(255, 255, 255); doc.setFontSize(16); doc.setFont(undefined, 'bold'); doc.text(title, 15, 12, { align: 'left' }); doc.setFontSize(11); doc.setFont(undefined, 'normal'); doc.text(subtitle, 195, 12, { align: 'right' }); // Employee info in second line if (employeeName || employeeId) { doc.setFontSize(8); doc.setTextColor(200, 200, 200); let employeeInfo = ''; if (employeeName) employeeInfo += employeeName; if (employeeId) { if (employeeInfo) employeeInfo += ' | '; employeeInfo += `Personal-Nr. ${employeeId}`; } doc.text(employeeInfo, 15, 19, { align: 'left' }); } // Statistics - three columns in one line const statsY = employeeName || employeeId ? 28 : 22; // Soll doc.setTextColor(180, 180, 180); doc.setFontSize(7); doc.text('SOLL-STUNDEN', 40, statsY - 3, { align: 'center' }); doc.setTextColor(255, 255, 255); doc.setFontSize(11); doc.setFont(undefined, 'bold'); doc.text(`${targetHours.toFixed(1)}h`, 40, statsY + 3, { align: 'center' }); // Ist doc.setTextColor(180, 180, 180); doc.setFontSize(7); doc.setFont(undefined, 'normal'); doc.text('IST-STUNDEN', 105, statsY - 3, { align: 'center' }); doc.setTextColor(255, 255, 255); doc.setFontSize(11); doc.setFont(undefined, 'bold'); doc.text(`${totalNetHours.toFixed(1)}h`, 105, statsY + 3, { align: 'center' }); // Saldo doc.setTextColor(180, 180, 180); doc.setFontSize(7); doc.setFont(undefined, 'normal'); doc.text('SALDO', 170, statsY - 3, { align: 'center' }); if (balance >= 0) { doc.setTextColor(34, 197, 94); } else { doc.setTextColor(239, 68, 68); } doc.setFontSize(11); doc.setFont(undefined, 'bold'); doc.text(`${balance >= 0 ? '+' : ''}${balance.toFixed(1)}h`, 170, statsY + 3, { align: 'center' }); // Additional info if needed (small, far right) if (vacationDays > 0 || flextimeDays > 0) { doc.setTextColor(150, 150, 150); doc.setFontSize(7); doc.setFont(undefined, 'normal'); let infoText = ''; if (vacationDays > 0) infoText += `Urlaub: ${vacationDays}`; if (flextimeDays > 0) { if (infoText) infoText += ' '; infoText += `Gleitzeit: ${flextimeDays}`; } doc.text(infoText, 195, statsY + 3, { align: 'right' }); } // Table starts after header let yPos = 37; // Generate table doc.autoTable({ startY: yPos, head: [['Datum', 'Tag', 'Beginn', 'Ende', 'Pause', 'Typ', 'Netto', 'Abw.']], body: tableData, theme: 'grid', tableWidth: 'auto', headStyles: { fillColor: [30, 41, 59], textColor: [255, 255, 255], fontSize: 9, fontStyle: 'bold', halign: 'center', cellPadding: 2.5, minCellHeight: 7 }, bodyStyles: { fillColor: [248, 250, 252], textColor: [15, 23, 42], fontSize: 8, cellPadding: 2, minCellHeight: 6 }, alternateRowStyles: { fillColor: [241, 245, 249] }, columnStyles: { 0: { halign: 'center', cellWidth: 24 }, // Datum 1: { halign: 'center', cellWidth: 14 }, // Tag 2: { halign: 'center', cellWidth: 18 }, // Beginn 3: { halign: 'center', cellWidth: 18 }, // Ende 4: { halign: 'center', cellWidth: 18 }, // Pause 5: { halign: 'center', cellWidth: 26 }, // 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'; } } }, margin: { left: 20, right: 20 } // Smaller margins for wider table }); // 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 doc.save(fileName); } /** * 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 => { // Parse date correctly to avoid timezone issues const [year, month, day] = dateISO.split('-').map(Number); const dateObj = new Date(year, month - 1, day); 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++; // Only add flextime hours if it's on a weekend/holiday // (otherwise it's already counted as workday hours) const dateObj = new Date(entry.date); const isWeekendHoliday = isWeekendOrHoliday(dateObj); if (isWeekendHoliday) { totalNetHours += entry.netHours; } // If flextime on regular workday, don't add hours (already in work entries) } } }); const targetHours = workdaysPassed * 8; const balance = totalNetHours - targetHours; // Build table data 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' : 'Office'; } 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); } // Generate PDF using common template const fileName = `Zeiterfassung_${selectedEntriesData[0].date}_${selectedEntriesData[selectedEntriesData.length - 1].date}.pdf`; await generatePDF({ title: 'Zeiterfassung', subtitle: dateRange, tableData: allDaysData, statistics: { targetHours, totalNetHours, balance }, additionalInfo: { vacationDays, flextimeDays }, 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 */ async function bulkSetVacation() { if (selectedEntries.size === 0) { showNotification('Keine Einträge ausgewählt', 'error'); return; } if (!confirm(`Möchten Sie ${selectedEntries.size} Eintrag/Einträge als Urlaub eintragen?`)) { return; } let created = 0; let updated = 0; let skipped = 0; const entries = await fetchEntries(); for (const item of selectedEntries) { // Check if it's an ID (number) or date (string) if (typeof item === 'number') { // Update existing entry to vacation const entry = entries.find(e => e.id === item); if (entry) { // Skip weekends/holidays const dateObj = new Date(entry.date); if (isWeekendOrHoliday(dateObj)) { skipped++; continue; } try { const response = await fetch(`/api/entries/${item}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: entry.date, entryType: 'vacation' }) }); if (response.ok) { updated++; } } catch (error) { console.error('Error updating entry:', error); } } } else { // Create new vacation entry for empty day (item is date string) // Skip weekends/holidays const dateObj = new Date(item); if (isWeekendOrHoliday(dateObj)) { skipped++; continue; } try { const response = await fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: item, entryType: 'vacation' }) }); if (response.ok) { created++; } } catch (error) { console.error('Error creating vacation entry:', error); } } } selectedEntries.clear(); updateSelectedCount(); // Update counter to 0 await reloadView(); const message = skipped > 0 ? `✓ ${created} Urlaub neu eingetragen, ${updated} Einträge geändert, ${skipped} Wochenenden/Feiertage übersprungen` : `✓ ${created} Urlaub neu eingetragen, ${updated} Einträge geändert`; showNotification(message, 'success'); toggleBulkEditMode(); // Close bulk edit mode } /** * Bulk set flextime entries */ async function bulkSetFlextime() { if (selectedEntries.size === 0) { showNotification('Keine Einträge ausgewählt', 'error'); return; } if (!confirm(`Möchten Sie ${selectedEntries.size} Eintrag/Einträge als Gleitzeit eintragen?`)) { return; } let created = 0; let updated = 0; let skipped = 0; const entries = await fetchEntries(); for (const item of selectedEntries) { // Check if it's an ID (number) or date (string) if (typeof item === 'number') { // Update existing entry to flextime const entry = entries.find(e => e.id === item); if (entry) { // Skip weekends/holidays const dateObj = new Date(entry.date); if (isWeekendOrHoliday(dateObj)) { skipped++; continue; } try { const response = await fetch(`/api/entries/${item}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: entry.date, entryType: 'flextime' }) }); if (response.ok) { updated++; } } catch (error) { console.error('Error updating entry:', error); } } } else { // Create new flextime entry for empty day (item is date string) // Skip weekends/holidays const dateObj = new Date(item); if (isWeekendOrHoliday(dateObj)) { skipped++; continue; } try { const response = await fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: item, entryType: 'flextime' }) }); if (response.ok) { created++; } } catch (error) { console.error('Error creating flextime entry:', error); } } } selectedEntries.clear(); updateSelectedCount(); // Update counter to 0 await reloadView(); const message = skipped > 0 ? `✓ ${created} Gleitzeit neu eingetragen, ${updated} Einträge geändert, ${skipped} Wochenenden/Feiertage übersprungen` : `✓ ${created} Gleitzeit neu eingetragen, ${updated} Einträge geändert`; showNotification(message, 'success'); toggleBulkEditMode(); // Close bulk edit mode } // ============================================ // BUNDESLAND / SETTINGS // ============================================ /** * Handle Bundesland change */ async function handleBundeslandChange(event) { const newBundesland = event.target.value; const oldBundesland = currentBundesland; // Check for conflicts with existing entries first const entries = await fetchEntries(); const conflicts = []; // Get old and new holidays for comparison const currentYear = new Date().getFullYear(); const years = [currentYear - 1, currentYear, currentYear + 1]; const oldHolidays = new Set(); const newHolidays = new Set(); years.forEach(year => { getPublicHolidays(year, oldBundesland).forEach(h => { // Convert to YYYY-MM-DD format for comparison const year = h.date.getFullYear(); const month = String(h.date.getMonth() + 1).padStart(2, '0'); const day = String(h.date.getDate()).padStart(2, '0'); oldHolidays.add(`${year}-${month}-${day}`); }); getPublicHolidays(year, newBundesland).forEach(h => { // Convert to YYYY-MM-DD format for comparison const year = h.date.getFullYear(); const month = String(h.date.getMonth() + 1).padStart(2, '0'); const day = String(h.date.getDate()).padStart(2, '0'); const dateKey = `${year}-${month}-${day}`; newHolidays.add(dateKey); }); }); // Create a map of new holidays with their names const newHolidayMap = new Map(); years.forEach(year => { getPublicHolidays(year, newBundesland).forEach(h => { const year = h.date.getFullYear(); const month = String(h.date.getMonth() + 1).padStart(2, '0'); const day = String(h.date.getDate()).padStart(2, '0'); const dateKey = `${year}-${month}-${day}`; newHolidayMap.set(dateKey, h.name); }); }); // Find dates that are holidays in new state but not in old state, and have entries entries.forEach(entry => { if (newHolidays.has(entry.date) && !oldHolidays.has(entry.date)) { conflicts.push({ date: entry.date, displayDate: formatDateDisplay(entry.date), holidayName: newHolidayMap.get(entry.date) }); } }); // If no conflicts, change directly without warning if (conflicts.length === 0) { await performBundeslandChange(newBundesland, oldBundesland, event); return; } // Show warning with backup recommendation only if conflicts exist const bundeslandNames = { 'BW': 'Baden-Württemberg', 'BY': 'Bayern', 'BE': 'Berlin', 'BB': 'Brandenburg', 'HB': 'Bremen', 'HH': 'Hamburg', 'HE': 'Hessen', 'MV': 'Mecklenburg-Vorpommern', 'NI': 'Niedersachsen', 'NW': 'Nordrhein-Westfalen', 'RP': 'Rheinland-Pfalz', 'SL': 'Saarland', 'SN': 'Sachsen', 'ST': 'Sachsen-Anhalt', 'SH': 'Schleswig-Holstein', 'TH': 'Thüringen' }; const conflictList = conflicts.map(c => `
  • • ${c.displayDate} (${c.holidayName})
  • `).join(''); // Create custom modal for bundesland change confirmation const modalHTML = `

    Achtung: Konflikte gefunden

    Sie möchten das Bundesland von ${bundeslandNames[oldBundesland]} auf ${bundeslandNames[newBundesland]} ändern.

    Warnung: Die folgenden Tage werden zu Feiertagen und haben bereits Einträge:

      ${conflictList}

    Die Einträge bleiben erhalten, aber die Tage werden als Feiertage markiert.

    Wir empfehlen ein Backup vor der Änderung:

    `; // Insert modal into DOM const modalContainer = document.createElement('div'); modalContainer.innerHTML = modalHTML; document.body.appendChild(modalContainer); // Initialize icons in modal if (typeof lucide !== 'undefined' && lucide.createIcons) { lucide.createIcons(); } // Handle backup button document.getElementById('quickBackupBtn').addEventListener('click', async () => { await exportDatabase(); showNotification('✓ Backup erstellt', 'success'); }); // Handle cancel document.getElementById('cancelBundeslandChange').addEventListener('click', () => { event.target.value = oldBundesland; document.getElementById('bundeslandWarningModal').remove(); }); // Handle confirm document.getElementById('confirmBundeslandChange').addEventListener('click', async () => { document.getElementById('bundeslandWarningModal').remove(); await performBundeslandChange(newBundesland, oldBundesland, event); }); } /** * Perform the actual bundesland change after confirmation */ async function performBundeslandChange(newBundesland, oldBundesland, event) { const bundeslandNames = { 'BW': 'Baden-Württemberg', 'BY': 'Bayern', 'BE': 'Berlin', 'BB': 'Brandenburg', 'HB': 'Bremen', 'HH': 'Hamburg', 'HE': 'Hessen', 'MV': 'Mecklenburg-Vorpommern', 'NI': 'Niedersachsen', 'NW': 'Nordrhein-Westfalen', 'RP': 'Rheinland-Pfalz', 'SL': 'Saarland', 'SN': 'Sachsen', 'ST': 'Sachsen-Anhalt', 'SH': 'Schleswig-Holstein', 'TH': 'Thüringen' }; // Update state and save currentBundesland = newBundesland; await setSetting('bundesland', newBundesland); // Reload view to show updated holidays await reloadView(); showNotification(`✓ Bundesland auf ${bundeslandNames[newBundesland]} gesetzt`, 'success'); } /** * Load saved settings */ async function loadSettings() { const savedBundesland = await getSetting('bundesland'); if (savedBundesland) { currentBundesland = savedBundesland; document.getElementById('bundeslandSelect').value = savedBundesland; } const savedVacationDays = await getSetting('vacationDays'); if (savedVacationDays) { 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; } const savedCompanyHoliday = await getSetting('companyHoliday'); if (savedCompanyHoliday) { companyHolidayPreference = savedCompanyHoliday; if (savedCompanyHoliday === 'christmas') { document.getElementById('companyHolidayChristmas').checked = true; } else if (savedCompanyHoliday === 'newyearseve') { document.getElementById('companyHolidayNewYear').checked = true; } } // Load target hours (always restore dropdown value) const savedTargetHours = await getSetting('targetHours'); if (savedTargetHours) { targetHours = parseInt(savedTargetHours); document.getElementById('targetHoursSelect').value = targetHours; } } /** * Load and display version info */ async function loadVersionInfo() { try { const response = await fetch('/api/version'); if (!response.ok) return; const versionData = await response.json(); const versionEl = document.getElementById('versionInfo'); if (versionEl && versionData.commit) { const shortHash = versionData.commit.substring(0, 7); versionEl.textContent = shortHash !== 'dev' ? shortHash : 'dev'; versionEl.title = `Commit: ${versionData.commit}\nBuild: ${versionData.buildDate}`; } } catch (error) { console.log('Could not load version info:', error); } } /** * Handle vacation days input change */ async function handleVacationDaysChange(event) { const newValue = parseInt(event.target.value); if (isNaN(newValue) || newValue < 0 || newValue > 50) { showNotification('Bitte geben Sie eine gültige Anzahl (0-50) ein', 'error'); event.target.value = totalVacationDays; return; } // Check for conflicts: More vacation days already taken/planned than new value? const currentYear = new Date().getFullYear(); const fromDate = `${currentYear}-01-01`; const toDate = `${currentYear}-12-31`; try { const allEntries = await fetchEntries(fromDate, toDate); const vacationCount = allEntries.filter(e => e.entryType === 'vacation').length; if (vacationCount > newValue) { // Show warning const difference = vacationCount - newValue; const confirmed = confirm( `⚠️ Warnung: Konflikt erkannt!\n\n` + `Sie haben bereits ${vacationCount} Urlaubstage für ${currentYear} eingetragen.\n` + `Der neue Wert von ${newValue} Tag${newValue !== 1 ? 'en' : ''} ist ${difference} Tag${difference > 1 ? 'e' : ''} zu niedrig.\n\n` + `Möchten Sie den Wert trotzdem auf ${newValue} setzen?` ); if (!confirmed) { event.target.value = totalVacationDays; return; } } } catch (error) { console.error('Error checking vacation conflicts:', error); } totalVacationDays = newValue; await setSetting('vacationDays', newValue.toString()); await updateVacationStatistics(); showNotification(`✓ Urlaubstage auf ${newValue} pro Jahr gesetzt`, 'success'); } /** * Handle company holiday preference change */ async function handleCompanyHolidayChange(event) { const newPreference = event.target.value; // Check if there are any entries on the affected dates const currentYear = new Date().getFullYear(); const years = [currentYear - 1, currentYear, currentYear + 1]; let affectedDate, affectedDateDisplay, holidayName; if (newPreference === 'christmas') { affectedDate = '12-24'; affectedDateDisplay = '24.12.'; holidayName = 'Heiligabend'; } else { affectedDate = '12-31'; affectedDateDisplay = '31.12.'; holidayName = 'Silvester'; } // Check for conflicts across multiple years const conflicts = []; try { const allEntries = await fetchEntries(); years.forEach(year => { const dateToCheck = `${year}-${affectedDate}`; const hasEntry = allEntries.some(e => e.date === dateToCheck); if (hasEntry) { conflicts.push(`${affectedDateDisplay}${year}`); } }); } catch (error) { console.error('Error checking conflicts:', error); } // Show warning if there are existing entries on the new holiday date if (conflicts.length > 0) { const confirmed = confirm( `⚠️ Hinweis: Auf ${holidayName} existieren bereits Einträge:\n\n` + conflicts.join(', ') + '\n\n' + `Diese Tage werden nun als betriebsfrei markiert.\n\n` + `Möchten Sie fortfahren?` ); if (!confirmed) { // Revert the radio button selection if (newPreference === 'christmas') { document.getElementById('companyHolidayNewYear').checked = true; } else { document.getElementById('companyHolidayChristmas').checked = true; } return; } } // Update setting companyHolidayPreference = newPreference; await setSetting('companyHoliday', newPreference); // Reload view to show updated holidays await reloadView(); const holidayLabel = newPreference === 'christmas' ? 'Heiligabend (24.12.)' : 'Silvester (31.12.)'; showNotification(`✓ Betriebsfrei auf ${holidayLabel} gesetzt`, 'success'); } // ============================================ // INLINE EDITING // ============================================ /** * Attach inline edit listeners to all editable cells */ function attachInlineEditListeners() { document.querySelectorAll('.editable-cell').forEach(cell => { cell.addEventListener('click', handleCellClick); }); } /** * Handle cell click for inline editing */ function handleCellClick(e) { const cell = e.target; // Prevent editing if already editing if (cell.classList.contains('editing')) { return; } const field = cell.dataset.field; const id = parseInt(cell.dataset.id); const currentValue = cell.dataset.value; // Create input cell.classList.add('editing'); const originalContent = cell.innerHTML; let input; let picker; if (field === 'pauseMinutes') { input = document.createElement('input'); input.type = 'number'; input.min = '0'; input.step = '1'; input.className = 'cell-input'; input.value = currentValue; } else { // Time fields - use Flatpickr input = document.createElement('input'); input.type = 'text'; input.className = 'cell-input'; input.value = currentValue; input.placeholder = 'HH:MM'; } cell.innerHTML = ''; cell.appendChild(input); input.focus(); // Initialize Flatpickr for time fields if (field === 'startTime' || field === 'endTime') { picker = flatpickr(input, { enableTime: true, noCalendar: true, dateFormat: 'H:i', time_24hr: true, minuteIncrement: 15, allowInput: true, locale: 'de', defaultHour: parseInt(currentValue.split(':')[0]) || 9, defaultMinute: parseInt(currentValue.split(':')[1]) || 0, onOpen: function(selectedDates, dateStr, instance) { if (currentValue && currentValue.match(/^\d{1,2}:\d{2}$/)) { instance.setDate(currentValue, false); } }, onClose: function(selectedDates, dateStr, instance) { if (dateStr) { input.value = dateStr; } // Trigger save after picker closes setTimeout(() => saveEdit(), 100); } }); // Open picker immediately setTimeout(() => picker.open(), 50); } else { input.select(); } // Save on blur or Enter const saveEdit = async () => { // Destroy picker if exists if (picker) { picker.destroy(); } const newValue = input.value.trim(); if (newValue === currentValue) { // No change cell.classList.remove('editing'); cell.innerHTML = originalContent; return; } // Validate if (field !== 'pauseMinutes') { // Validate time format if (!/^\d{1,2}:\d{2}$/.test(newValue)) { showNotification('Ungültiges Format. Bitte HH:MM verwenden.', 'error'); cell.classList.remove('editing'); cell.innerHTML = originalContent; return; } } // Get current entry data const row = cell.closest('tr'); const entryId = parseInt(row.dataset.id); // Find date from row (skip weekday column, get actual date column) const dateCells = row.querySelectorAll('td'); const dateText = dateCells[1].textContent; // Second column is date // Get all values from row const cells = row.querySelectorAll('.editable-cell'); const values = {}; cells.forEach(c => { const f = c.dataset.field; if (c === cell) { values[f] = newValue; } else { values[f] = c.dataset.value; } }); // If startTime or endTime changed, set pauseMinutes to null for auto-calculation // If pauseMinutes was edited, keep the manual value let pauseToSend = values.pauseMinutes; if (field === 'startTime' || field === 'endTime') { pauseToSend = null; // Trigger auto-calculation on server } // Validate max 10h net time const netHours = calculateNetHours(values.startTime, values.endTime, pauseToSend); if (netHours > 10) { showNotification('❌ Maximale Arbeitszeit überschritten! Netto-Arbeitszeit darf maximal 10,0h betragen.', 'error'); cell.classList.remove('editing'); cell.innerHTML = originalContent; return; } // Update via API const success = await updateEntry( entryId, dateText, values.startTime, values.endTime, pauseToSend ); if (success) { // Update cell cell.dataset.value = newValue; cell.classList.remove('editing'); cell.textContent = newValue; // Reload to update calculated values loadMonthlyView(); } else { // Revert on error cell.classList.remove('editing'); cell.innerHTML = originalContent; } }; // Event listeners - only if not a time picker if (field === 'pauseMinutes') { input.addEventListener('blur', saveEdit); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); saveEdit(); } else if (e.key === 'Escape') { cell.classList.remove('editing'); cell.innerHTML = originalContent; } }); } else { // For time fields, save is handled by Flatpickr onClose input.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (picker) picker.destroy(); cell.classList.remove('editing'); cell.innerHTML = originalContent; } }); } } /** * Handle filter button click */ function handleFilter() { const fromValue = document.getElementById('filterFrom').value; const toValue = document.getElementById('filterTo').value; const fromDate = fromValue ? formatDateISO(fromValue) : null; const toDate = toValue ? formatDateISO(toValue) : null; currentView = 'filter'; currentFilterFrom = fromDate; currentFilterTo = toDate; hideMonthNavigation(); // Hide bridge days recommendations in filter view const bridgeDaysContainer = document.getElementById('bridgeDaysContainer'); if (bridgeDaysContainer) { bridgeDaysContainer.classList.add('hidden'); } loadEntries(fromDate, toDate); } /** * Handle clear filter button click */ function handleClearFilter() { document.getElementById('filterFrom').value = ''; document.getElementById('filterTo').value = ''; currentView = 'monthly'; currentFilterFrom = null; currentFilterTo = null; loadMonthlyView(); } /** * Handle export button click */ async function handleExport(onlyDeviations = false) { const fromValue = document.getElementById('filterFrom').value; const toValue = document.getElementById('filterTo').value; // Validate that both dates are set if (!fromValue || !toValue) { showNotification('Bitte beide Datumsfelder ausfüllen', 'error'); return; } const fromDate = formatDateISO(fromValue); const toDate = formatDateISO(toValue); try { // Fetch entries for the date range let entries = await fetchEntries(fromDate, toDate); // Filter entries if only deviations are requested if (onlyDeviations) { entries = entries.filter(entry => Math.abs(entry.netHours - 8.0) > 0.01); } // Check if there are any entries if (entries.length === 0) { showNotification( onlyDeviations ? 'Keine Abweichungen vom 8h-Standard im gewählten Zeitraum.' : 'Keine Einträge im gewählten Zeitraum.', 'error' ); return; } // Create CSV content const csvHeader = 'Datum;Beginn;Ende;Pause (min);Netto (h);Arbeitsort;Abweichung (h)\n'; const csvRows = entries.map(entry => { const deviation = entry.netHours - 8.0; const deviationStr = (deviation >= 0 ? '+' : '') + deviation.toFixed(2); const locationText = entry.location === 'home' ? 'Home' : 'Büro'; return `${entry.date};${entry.startTime};${entry.endTime};${entry.pauseMinutes};${entry.netHours.toFixed(2)};${locationText};${deviationStr}`; }).join('\n'); const csvContent = csvHeader + csvRows; // Create download link const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); const url = URL.createObjectURL(blob); const fileName = onlyDeviations ? `zeiterfassung_abweichungen_${fromDate}_${toDate}.csv` : `zeiterfassung_${fromDate}_${toDate}.csv`; link.setAttribute('href', url); link.setAttribute('download', fileName); link.style.visibility = 'hidden'; document.body.appendChild(link); link.click(); document.body.removeChild(link); const message = onlyDeviations ? `${entries.length} Abweichung(en) exportiert` : `${entries.length} Eintrag/Einträge exportiert`; showNotification(message, 'success'); } catch (error) { console.error('Error exporting CSV:', error); showNotification('Fehler beim CSV-Export', 'error'); } } /** * 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(); today.setHours(23, 59, 59, 999); // Set to end of day to include today // Find the last entry date to calculate workdays up to (parse correctly to avoid timezone issues) let lastEntryDate = today; if (entries.length > 0) { const sortedEntries = entries.sort((a, b) => b.date.localeCompare(a.date)); const lastDateStr = sortedEntries[0].date; // "2025-10-22" const [year, month, day] = lastDateStr.split('-').map(Number); lastEntryDate = new Date(year, month - 1, day, 23, 59, 59, 999); } // Count workdays (excluding weekends and holidays, up to last entry or today, whichever is later) const countUntil = lastEntryDate > today ? lastEntryDate : 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 <= countUntil) { workdaysPassed++; } } else if (isFlextime && isWeekendHoliday) { // Flextime on weekend/holiday counts as additional workday totalWorkdaysInMonth++; if (new Date(dateISO) <= countUntil) { workdaysPassed++; } } // Vacation days are excluded from all counts } let totalNetHours = 0; let vacationDays = 0; let flextimeDays = 0; let workEntriesCount = 0; // Create map of entries by date for proper handling const entriesByDate = new Map(); entries.forEach(entry => { if (!entriesByDate.has(entry.date)) { entriesByDate.set(entry.date, []); } entriesByDate.get(entry.date).push(entry); }); entries.forEach(entry => { const entryDate = new Date(entry.date); if (entryDate <= countUntil) { if (!entry.entryType || entry.entryType === 'work') { totalNetHours += entry.netHours; workEntriesCount++; } else if (entry.entryType === 'vacation') { vacationDays++; // Vacation hours are already included in netHours (8h per day typically) totalNetHours += entry.netHours; } else if (entry.entryType === 'flextime') { flextimeDays++; // Only add flextime hours if it's on a weekend/holiday // (otherwise it's already counted as workday hours) const dateObj = new Date(entry.date); const isWeekendHoliday = isWeekendOrHoliday(dateObj); if (isWeekendHoliday) { totalNetHours += entry.netHours; } // If flextime on regular workday, hours are already counted in work entries } } }); // 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; // Build table data 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' : 'Office'; } 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 } // Generate PDF using common template const fileName = `Zeiterfassung_${monthName.replace(' ', '_')}.pdf`; await generatePDF({ title: 'Zeiterfassung', subtitle: monthName, tableData: allDaysData, statistics: { targetHours, totalNetHours, balance: monthBalance }, additionalInfo: { vacationDays, flextimeDays }, fileName }); showNotification(`PDF für ${monthName} erstellt`, 'success'); } catch (error) { console.error('Error exporting PDF:', error); showNotification('Fehler beim PDF-Export', 'error'); } } /** * Export entire database */ async function exportDatabase() { try { // Fetch all data const entries = await fetchEntries(); const settings = { employeeName: await getSetting('employeeName') || '', employeeId: await getSetting('employeeId') || '', bundesland: await getSetting('bundesland') || 'NW', vacationDaysPerYear: await getSetting('vacationDaysPerYear') || 30 }; // Create export object const exportData = { version: '1.0', exportDate: new Date().toISOString(), entries: entries, settings: settings }; // Convert to JSON const jsonString = JSON.stringify(exportData, null, 2); const blob = new Blob([jsonString], { type: 'application/json' }); // Create download link const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `zeiterfassung_backup_${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showNotification(`Datenbank exportiert: ${entries.length} Einträge`, 'success'); } catch (error) { console.error('Error exporting database:', error); showNotification('Fehler beim Exportieren der Datenbank', 'error'); } } /** * Import entire database */ async function importDatabase(file) { try { const text = await file.text(); const importData = JSON.parse(text); // Validate data structure if (!importData.entries || !Array.isArray(importData.entries)) { throw new Error('Ungültiges Datenbankformat'); } // Confirm before overwriting const confirmed = confirm( `Möchten Sie die Datenbank wirklich importieren?\n\n` + `Dies wird alle ${importData.entries.length} Einträge importieren und vorhandene Daten überschreiben.\n\n` + `Export-Datum: ${new Date(importData.exportDate).toLocaleString('de-DE')}` ); if (!confirmed) { return; } // Delete all existing entries const existingEntries = await fetchEntries(); for (const entry of existingEntries) { await fetch(`/api/entries/${entry.id}`, { method: 'DELETE' }); } // Import entries let imported = 0; for (const entry of importData.entries) { try { // Remove ID to create new entries const { id, ...entryData } = entry; await fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(entryData) }); imported++; } catch (err) { console.error('Error importing entry:', err); } } // Import settings if (importData.settings) { await setSetting('employeeName', importData.settings.employeeName || ''); await setSetting('employeeId', importData.settings.employeeId || ''); await setSetting('bundesland', importData.settings.bundesland || 'NW'); await setSetting('vacationDaysPerYear', importData.settings.vacationDaysPerYear || 30); await loadSettings(); // Reload settings to UI } // Reload view await reloadView(); showNotification(`Datenbank importiert: ${imported} Einträge`, 'success'); } catch (error) { console.error('Error importing database:', error); showNotification('Fehler beim Importieren der Datenbank: ' + error.message, 'error'); } } // ============================================ // FLATPICKR INITIALIZATION // ============================================ /** * Initialize all Flatpickr instances with optimized settings */ function initializeFlatpickr() { // German locale flatpickr.localize(flatpickr.l10ns.de); // Date pickers - using DD.MM.YYYY format const dateConfig = { dateFormat: 'd.m.Y', locale: 'de', allowInput: true }; filterFromPicker = flatpickr('#filterFrom', dateConfig); filterToPicker = flatpickr('#filterTo', dateConfig); datePicker = flatpickr('#modalDate', dateConfig); // Time pickers with enhanced configuration // For a better "tumbler" experience on desktop, we use: // - enableTime: true // - noCalendar: true (time only) // - time_24hr: true (24-hour format) // - minuteIncrement: 15 (15-minute steps) // - Mobile browsers will show native time picker automatically const timeConfig = { enableTime: true, noCalendar: true, dateFormat: 'H:i', time_24hr: true, minuteIncrement: 15, allowInput: true, locale: 'de', // Mobile devices will use native picker which has wheel/tumbler interface // Desktop will use Flatpickr's time picker with improved UX static: false, // When opening the picker, use the current value in the input field onOpen: function(selectedDates, dateStr, instance) { const inputValue = instance.input.value; if (inputValue && inputValue.match(/^\d{1,2}:\d{2}$/)) { instance.setDate(inputValue, false); } } }; startTimePicker = flatpickr('#modalStartTime', timeConfig); endTimePicker = flatpickr('#modalEndTime', timeConfig); // Initialize manual start time picker manualStartTimePicker = flatpickr('#manualTimeInput', { enableTime: true, noCalendar: true, dateFormat: 'H:i', time_24hr: true, minuteIncrement: 15, allowInput: true, locale: 'de', defaultHour: 9, defaultMinute: 0, onOpen: function(selectedDates, dateStr, instance) { const inputValue = instance.input.value; if (inputValue && inputValue.match(/^\d{1,2}:\d{2}$/)) { instance.setDate(inputValue, false); } } }); } /** * Handle manual start time input */ async function handleManualStartTime(timeStr) { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; // Parse the time const [hours, minutes] = timeStr.split(':').map(Number); const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, minutes, 0); // Check if entry for today already exists const entries = await fetchEntries(today, today); if (entries.length > 0) { showNotification('Für heute existiert bereits ein Eintrag', 'error'); return; } // Create entry with same start and end time (indicates running) const entry = await createEntry(formatDateDisplay(today), timeStr, timeStr, null); if (!entry) { return; } currentEntryId = entry.id; timerStartTime = startDate.getTime(); timerStartTimeString = timeStr; timerPausedDuration = 0; isPaused = false; // Save current target hours await setSetting('targetHours', targetHours); // Update UI const startBtn = document.getElementById('btnStartWork'); const stopBtn = document.getElementById('btnStopWork'); startBtn.disabled = true; startBtn.classList.add('opacity-50', 'cursor-not-allowed'); startBtn.classList.remove('hover:bg-green-700'); stopBtn.disabled = false; stopBtn.classList.remove('opacity-50', 'cursor-not-allowed'); stopBtn.classList.add('hover:bg-red-700'); setTimerStatus('Läuft seit ' + timeStr, true); // 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); setTimerStatus(`Läuft seit ${timeStr} - Pause (${Math.ceil(remainingPause / 60)} Min)`, true); // Schedule end of pause pauseTimeout = setTimeout(() => { timerPausedDuration = thirtyMinutes; isPaused = false; pauseStartElapsed = 0; pauseEndTime = 0; setTimerStatus('Läuft seit ' + timeStr, true); }, 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); setTimerStatus(`Läuft seit ${timeStr} - Pause (${Math.ceil(remainingPause / 60)} Min)`, true); // Schedule end of pause pauseTimeout = setTimeout(() => { timerPausedDuration = thirtyMinutes + fifteenMinutes; isPaused = false; pauseStartElapsed = 0; pauseEndTime = 0; setTimerStatus('Läuft seit ' + timeStr, true); }, 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 schedulePausesWithOffset(elapsed); // Reload view to show entry await loadMonthlyView(); showNotification(`Timer gestartet ab ${timeStr}`, 'success'); } // ============================================ // EVENT LISTENERS // ============================================ 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); // Master checkbox document.getElementById('masterCheckbox').addEventListener('change', handleMasterCheckboxToggle); // Bulk edit actions document.getElementById('btnSelectAll').addEventListener('click', selectAllEntries); document.getElementById('btnDeselectAll').addEventListener('click', deselectAllEntries); document.getElementById('btnBulkSetOffice').addEventListener('click', () => bulkSetLocation('office')); document.getElementById('btnBulkSetHome').addEventListener('click', () => bulkSetLocation('home')); 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); // Location buttons document.getElementById('btnLocationOffice').addEventListener('click', () => { document.getElementById('modalLocation').value = 'office'; updateLocationButtons('office'); }); document.getElementById('btnLocationHome').addEventListener('click', () => { document.getElementById('modalLocation').value = 'home'; updateLocationButtons('home'); }); // Form submission document.getElementById('entryForm').addEventListener('submit', handleFormSubmit); // Filter buttons document.getElementById('btnFilter').addEventListener('click', handleFilter); document.getElementById('btnClearFilter').addEventListener('click', handleClearFilter); // Month navigation document.getElementById('btnPrevMonth').addEventListener('click', handlePrevMonth); document.getElementById('btnNextMonth').addEventListener('click', handleNextMonth); // Export buttons document.getElementById('btnExport').addEventListener('click', () => handleExport(false)); document.getElementById('btnExportDeviations').addEventListener('click', () => handleExport(true)); // Timer buttons document.getElementById('btnStartWork').addEventListener('click', startWork); document.getElementById('btnStopWork').addEventListener('click', stopWork); // Target hours selector document.getElementById('targetHoursSelect').addEventListener('change', async (e) => { targetHours = parseInt(e.target.value); // Save target hours if timer is running if (timerStartTime) { await setSetting('targetHours', targetHours); const elapsed = Math.floor((Date.now() - timerStartTime) / 1000) - timerPausedDuration; updateTimerMetrics(elapsed); } }); // Timer status - manual start time entry document.getElementById('timerStatus').addEventListener('click', () => { if (!timerStartTime) { // Show custom time picker modal document.getElementById('manualTimePickerModal').classList.remove('hidden'); // Set default time to 09:00 manualStartTimePicker.setDate('09:00', false); } }); // Manual time link - opens time picker when timer is not running document.getElementById('manualTimeLink').addEventListener('click', () => { // Show custom time picker modal document.getElementById('manualTimePickerModal').classList.remove('hidden'); // Set default time to 09:00 manualStartTimePicker.setDate('09:00', false); }); // Manual time picker - Confirm button document.getElementById('btnConfirmManualTime').addEventListener('click', async () => { const timeInput = document.getElementById('manualTimeInput'); const timeStr = timeInput.value; // Format: HH:MM from Flatpickr if (timeStr && timeStr.match(/^\d{1,2}:\d{2}$/)) { await handleManualStartTime(timeStr); document.getElementById('manualTimePickerModal').classList.add('hidden'); } else { showNotification('Bitte wählen Sie eine gültige Zeit aus', 'error'); } }); // Manual time picker - Cancel button document.getElementById('btnCancelManualTime').addEventListener('click', () => { document.getElementById('manualTimePickerModal').classList.add('hidden'); }); // Close manual time picker when clicking outside document.getElementById('manualTimePickerModal').addEventListener('click', (e) => { if (e.target.id === 'manualTimePickerModal') { document.getElementById('manualTimePickerModal').classList.add('hidden'); } }); // Bundesland selection document.getElementById('bundeslandSelect').addEventListener('change', handleBundeslandChange); // Vacation days input document.getElementById('vacationDaysInput').addEventListener('change', handleVacationDaysChange); // Company holiday preference document.getElementById('companyHolidayChristmas').addEventListener('change', handleCompanyHolidayChange); document.getElementById('companyHolidayNewYear').addEventListener('change', handleCompanyHolidayChange); // 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'); }); // Database export/import document.getElementById('btnExportDB').addEventListener('click', exportDatabase); document.getElementById('btnImportDB').addEventListener('click', () => { document.getElementById('importDBFile').click(); }); document.getElementById('importDBFile').addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { importDatabase(file); e.target.value = ''; // Reset file input } }); // Close modal when clicking outside document.getElementById('entryModal').addEventListener('click', (e) => { if (e.target.id === 'entryModal') { closeModal(); } }); // Quick time buttons document.querySelectorAll('.quick-time-btn').forEach(btn => { btn.addEventListener('click', () => handleQuickTimeButton(parseInt(btn.dataset.hours))); }); } // ============================================ // INITIALIZATION // ============================================ document.addEventListener('DOMContentLoaded', async () => { initializeFlatpickr(); initializeEventListeners(); await loadSettings(); // Load saved settings first await loadVersionInfo(); // Load version info checkRunningTimer(); // Check if timer was running loadMonthlyView(); // Load monthly view by default });