// ============================================ // STATE & VARIABLES // ============================================ let currentEditingId = null; let datePicker = null; let startTimePicker = null; let endTimePicker = null; let filterFromPicker = null; let filterToPicker = null; // Timer state let timerInterval = null; let timerStartTime = null; let timerPausedDuration = 0; // Total paused time in seconds let isPaused = false; let pauseTimeout = null; let currentEntryId = null; // ID of today's entry being timed // Current month display state let displayYear = new Date().getFullYear(); let displayMonth = new Date().getMonth(); // 0-11 // Bulk edit state let bulkEditMode = false; let selectedEntries = new Set(); // ============================================ // UTILITY FUNCTIONS // ============================================ /** * Format date from YYYY-MM-DD to DD.MM.YYYY */ function formatDateDisplay(dateStr) { const [year, month, day] = dateStr.split('-'); return `${day}.${month}.${year}`; } /** * Format date from DD.MM.YYYY to YYYY-MM-DD */ function formatDateISO(dateStr) { const [day, month, year] = dateStr.split('.'); return `${year}-${month}-${day}`; } /** * Get today's date in YYYY-MM-DD format */ function getTodayISO() { const today = new Date(); const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } /** * Round time down to nearest 15 minutes */ function roundDownTo15Min(date) { const minutes = date.getMinutes(); const roundedMinutes = Math.floor(minutes / 15) * 15; date.setMinutes(roundedMinutes); date.setSeconds(0); date.setMilliseconds(0); return date; } /** * Round time up to nearest 15 minutes */ function roundUpTo15Min(date) { const minutes = date.getMinutes(); const roundedMinutes = Math.ceil(minutes / 15) * 15; date.setMinutes(roundedMinutes); date.setSeconds(0); date.setMilliseconds(0); return date; } /** * Format time as HH:MM */ function formatTime(date) { const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${hours}:${minutes}`; } /** * Format seconds to HH:MM:SS */ function formatDuration(seconds) { const hrs = Math.floor(seconds / 3600); const mins = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; } /** * Show toast notification */ function showNotification(message, type = 'info') { const container = document.getElementById('toastContainer'); // Create toast element const toast = document.createElement('div'); toast.className = `toast toast-${type}`; // Icon based on type const icons = { success: '✓', error: '✕', info: 'ℹ' }; toast.innerHTML = ` ${message} `; container.appendChild(toast); // Auto-remove after 3 seconds setTimeout(() => { toast.classList.add('hiding'); setTimeout(() => { container.removeChild(toast); }, 300); }, 3000); } /** * Get day of week abbreviation in German */ function getDayOfWeek(date) { const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; return days[date.getDay()]; } /** * Check if date is weekend */ function isWeekend(date) { const day = date.getDay(); return day === 0 || day === 6; // Sunday or Saturday } /** * Calculate Easter Sunday for a given year (Gauss algorithm) */ function getEasterSunday(year) { const a = year % 19; const b = Math.floor(year / 100); const c = year % 100; const d = Math.floor(b / 4); const e = b % 4; const f = Math.floor((b + 8) / 25); const g = Math.floor((b - f + 1) / 3); const h = (19 * a + b - d - g + 15) % 30; const i = Math.floor(c / 4); const k = c % 4; const l = (32 + 2 * e + 2 * i - h - k) % 7; const m = Math.floor((a + 11 * h + 22 * l) / 451); const month = Math.floor((h + l - 7 * m + 114) / 31); const day = ((h + l - 7 * m + 114) % 31) + 1; return new Date(year, month - 1, day); } /** * Get all public holidays for Baden-Württemberg for a given year */ function getPublicHolidays(year) { const holidays = []; // Fixed holidays holidays.push({ date: new Date(year, 0, 1), name: 'Neujahr' }); holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' }); holidays.push({ date: new Date(year, 4, 1), name: 'Tag der Arbeit' }); holidays.push({ date: new Date(year, 9, 3), name: 'Tag der Deutschen Einheit' }); holidays.push({ date: new Date(year, 10, 1), name: 'Allerheiligen' }); holidays.push({ date: new Date(year, 11, 25), name: '1. Weihnachtstag' }); holidays.push({ date: new Date(year, 11, 26), name: '2. Weihnachtstag' }); // Easter-dependent holidays const easter = getEasterSunday(year); // Karfreitag (Good Friday) - 2 days before Easter const goodFriday = new Date(easter); goodFriday.setDate(easter.getDate() - 2); holidays.push({ date: goodFriday, name: 'Karfreitag' }); // Ostermontag (Easter Monday) - 1 day after Easter const easterMonday = new Date(easter); easterMonday.setDate(easter.getDate() + 1); holidays.push({ date: easterMonday, name: 'Ostermontag' }); // Christi Himmelfahrt (Ascension Day) - 39 days after Easter const ascension = new Date(easter); ascension.setDate(easter.getDate() + 39); holidays.push({ date: ascension, name: 'Christi Himmelfahrt' }); // Pfingstmontag (Whit Monday) - 50 days after Easter const whitMonday = new Date(easter); whitMonday.setDate(easter.getDate() + 50); holidays.push({ date: whitMonday, name: 'Pfingstmontag' }); // Fronleichnam (Corpus Christi) - 60 days after Easter const corpusChristi = new Date(easter); corpusChristi.setDate(easter.getDate() + 60); holidays.push({ date: corpusChristi, name: 'Fronleichnam' }); return holidays; } /** * Check if date is a public holiday in Baden-Württemberg * Returns the holiday name or null */ function getHolidayName(date) { const year = date.getFullYear(); const holidays = getPublicHolidays(year); const dateStr = date.toISOString().split('T')[0]; const holiday = holidays.find(h => { return h.date.toISOString().split('T')[0] === dateStr; }); return holiday ? holiday.name : null; } /** * Check if date is a public holiday in Baden-Württemberg */ function isPublicHoliday(date) { return getHolidayName(date) !== null; } /** * Check if date is weekend or public holiday */ function isWeekendOrHoliday(date) { return isWeekend(date) || isPublicHoliday(date); } /** * 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; // Hide next month button if it's current month (future) const today = new Date(); const isCurrentMonth = displayYear === today.getFullYear() && displayMonth === today.getMonth(); const nextBtn = document.getElementById('btnNextMonth'); if (isCurrentMonth) { nextBtn.style.visibility = 'hidden'; } else { 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() { const today = new Date(); const nextMonth = displayMonth + 1; const nextYear = nextMonth > 11 ? displayYear + 1 : displayYear; const adjustedNextMonth = nextMonth > 11 ? 0 : nextMonth; // Don't allow going into future if (nextYear > today.getFullYear() || (nextYear === today.getFullYear() && adjustedNextMonth > today.getMonth())) { return; } if (displayMonth === 11) { displayMonth = 0; displayYear++; } else { displayMonth++; } loadMonthlyView(); } // ============================================ // TIMER FUNCTIONS // ============================================ /** * 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(); // Update UI document.getElementById('btnStartWork').disabled = true; document.getElementById('btnStopWork').disabled = false; document.getElementById('timerStatus').textContent = 'Läuft seit ' + todayEntry.startTime; // Start timer interval timerInterval = setInterval(updateTimer, 1000); updateTimer(); // Immediate update // Schedule automatic pauses const elapsed = Date.now() - timerStartTime; 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(); timerPausedDuration = 0; isPaused = false; // Update UI document.getElementById('btnStartWork').disabled = true; document.getElementById('btnStopWork').disabled = false; document.getElementById('timerStatus').textContent = 'Läuft seit ' + startTime; // 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; // Update UI document.getElementById('btnStartWork').disabled = false; document.getElementById('btnStopWork').disabled = true; document.getElementById('timerDisplay').textContent = '00:00:00'; document.getElementById('timerStatus').textContent = 'Nicht gestartet'; // Reload monthly view loadMonthlyView(); } /** * Update timer display */ function updateTimer() { if (!timerStartTime) return; const elapsed = Math.floor((Date.now() - timerStartTime) / 1000) - timerPausedDuration; document.getElementById('timerDisplay').textContent = formatDuration(elapsed); } /** * 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; document.getElementById('timerStatus').textContent = `Pause (${Math.floor(durationSeconds / 60)} Min)...`; pauseTimeout = setTimeout(() => { timerPausedDuration += durationSeconds; isPaused = false; document.getElementById('timerStatus').textContent = 'Läuft...'; }, durationSeconds * 1000); } /** * Fetch entries from the backend */ async function fetchEntries(fromDate = null, toDate = null) { try { let url = '/api/entries'; 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 fetch entries'); } const entries = await response.json(); return entries; } catch (error) { console.error('Error fetching entries:', error); showNotification('Fehler beim Laden der Einträge', 'error'); return []; } } /** * Create a new entry */ async function createEntry(date, startTime, endTime, pauseMinutes, location) { try { const body = { date: formatDateISO(date), startTime, endTime, location: location || 'office' }; // Only include pauseMinutes if explicitly provided (not empty) if (pauseMinutes !== null && pauseMinutes !== undefined && pauseMinutes !== '') { body.pauseMinutes = parseInt(pauseMinutes); } const response = await fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to create entry'); } const entry = await response.json(); return entry; } catch (error) { console.error('Error creating entry:', error); showNotification(error.message || 'Fehler beim Erstellen des Eintrags', 'error'); return null; } } /** * Update an existing entry */ async function updateEntry(id, date, startTime, endTime, pauseMinutes, location) { try { const body = { date: formatDateISO(date), startTime, endTime, location: location || 'office' }; // Only include pauseMinutes if explicitly provided (not empty) if (pauseMinutes !== null && pauseMinutes !== undefined && pauseMinutes !== '') { body.pauseMinutes = parseInt(pauseMinutes); } const response = await fetch(`/api/entries/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to update entry'); } const entry = await response.json(); return entry; } catch (error) { console.error('Error updating entry:', error); showNotification(error.message || 'Fehler beim Aktualisieren des Eintrags', 'error'); return null; } } /** * Delete an entry */ async function deleteEntry(id) { try { const response = await fetch(`/api/entries/${id}`, { method: 'DELETE' }); if (!response.ok) { throw new Error('Failed to delete entry'); } return true; } catch (error) { console.error('Error deleting entry:', error); showNotification('Fehler beim Löschen des Eintrags', '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'); 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 (only in bulk edit mode) const checkboxCell = bulkEditMode ? `
Keine Einträge für zukünftige Monate.
'; } // 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))); }); document.querySelectorAll('.btn-add-missing').forEach(btn => { btn.addEventListener('click', () => { const dateISO = btn.dataset.date; openModalForDate(dateISO); }); }); // Checkbox event listeners for bulk edit document.querySelectorAll('.entry-checkbox').forEach(checkbox => { checkbox.addEventListener('change', () => { toggleEntrySelection(parseInt(checkbox.dataset.id)); }); }); } /** * 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() { // 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); } /** * 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; for (let day = 1; day <= lastDay; day++) { const dateObj = new Date(currentYear, currentMonth, day); if (!isWeekendOrHoliday(dateObj)) { workdaysCount++; if (dateObj <= today) { workdaysPassed++; } } } // Calculate target hours (8h per workday passed) const targetHours = workdaysPassed * 8; // Calculate actual hours worked const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0); // Calculate balance for current month const balance = actualHours - targetHours; // Calculate previous month balance const previousBalance = await calculatePreviousBalance(); // Total balance = previous balance + current month balance const totalBalance = previousBalance + balance; // Update UI document.getElementById('statTargetHours').textContent = targetHours.toFixed(1) + 'h'; document.getElementById('statActualHours').textContent = actualHours.toFixed(1) + 'h'; document.getElementById('statWorkdays').textContent = `${entries.length}/${workdaysPassed}`; // 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'; } } /** * Calculate balance from all previous months (starting from first month with entries) */ async function calculatePreviousBalance() { const currentYear = displayYear; const currentMonth = displayMonth; let totalBalance = 0; let foundFirstMonth = false; // Go backwards from previous month until we find the first month with entries let checkYear = currentYear; let checkMonth = currentMonth - 1; // Handle year transition if (checkMonth < 0) { checkMonth = 11; checkYear--; } // Check up to 24 months back for (let i = 0; i < 24; i++) { 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); if (entries.length > 0) { foundFirstMonth = true; // Calculate workdays for this month (only up to last day of month or today, whichever is earlier) const today = new Date(); const monthEnd = new Date(checkYear, checkMonth + 1, 0); const limitDate = monthEnd < today ? monthEnd : today; let workdaysPassed = 0; const monthLastDay = new Date(checkYear, checkMonth + 1, 0).getDate(); for (let day = 1; day <= monthLastDay; day++) { const dateObj = new Date(checkYear, checkMonth, day); if (!isWeekendOrHoliday(dateObj) && dateObj <= limitDate) { workdaysPassed++; } } const targetHours = workdaysPassed * 8; const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0); const monthBalance = actualHours - targetHours; totalBalance += monthBalance; } else if (foundFirstMonth) { // If we found entries in a later month but not in this one, stop // (only count months after the first month with entries) break; } // Move to previous month checkMonth--; if (checkMonth < 0) { checkMonth = 11; 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-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-semibold 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-colors font-semibold flex items-center justify-center gap-2'; } else { officeBtn.className = 'flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold flex items-center justify-center gap-2'; homeBtn.className = 'flex-1 px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-semibold flex items-center justify-center gap-2'; } } /** * 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; } 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(); } } } /** * 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 loadMonthlyView(); 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 = 'px-4 py-3 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-xl'; toggleBtn.title = 'Mehrfachauswahl deaktivieren'; } else { bulkEditBar.classList.add('hidden'); checkboxHeader.classList.add('hidden'); toggleBtn.className = 'px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-xl'; toggleBtn.title = 'Mehrfachauswahl aktivieren'; } // Reload view to show/hide checkboxes loadMonthlyView(); } /** * Toggle entry selection */ function toggleEntrySelection(id) { if (selectedEntries.has(id)) { selectedEntries.delete(id); } else { selectedEntries.add(id); } updateSelectedCount(); updateCheckboxes(); } /** * Select all visible entries */ function selectAllEntries() { document.querySelectorAll('.entry-checkbox').forEach(checkbox => { const id = parseInt(checkbox.dataset.id); selectedEntries.add(id); checkbox.checked = true; }); updateSelectedCount(); } /** * Deselect all entries */ function deselectAllEntries() { selectedEntries.clear(); document.querySelectorAll('.entry-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'); 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() { document.querySelectorAll('.entry-checkbox').forEach(checkbox => { const id = parseInt(checkbox.dataset.id); checkbox.checked = selectedEntries.has(id); }); 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) { const success = await updateEntry(id, entry.date, entry.startTime, entry.endTime, entry.pauseMinutes, location); if (success) { updated++; } } } selectedEntries.clear(); await loadMonthlyView(); showNotification(`✓ ${updated} Eintrag/Einträge aktualisiert`, 'success'); } /** * 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(); await loadMonthlyView(); showNotification(`✓ ${deleted} Eintrag/Einträge gelöscht`, '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; 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 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(); input.select(); // Save on blur or Enter const saveEdit = async () => { 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 } // 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; } }; 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; } }); } /** * 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; loadEntries(fromDate, toDate); } /** * Handle clear filter button click */ function handleClearFilter() { document.getElementById('filterFrom').value = ''; document.getElementById('filterTo').value = ''; 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);Abweichung (h)\n'; const csvRows = entries.map(entry => { const deviation = entry.netHours - 8.0; const deviationStr = (deviation >= 0 ? '+' : '') + deviation.toFixed(2); return `${entry.date};${entry.startTime};${entry.endTime};${entry.pauseMinutes};${entry.netHours.toFixed(2)};${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'); } } /** * Show a temporary notification */ function showNotification(message, type = 'info') { const notification = document.createElement('div'); notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white z-50 ${ type === 'success' ? 'bg-green-600' : type === 'error' ? 'bg-red-600' : 'bg-blue-600' }`; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { notification.style.transition = 'opacity 0.5s'; notification.style.opacity = '0'; setTimeout(() => document.body.removeChild(notification), 500); }, 3000); } // ============================================ // 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: 1 (or 15 for larger steps) // - Mobile browsers will show native time picker automatically const timeConfig = { enableTime: true, noCalendar: true, dateFormat: 'H:i', time_24hr: true, minuteIncrement: 1, 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 }; startTimePicker = flatpickr('#modalStartTime', timeConfig); endTimePicker = flatpickr('#modalEndTime', timeConfig); } // ============================================ // EVENT LISTENERS // ============================================ function initializeEventListeners() { // Add entry button document.getElementById('btnAddEntry').addEventListener('click', () => openModal()); // Auto-fill month button document.getElementById('btnAutoFill').addEventListener('click', handleAutoFillMonth); // 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('btnBulkDelete').addEventListener('click', bulkDeleteEntries); // 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); // 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', () => { initializeFlatpickr(); initializeEventListeners(); checkRunningTimer(); // Check if timer was running loadMonthlyView(); // Load monthly view by default });