const fileInput = document.getElementById('csvFile'); const dropZone = document.getElementById('dropZone'); const targetTimeInput = document.getElementById('targetTime'); const federalStateSelect = document.getElementById('federalState'); const errorBox = document.getElementById('errorBox'); const summary = document.getElementById('summary'); const results = document.getElementById('results'); const monthTable = document.getElementById('monthTable'); const dayTable = document.getElementById('dayTable'); const exportButton = document.getElementById('exportCsv'); const formatHint = document.getElementById('formatHint'); let lastResult = null; let lastFileText = null; fileInput.addEventListener('change', event => { const file = event.target.files[0]; if (file) readFile(file); }); targetTimeInput.addEventListener('change', () => { if (lastFileText) analyseCsv(lastFileText); }); federalStateSelect.addEventListener('change', () => { if (lastFileText) analyseCsv(lastFileText); }); exportButton.addEventListener('click', () => { if (lastResult) exportResultCsv(lastResult); }); ['dragenter', 'dragover'].forEach(eventName => { dropZone.addEventListener(eventName, event => { event.preventDefault(); dropZone.classList.add('dragover'); }); }); ['dragleave', 'drop'].forEach(eventName => { dropZone.addEventListener(eventName, event => { event.preventDefault(); dropZone.classList.remove('dragover'); }); }); dropZone.addEventListener('drop', event => { const file = event.dataTransfer.files[0]; if (file) readFile(file); }); function readFile(file) { hideError(); if (!file.name.toLowerCase().endsWith('.csv')) { showError('Bitte eine CSV-Datei auswählen.'); return; } const reader = new FileReader(); reader.onload = () => { lastFileText = String(reader.result || ''); analyseCsv(lastFileText); }; reader.onerror = () => showError('Die Datei konnte nicht gelesen werden.'); reader.readAsText(file, 'utf-8'); } function analyseCsv(text) { try { const rows = parseCsv(text); if (rows.length < 2) throw new Error('Die CSV enthält keine auswertbaren Daten.'); const headers = rows[0].map(h => h.trim().replace(/^\uFEFF/, '')); const dataRows = rows.slice(1).filter(row => row.some(cell => String(cell).trim() !== '')); const targetMinutes = timeToMinutes(targetTimeInput.value || '08:00'); const federalState = federalStateSelect.value || 'NW'; const detectedFormat = detectFormat(headers); let days; if (detectedFormat === 'daily') { days = analyseDailyFormat(headers, dataRows, targetMinutes, federalState); formatHint.textContent = `Format erkannt: Tagesauswertung. Montag bis Freitag haben Sollzeit, Samstag/Sonntag und Feiertage in ${stateLabel(federalState)} werden mit 0:00 Sollzeit berechnet. Zeilen ohne Arbeitszeit werden ignoriert.`; } else if (detectedFormat === 'monthly') { days = analyseMonthlyFormat(headers, dataRows, targetMinutes, federalState); formatHint.textContent = `Format erkannt: Monatsauswertung. Arbeitszeiten werden aus dem Monatsblock gelesen. Wenn ein Datum erkannt wird, werden Wochenenden und Feiertage in ${stateLabel(federalState)} mit 0:00 Sollzeit berechnet.`; } else { throw new Error('Das CSV-Format wurde nicht erkannt. Benötigt wird entweder die Spalte "Tag" oder "Monat" plus "Dauer Arbeitstag".'); } if (!days.length) throw new Error('Es wurden keine Arbeitszeiten in "Dauer Arbeitstag" gefunden.'); const months = groupByMonth(days); const totals = { days: days.length, workMinutes: days.reduce((sum, day) => sum + day.workMinutes, 0), targetMinutes: days.reduce((sum, day) => sum + day.targetMinutes, 0), saldoMinutes: days.reduce((sum, day) => sum + day.saldoMinutes, 0) }; lastResult = { months, days, totals, federalState, federalStateLabel: stateLabel(federalState) }; render(lastResult); } catch (error) { showError(error.message); } } function detectFormat(headers) { const hasDuration = headers.includes('Dauer Arbeitstag'); if (!hasDuration) return 'unknown'; if (headers.includes('Tag')) return 'daily'; if (headers.includes('Monat')) return 'monthly'; return 'unknown'; } function analyseDailyFormat(headers, dataRows, targetMinutes, federalState) { const dateIndex = headers.indexOf('Tag'); const startIndex = headers.indexOf('Arbeitstag Beginn'); const endIndex = headers.indexOf('Arbeitstag Ende'); const durationIndex = headers.indexOf('Dauer Arbeitstag'); const breakIndex = headers.indexOf('Ruhepause eingehalten'); const warningIndex = headers.indexOf('Warnungen'); const days = []; for (const row of dataRows) { const date = (row[dateIndex] || '').trim(); const durationText = row[durationIndex] || ''; const workMinutes = extractSingleDuration(durationText); // Wochenenden, Feiertage und nicht gearbeitete Tage stehen meist als "-" in Dauer Arbeitstag. if (workMinutes === null) continue; days.push({ date, month: monthNameFromDate(date), start: cleanCell(row[startIndex]), end: cleanCell(row[endIndex]), workMinutes, weekday: weekdayNameFromDate(date), holiday: holidayNameForDate(date, federalState), targetMinutes: targetMinutesForDate(date, targetMinutes, federalState), saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes, federalState), breakOk: cleanCell(row[breakIndex]), warnings: cleanCell(row[warningIndex]) }); } return days; } function analyseMonthlyFormat(headers, dataRows, targetMinutes, federalState) { const monthIndex = headers.indexOf('Monat'); const durationIndex = headers.indexOf('Dauer Arbeitstag'); const stampIndex = headers.indexOf('Stempelzeit'); const days = []; for (const row of dataRows) { const month = row[monthIndex] || 'Unbekannter Monat'; const durations = extractDurations(row[durationIndex] || ''); const dates = stampIndex >= 0 ? extractDates(row[stampIndex] || '') : []; durations.forEach((workMinutes, index) => { const date = dates[index] || `Tag ${index + 1}`; days.push({ date, month, start: '', end: '', workMinutes, weekday: weekdayNameFromDate(date), holiday: holidayNameForDate(date, federalState), targetMinutes: targetMinutesForDate(date, targetMinutes, federalState), saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes, federalState), breakOk: '', warnings: '' }); }); } return days; } function targetMinutesForDate(date, weekdayTargetMinutes, federalState) { const weekday = getWeekdayIndex(date); // 0 = Sonntag, 6 = Samstag. Rufbereitschafts-/Wochenendarbeit zählt komplett als Pluszeit. if (weekday === 0 || weekday === 6) return 0; if (holidayNameForDate(date, federalState)) return 0; return weekdayTargetMinutes; } function stateLabel(state) { const labels = { NW: 'Nordrhein-Westfalen', BY: 'Bayern', BY_MH: 'Bayern inkl. Mariä Himmelfahrt', NI: 'Niedersachsen', HH: 'Hamburg' }; return labels[state] || 'Nordrhein-Westfalen'; } function parseGermanDate(date) { const match = String(date).match(/^(\d{2})\.(\d{2})\.(\d{4})$/); if (!match) return null; return { day: Number(match[1]), month: Number(match[2]), year: Number(match[3]), key: `${match[3]}-${match[2]}-${match[1]}` }; } function dateKeyFromDate(year, month, day) { return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; } function addDaysKey(date, offset) { const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset); return dateKeyFromDate(copy.getFullYear(), copy.getMonth() + 1, copy.getDate()); } function easterSunday(year) { // Gaußsche Osterformel für den gregorianischen Kalender. 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); } function holidaysForYear(year, federalState) { const easter = easterSunday(year); const holidays = new Map(); const add = (month, day, name) => holidays.set(dateKeyFromDate(year, month, day), name); const addRelative = (offset, name) => holidays.set(addDaysKey(easter, offset), name); // Bundesweite Feiertage add(1, 1, 'Neujahr'); addRelative(-2, 'Karfreitag'); addRelative(1, 'Ostermontag'); add(5, 1, 'Tag der Arbeit'); addRelative(39, 'Christi Himmelfahrt'); addRelative(50, 'Pfingstmontag'); add(10, 3, 'Tag der Deutschen Einheit'); add(12, 25, '1. Weihnachtstag'); add(12, 26, '2. Weihnachtstag'); if (federalState === 'BY' || federalState === 'BY_MH') { add(1, 6, 'Heilige Drei Könige'); addRelative(60, 'Fronleichnam'); add(11, 1, 'Allerheiligen'); } if (federalState === 'BY_MH') { add(8, 15, 'Mariä Himmelfahrt'); } if (federalState === 'NW') { addRelative(60, 'Fronleichnam'); add(11, 1, 'Allerheiligen'); } if (federalState === 'NI' || federalState === 'HH') { add(10, 31, 'Reformationstag'); } return holidays; } function holidayNameForDate(date, federalState) { const parsed = parseGermanDate(date); if (!parsed) return ''; return holidaysForYear(parsed.year, federalState).get(parsed.key) || ''; } function getWeekdayIndex(date) { const match = String(date).match(/^(\d{2})\.(\d{2})\.(\d{4})$/); if (!match) return null; const day = Number(match[1]); const month = Number(match[2]) - 1; const year = Number(match[3]); return new Date(year, month, day).getDay(); } function weekdayNameFromDate(date) { const names = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; const index = getWeekdayIndex(date); return index === null ? '' : names[index]; } function groupByMonth(days) { const map = new Map(); for (const day of days) { const key = day.month || 'Unbekannt'; if (!map.has(key)) { map.set(key, { month: key, days: 0, workMinutes: 0, targetMinutes: 0, saldoMinutes: 0 }); } const month = map.get(key); month.days += 1; month.workMinutes += day.workMinutes; month.targetMinutes += day.targetMinutes; month.saldoMinutes += day.saldoMinutes; } return [...map.values()]; } function parseCsv(text) { const rows = []; let row = []; let value = ''; let inQuotes = false; for (let i = 0; i < text.length; i++) { const char = text[i]; const next = text[i + 1]; if (char === '"') { if (inQuotes && next === '"') { value += '"'; i++; } else { inQuotes = !inQuotes; } continue; } if (char === ',' && !inQuotes) { row.push(value); value = ''; continue; } if ((char === '\n' || char === '\r') && !inQuotes) { if (char === '\r' && next === '\n') i++; row.push(value); rows.push(row); row = []; value = ''; continue; } value += char; } if (value.length || row.length) { row.push(value); rows.push(row); } return rows; } function extractDurations(text) { const matches = [...text.matchAll(/=(\d{1,3}):(\d{2})/g)]; return matches.map(match => Number(match[1]) * 60 + Number(match[2])); } function extractSingleDuration(text) { const match = String(text).match(/=(\d{1,3}):(\d{2})/); if (!match) return null; return Number(match[1]) * 60 + Number(match[2]); } function extractDates(text) { return [...text.matchAll(/(\d{2}\.\d{2}\.\d{4})\s+\d{2}:\d{2}\s+-/g)].map(match => match[1]); } function monthNameFromDate(date) { const match = String(date).match(/^(\d{2})\.(\d{2})\.(\d{4})$/); if (!match) return 'Unbekannt'; const names = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; const monthIndex = Number(match[2]) - 1; return `${names[monthIndex] || 'Unbekannt'} ${match[3]}`; } function cleanCell(value) { const text = String(value ?? '').trim(); return text === '-' ? '' : text; } function timeToMinutes(time) { const [hours, minutes] = time.split(':').map(Number); return hours * 60 + minutes; } function formatDuration(minutes, signed = false) { const sign = minutes < 0 ? '-' : signed && minutes > 0 ? '+' : signed ? '±' : ''; const abs = Math.abs(minutes); const hours = Math.floor(abs / 60); const mins = abs % 60; return `${sign}${hours}:${String(mins).padStart(2, '0')}`; } function saldoClass(minutes) { if (minutes > 0) return 'positive'; if (minutes < 0) return 'negative'; return 'neutral'; } function render(result) { hideError(); summary.classList.remove('hidden'); results.classList.remove('hidden'); document.getElementById('totalDays').textContent = result.totals.days; document.getElementById('totalWork').textContent = formatDuration(result.totals.workMinutes); document.getElementById('totalTarget').textContent = formatDuration(result.totals.targetMinutes); const totalSaldo = document.getElementById('totalSaldo'); totalSaldo.textContent = formatDuration(result.totals.saldoMinutes, true); totalSaldo.className = saldoClass(result.totals.saldoMinutes); monthTable.innerHTML = result.months.map(month => `