diff --git a/.gitignore b/.gitignore index 65f6af9..600fea8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ Thumbs.db .idea/ *.swp *.swo +docker-volume/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index bcba329..26b9320 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,7 @@ WORKDIR /app COPY package*.json ./ # Install only production dependencies -# Using npm ci for reproducible builds -RUN npm ci --only=production && \ +RUN npm install --omit=dev && \ npm cache clean --force # ============================================ @@ -30,26 +29,17 @@ WORKDIR /app # Install dumb-init for proper signal handling RUN apk add --no-cache dumb-init -# Create non-root user for security -RUN addgroup -g 1001 -S nodejs && \ - adduser -S nodejs -u 1001 - # Copy dependencies from builder stage COPY --from=builder /app/node_modules ./node_modules # Copy application files -COPY --chown=nodejs:nodejs server.js ./ -COPY --chown=nodejs:nodejs package*.json ./ -COPY --chown=nodejs:nodejs src ./src -COPY --chown=nodejs:nodejs db ./db -COPY --chown=nodejs:nodejs public ./public +COPY server.js ./ +COPY schema.sql ./ +COPY package*.json ./ +COPY public ./public -# Create data directory for SQLite database with proper permissions -RUN mkdir -p /app/db && \ - chown -R nodejs:nodejs /app/db - -# Switch to non-root user -USER nodejs +# Create data directory for SQLite database +RUN mkdir -p /app/db # Expose the application port EXPOSE 3000 diff --git a/README.md b/README.md index 9548d4c..a4587f6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,17 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite und containerisiert mit Docker. +## Screenshots + +![Screenshot 1](media/screenshots/Screenshot1.png) +*Hauptansicht mit Timer und Monatsübersicht* + +![Screenshot 2](media/screenshots/Screenshot2.png) +*Detaillierte Statistiken und Urlaubsverwaltung* + +![Screenshot 3](media/screenshots/Screenshot3.png) +*Eintragsbearbeitung und Bulk-Operationen* + ## Funktionen ### Zeiterfassung @@ -12,6 +23,8 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite - ✅ **Manuelle Eingabe**: Erfassung von Arbeitszeiten (Datum, Startzeit, Endzeit, Pause) - ✅ **Inline-Bearbeitung**: Schnelle Änderung von Zeiten durch Klick in die Tabelle - ✅ **Standort-Tracking**: Home-Office oder Büro pro Eintrag +- ✅ **Urlaub eintragen**: Urlaubstage werden nicht vom Saldo abgezogen +- ✅ **Gleittage eintragen**: Gleittage ziehen 8 Stunden vom Saldo ab ### Intelligente Berechnungen - ✅ **Automatische Pausenberechnung** nach deutschem Arbeitszeitgesetz: @@ -31,6 +44,8 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite - ✅ **Monatskalender**: Vollständige Ansicht aller Tage des Monats - ✅ **Farbcodierung**: - Grün: Home-Office Tage + - Gelb: Urlaub + - Cyan: Gleittage - Rot: Fehlende Einträge an Arbeitstagen - Grau: Wochenenden - Blau: Feiertage mit Namen @@ -210,8 +225,9 @@ Exportiert **nur** Tage, die von der Standard-Arbeitszeit (8,0 Stunden) abweiche ### CSV-Spalten: - **Datum**: TT.MM.JJJJ (z.B. 23.10.2025) -- **Startzeit**: HH:MM (z.B. 08:00) -- **Endzeit**: HH:MM (z.B. 17:00) +- **Typ**: Arbeit / Urlaub / Gleitzeit +- **Startzeit**: HH:MM (z.B. 08:00, bei Urlaub/Gleitzeit: -) +- **Endzeit**: HH:MM (z.B. 17:00, bei Urlaub/Gleitzeit: -) - **Pause in Minuten**: Ganzzahl (z.B. 30) - **Gesamtstunden**: Nettostunden mit Komma als Dezimaltrennzeichen (z.B. 8,50) diff --git a/media/screenshots/Screenshot1.png b/media/screenshots/Screenshot1.png new file mode 100644 index 0000000..ca685f8 Binary files /dev/null and b/media/screenshots/Screenshot1.png differ diff --git a/media/screenshots/Screenshot2.png b/media/screenshots/Screenshot2.png new file mode 100644 index 0000000..7be2b30 Binary files /dev/null and b/media/screenshots/Screenshot2.png differ diff --git a/media/screenshots/Screenshot3.png b/media/screenshots/Screenshot3.png new file mode 100644 index 0000000..2847083 Binary files /dev/null and b/media/screenshots/Screenshot3.png differ diff --git a/public/app.js b/public/app.js index 1f3d0ba..f5bfd36 100644 --- a/public/app.js +++ b/public/app.js @@ -7,6 +7,7 @@ let startTimePicker = null; let endTimePicker = null; let filterFromPicker = null; let filterToPicker = null; +let manualStartTimePicker = null; // Timer state let timerInterval = null; @@ -20,12 +21,18 @@ let currentEntryId = null; // ID of today's entry being timed let displayYear = new Date().getFullYear(); let displayMonth = new Date().getMonth(); // 0-11 +// Current view state +let currentView = 'monthly'; // 'monthly' or 'filter' +let currentFilterFrom = null; +let currentFilterTo = null; + // Bulk edit state let bulkEditMode = false; let selectedEntries = new Set(); // Settings state let currentBundesland = 'BW'; // Default: Baden-Württemberg +let totalVacationDays = 30; // Default vacation days per year // ============================================ // UTILITY FUNCTIONS @@ -311,16 +318,9 @@ function updateMonthDisplay() { 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(); - + // Always show next month button (allow navigation to future) const nextBtn = document.getElementById('btnNextMonth'); - if (isCurrentMonth) { - nextBtn.style.visibility = 'hidden'; - } else { - nextBtn.style.visibility = 'visible'; - } + nextBtn.style.visibility = 'visible'; } /** @@ -340,17 +340,7 @@ function handlePrevMonth() { * 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; - } - + // Allow navigation to future months if (displayMonth === 11) { displayMonth = 0; displayYear++; @@ -385,9 +375,17 @@ async function checkRunningTimer() { timerStartTime = startDate.getTime(); // Update UI - document.getElementById('btnStartWork').disabled = true; - document.getElementById('btnStopWork').disabled = false; + 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'); document.getElementById('timerStatus').textContent = 'Läuft seit ' + todayEntry.startTime; + document.getElementById('timerStatus').classList.remove('cursor-pointer', 'underline', 'hover:text-blue-300'); + document.getElementById('timerStatus').classList.add('cursor-default'); // Start timer interval timerInterval = setInterval(updateTimer, 1000); @@ -422,9 +420,17 @@ async function startWork() { isPaused = false; // Update UI - document.getElementById('btnStartWork').disabled = true; - document.getElementById('btnStopWork').disabled = false; + 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'); document.getElementById('timerStatus').textContent = 'Läuft seit ' + startTime; + document.getElementById('timerStatus').classList.remove('cursor-pointer', 'underline', 'hover:text-blue-300'); + document.getElementById('timerStatus').classList.add('cursor-default'); // Start timer interval timerInterval = setInterval(updateTimer, 1000); @@ -468,10 +474,18 @@ async function stopWork() { isPaused = false; // Update UI - document.getElementById('btnStartWork').disabled = false; - document.getElementById('btnStopWork').disabled = true; + 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'; document.getElementById('timerStatus').textContent = 'Nicht gestartet'; + document.getElementById('timerStatus').classList.add('cursor-pointer', 'underline', 'hover:text-blue-300'); + document.getElementById('timerStatus').classList.remove('cursor-default'); // Reload monthly view loadMonthlyView(); @@ -782,7 +796,9 @@ function renderEntries(entries) { row.dataset.id = entry.id; // Location icon and text - const locationIcon = location === 'home' ? '🏠' : '🏢'; + const locationIcon = location === 'home' + ? '' + : ''; const locationText = location === 'home' ? 'Home' : 'Büro'; // Checkbox column (always present for consistent layout) @@ -814,11 +830,11 @@ function renderEntries(entries) {
- -
@@ -827,6 +843,11 @@ function renderEntries(entries) { tbody.appendChild(row); }); + // Reinitialize Lucide icons + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } + // Add event listeners attachInlineEditListeners(); @@ -868,18 +889,8 @@ function renderMonthlyView(entries) { const todayMonth = today.getMonth(); const todayDay = today.getDate(); - // Determine last day to show - let lastDay; - if (displayYear === todayYear && displayMonth === todayMonth) { - // Current month: show up to today - lastDay = todayDay; - } else if (displayYear < todayYear || (displayYear === todayYear && displayMonth < todayMonth)) { - // Past month: show all days - lastDay = new Date(displayYear, displayMonth + 1, 0).getDate(); - } else { - // Future month: show nothing - lastDay = 0; - } + // Show all days of the month + const lastDay = new Date(displayYear, displayMonth + 1, 0).getDate(); // Create a map of entries by date const entriesMap = {}; @@ -895,26 +906,77 @@ function renderMonthlyView(entries) { 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 location + // Row color based on entry type and location let rowClass = 'hover:bg-gray-700'; - if (location === 'home') { + if (entryType === 'vacation') { + rowClass = 'hover:bg-yellow-900 bg-yellow-950'; + } else if (entryType === 'flextime') { + rowClass = 'hover:bg-cyan-900 bg-cyan-950'; + } else 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'; + // Add inline border style for today + if (isToday) { + row.style.borderLeft = '4px solid #3b82f6'; // blue-500 + } + + // 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 ? ` @@ -927,42 +989,47 @@ function renderMonthlyView(entries) { row.innerHTML = checkboxCell + ` ${dayOfWeek} ${formatDateDisplay(entry.date)} - - ${entry.startTime} - - - ${entry.endTime} - - - ${entry.pauseMinutes} - + ${displayTimes} ${entry.netHours.toFixed(2)} - ${locationIcon} ${locationText} + ${displayIcon} ${displayText}
- - + ` : ''} +
`; } else { - // Day has no entry + // Day has no entry - show add options const holidayName = getHolidayName(dateObj); const displayText = holidayName || 'Kein Eintrag'; - row.className = weekend ? 'hover:bg-gray-700 bg-gray-700' : 'hover:bg-gray-700 bg-red-900'; + let emptyRowClass = weekend ? 'hover:bg-gray-700 bg-gray-700' : 'hover:bg-gray-700 bg-red-950/40'; - // Empty checkbox cell (always present for consistent layout) - const checkboxCell = bulkEditMode ? '' : ''; + + 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 ? '5' : '5'; row.innerHTML = checkboxCell + ` @@ -972,9 +1039,19 @@ function renderMonthlyView(entries) { ${displayText} - +
+ + ${!weekend ? ` + + + ` : ''} +
`; } @@ -982,11 +1059,11 @@ function renderMonthlyView(entries) { tbody.appendChild(row); } - if (lastDay === 0) { - emptyState.classList.remove('hidden'); - emptyState.innerHTML = '

Keine Einträge für zukünftige Monate.

'; + // Reinitialize Lucide icons + if (typeof lucide !== 'undefined') { + lucide.createIcons(); } - + // Add event listeners attachInlineEditListeners(); @@ -1005,19 +1082,76 @@ function renderMonthlyView(entries) { btn.addEventListener('click', () => handleDelete(parseInt(btn.dataset.id))); }); - document.querySelectorAll('.btn-add-missing').forEach(btn => { + // Add work entry for a specific date + document.querySelectorAll('.btn-add-work').forEach(btn => { btn.addEventListener('click', () => { const dateISO = btn.dataset.date; openModalForDate(dateISO); }); }); - // Checkbox event listeners for bulk edit + // 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'; + + 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'); + } } /** @@ -1032,6 +1166,9 @@ async function loadEntries(fromDate = null, toDate = null) { * 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`; @@ -1045,6 +1182,34 @@ async function loadMonthlyView() { updateStatistics(entries); } +/** + * 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 */ @@ -1057,23 +1222,65 @@ async function updateStatistics(entries) { // 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) + ); 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}`; - if (!isWeekendOrHoliday(dateObj)) { + const isVacation = vacationDays.has(dateISO); + const isFlextime = flextimeDays.has(dateISO); + const isWeekendHoliday = isWeekendOrHoliday(dateObj); + + if (!isWeekendHoliday && !isVacation) { + // Normal workday (excluding vacation days) + totalWorkdaysInMonth++; workdaysCount++; if (dateObj <= today) { workdaysPassed++; } + } else if (isFlextime && isWeekendHoliday) { + // Flextime on weekend/holiday counts as additional workday + totalWorkdaysInMonth++; + if (new Date(dateISO) <= today) { + workdaysPassed++; + } } + // Vacation days are excluded from all counts } // 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 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 + if (timerStartTime) { + const now = new Date(); + const elapsedMs = now.getTime() - timerStartTime; + const elapsedHours = elapsedMs / (1000 * 60 * 60); + actualHours += elapsedHours; + } // Calculate balance for current month const balance = actualHours - targetHours; @@ -1087,7 +1294,7 @@ async function updateStatistics(entries) { // 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}`; + document.getElementById('statWorkdays').textContent = `${entries.length}/${totalWorkdaysInMonth}`; // Current month balance const balanceElement = document.getElementById('statBalance'); @@ -1114,6 +1321,40 @@ async function updateStatistics(entries) { } 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(); + } + document.getElementById('statVacationTaken').textContent = vacationTaken; + document.getElementById('statVacationPlanned').textContent = vacationPlanned; + document.getElementById('statVacationRemaining').textContent = `${vacationRemaining} / ${totalVacationDays}`; } /** @@ -1123,62 +1364,90 @@ 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--; + // Find the first month with any entries by checking all entries + const allEntries = await fetchEntries(); + if (allEntries.length === 0) { + return 0; } - // Check up to 24 months back - for (let i = 0; i < 24; i++) { + // 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); - if (entries.length > 0) { - foundFirstMonth = true; + // 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}`; - // 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) { + 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; - } 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--; + 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++; } } @@ -1254,11 +1523,16 @@ function updateLocationButtons(location) { 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'; + 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-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'; + 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(); } } @@ -1314,10 +1588,10 @@ async function handleFormSubmit(e) { * Handle delete button click */ async function handleDelete(id) { - if (confirm('🗑️ Möchten Sie diesen Eintrag wirklich löschen?')) { + if (confirm('Möchten Sie diesen Eintrag wirklich löschen?')) { const success = await deleteEntry(id); if (success) { - showNotification('✅ Eintrag gelöscht', 'success'); + showNotification('Eintrag gelöscht', 'success'); loadMonthlyView(); } } @@ -1364,7 +1638,7 @@ function handleQuickTimeButton(netHours) { * 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.')) { + 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; } @@ -1416,7 +1690,7 @@ async function handleAutoFillMonth() { } // Reload view - await loadMonthlyView(); + await reloadView(); showNotification(`✓ ${created} Einträge erstellt, ${skipped} Tage übersprungen`, 'success'); } @@ -1439,17 +1713,17 @@ function toggleBulkEditMode() { 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.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 = 'px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-xl'; + 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 - loadMonthlyView(); + reloadView(); } /** @@ -1466,14 +1740,36 @@ function toggleEntrySelection(id) { } /** - * Select all visible entries + * 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(); } @@ -1482,9 +1778,15 @@ function selectAllEntries() { */ function deselectAllEntries() { selectedEntries.clear(); + document.querySelectorAll('.entry-checkbox').forEach(checkbox => { checkbox.checked = false; }); + + document.querySelectorAll('.empty-day-checkbox').forEach(checkbox => { + checkbox.checked = false; + }); + updateSelectedCount(); } @@ -1496,7 +1798,7 @@ function updateSelectedCount() { // Update master checkbox state const masterCheckbox = document.getElementById('masterCheckbox'); - const allCheckboxes = document.querySelectorAll('.entry-checkbox'); + const allCheckboxes = document.querySelectorAll('.entry-checkbox, .empty-day-checkbox'); if (allCheckboxes.length === 0) { masterCheckbox.checked = false; @@ -1517,10 +1819,18 @@ function updateSelectedCount() { * 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(); } @@ -1567,8 +1877,10 @@ async function bulkSetLocation(location) { } selectedEntries.clear(); - await loadMonthlyView(); + updateSelectedCount(); // Update counter to 0 + await reloadView(); showNotification(`✓ ${updated} Eintrag/Einträge aktualisiert`, 'success'); + toggleBulkEditMode(); // Close bulk edit mode } /** @@ -1580,7 +1892,7 @@ async function bulkDeleteEntries() { return; } - if (!confirm(`🗑️ Möchten Sie wirklich ${selectedEntries.size} Eintrag/Einträge löschen?`)) { + if (!confirm(`Möchten Sie wirklich ${selectedEntries.size} Eintrag/Einträge löschen?`)) { return; } @@ -1593,8 +1905,184 @@ async function bulkDeleteEntries() { } selectedEntries.clear(); - await loadMonthlyView(); - showNotification(`✓ ${deleted} Eintrag/Einträge gelöscht`, 'success'); + updateSelectedCount(); // Update counter to 0 + await reloadView(); + showNotification(`${deleted} Eintrag/Einträge gelöscht`, 'success'); + toggleBulkEditMode(); // Close bulk edit mode +} + +/** + * 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 } // ============================================ @@ -1649,7 +2137,7 @@ async function handleBundeslandChange(event) { // Warn user if conflicts exist if (conflicts.length > 0) { const conflictList = conflicts.map(c => ` • ${c.displayDate} (${c.holidayName})`).join('\n'); - const message = `⚠️ Achtung!\n\nDie folgenden Tage werden zu Feiertagen und haben bereits Einträge:\n\n${conflictList}\n\nMöchten Sie fortfahren? Die Einträge bleiben erhalten, aber die Tage werden als Feiertage markiert.`; + const message = `Achtung!\n\nDie folgenden Tage werden zu Feiertagen und haben bereits Einträge:\n\n${conflictList}\n\nMöchten Sie fortfahren? Die Einträge bleiben erhalten, aber die Tage werden als Feiertage markiert.`; if (!confirm(message)) { // Revert selection @@ -1663,7 +2151,7 @@ async function handleBundeslandChange(event) { await setSetting('bundesland', newBundesland); // Reload view to show updated holidays - await loadMonthlyView(); + await reloadView(); const bundeslandNames = { 'BW': 'Baden-Württemberg', @@ -1696,6 +2184,30 @@ async function loadSettings() { currentBundesland = savedBundesland; document.getElementById('bundeslandSelect').value = savedBundesland; } + + const savedVacationDays = await getSetting('vacationDays'); + if (savedVacationDays) { + totalVacationDays = parseInt(savedVacationDays); + document.getElementById('vacationDaysInput').value = totalVacationDays; + } +} + +/** + * 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; + } + + totalVacationDays = newValue; + await setSetting('vacationDays', newValue.toString()); + await updateVacationStatistics(); + showNotification(`✓ Urlaubstage auf ${newValue} pro Jahr gesetzt`, 'success'); } // ============================================ @@ -1767,7 +2279,7 @@ function handleCellClick(e) { if (field !== 'pauseMinutes') { // Validate time format if (!/^\d{1,2}:\d{2}$/.test(newValue)) { - showNotification('❌ Ungültiges Format. Bitte HH:MM verwenden.', 'error'); + showNotification('Ungültiges Format. Bitte HH:MM verwenden.', 'error'); cell.classList.remove('editing'); cell.innerHTML = originalContent; return; @@ -1847,6 +2359,11 @@ function handleFilter() { const fromDate = fromValue ? formatDateISO(fromValue) : null; const toDate = toValue ? formatDateISO(toValue) : null; + currentView = 'filter'; + currentFilterFrom = fromDate; + currentFilterTo = toDate; + hideMonthNavigation(); + loadEntries(fromDate, toDate); } @@ -1856,6 +2373,9 @@ function handleFilter() { function handleClearFilter() { document.getElementById('filterFrom').value = ''; document.getElementById('filterTo').value = ''; + currentView = 'monthly'; + currentFilterFrom = null; + currentFilterTo = null; loadMonthlyView(); } @@ -1896,12 +2416,13 @@ async function handleExport(onlyDeviations = false) { } // Create CSV content - const csvHeader = 'Datum;Beginn;Ende;Pause (min);Netto (h);Abweichung (h)\n'; + 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)};${deviationStr}`; + return `${entry.date};${entry.startTime};${entry.endTime};${entry.pauseMinutes};${entry.netHours.toFixed(2)};${locationText};${deviationStr}`; }).join('\n'); const csvContent = csvHeader + csvRows; @@ -1981,7 +2502,7 @@ function initializeFlatpickr() { // - enableTime: true // - noCalendar: true (time only) // - time_24hr: true (24-hour format) - // - minuteIncrement: 1 (or 15 for larger steps) + // - minuteIncrement: 15 (15-minute steps) // - Mobile browsers will show native time picker automatically const timeConfig = { @@ -1989,16 +2510,101 @@ function initializeFlatpickr() { noCalendar: true, dateFormat: 'H:i', time_24hr: true, - minuteIncrement: 1, + 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 + 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(); + timerPausedDuration = 0; + isPaused = false; + + // 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'); + document.getElementById('timerStatus').textContent = 'Läuft seit ' + timeStr; + document.getElementById('timerStatus').classList.remove('cursor-pointer', 'underline', 'hover:text-blue-300'); + document.getElementById('timerStatus').classList.add('cursor-default'); + + // Start timer interval + timerInterval = setInterval(updateTimer, 1000); + updateTimer(); // Immediate update + + // Calculate offset for pause scheduling + const timeSinceStart = now.getTime() - startDate.getTime(); + const offsetMinutes = Math.floor(timeSinceStart / 60000); + schedulePausesWithOffset(offsetMinutes); + + // Reload view to show entry + await loadMonthlyView(); + + showNotification(`Timer gestartet ab ${timeStr}`, 'success'); } // ============================================ @@ -2006,9 +2612,6 @@ function initializeFlatpickr() { // ============================================ function initializeEventListeners() { - // Add entry button - document.getElementById('btnAddEntry').addEventListener('click', () => openModal()); - // Auto-fill month button document.getElementById('btnAutoFill').addEventListener('click', handleAutoFillMonth); @@ -2023,6 +2626,8 @@ function initializeEventListeners() { 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); // Cancel modal button @@ -2058,9 +2663,47 @@ function initializeEventListeners() { document.getElementById('btnStartWork').addEventListener('click', startWork); document.getElementById('btnStopWork').addEventListener('click', stopWork); + // 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 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); + // Close modal when clicking outside document.getElementById('entryModal').addEventListener('click', (e) => { if (e.target.id === 'entryModal') { diff --git a/public/index.html b/public/index.html index 95e5c14..1ef03d1 100644 --- a/public/index.html +++ b/public/index.html @@ -8,6 +8,9 @@ + + + @@ -15,6 +18,115 @@ - +
-
+
-
-

⏱️ Zeiterfassung

+
+

+ + Zeiterfassung +

-
+
-
Heutige Arbeitszeit
-
00:00:00
-
Nicht gestartet
+
Heutige Arbeitszeit
+
00:00:00
+ +
+
+
+
+ + + -
+
@@ -223,43 +470,73 @@
+
+ + +
-
-

📊 Statistiken

+
+

+ + Statistiken +

-
-

Aktueller Monat

+
+

Aktueller Monat

-
-
Soll
-
0h
+
+
Soll
+
0h
-
-
Ist
-
0h
+
+
Ist
+
0h
-
-
Saldo (Monat)
-
0h
+
+
Saldo (Monat)
+
0h
-
-
Arbeitstage
-
0
+
+
Arbeitstage
+
0
+
+
+
+ + +
+

+ + Urlaub 2025 +

+
+
+
Genommen
+
0
+
+
+
Geplant
+
0
+
+
+
Verfügbar
+
0 / 30
-
+
-
Gesamt-Saldo (inkl. Vormonat)
-
0h
+
Gesamt-Saldo (inkl. Vormonat)
+
0h
Übertrag Vormonat
@@ -270,34 +547,32 @@
-
-
-
- -
-
+
-

+

@@ -306,65 +581,81 @@
-