|
|
|
@@ -1,6 +1,7 @@
|
|
|
|
|
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');
|
|
|
|
@@ -21,6 +22,10 @@ targetTimeInput.addEventListener('change', () => {
|
|
|
|
|
if (lastFileText) analyseCsv(lastFileText);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
federalStateSelect.addEventListener('change', () => {
|
|
|
|
|
if (lastFileText) analyseCsv(lastFileText);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
exportButton.addEventListener('click', () => {
|
|
|
|
|
if (lastResult) exportResultCsv(lastResult);
|
|
|
|
|
});
|
|
|
|
@@ -70,15 +75,16 @@ function analyseCsv(text) {
|
|
|
|
|
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);
|
|
|
|
|
formatHint.textContent = 'Format erkannt: Tagesauswertung. Montag bis Freitag haben Sollzeit, Samstag und Sonntag werden mit 0:00 Sollzeit berechnet. Zeilen ohne Arbeitszeit werden ignoriert.';
|
|
|
|
|
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);
|
|
|
|
|
formatHint.textContent = 'Format erkannt: Monatsauswertung. Arbeitszeiten werden aus dem Monatsblock gelesen.';
|
|
|
|
|
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".');
|
|
|
|
|
}
|
|
|
|
@@ -93,7 +99,7 @@ function analyseCsv(text) {
|
|
|
|
|
saldoMinutes: days.reduce((sum, day) => sum + day.saldoMinutes, 0)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
lastResult = { months, days, totals };
|
|
|
|
|
lastResult = { months, days, totals, federalState, federalStateLabel: stateLabel(federalState) };
|
|
|
|
|
render(lastResult);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
showError(error.message);
|
|
|
|
@@ -108,7 +114,7 @@ function detectFormat(headers) {
|
|
|
|
|
return 'unknown';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function analyseDailyFormat(headers, dataRows, targetMinutes) {
|
|
|
|
|
function analyseDailyFormat(headers, dataRows, targetMinutes, federalState) {
|
|
|
|
|
const dateIndex = headers.indexOf('Tag');
|
|
|
|
|
const startIndex = headers.indexOf('Arbeitstag Beginn');
|
|
|
|
|
const endIndex = headers.indexOf('Arbeitstag Ende');
|
|
|
|
@@ -132,8 +138,9 @@ function analyseDailyFormat(headers, dataRows, targetMinutes) {
|
|
|
|
|
end: cleanCell(row[endIndex]),
|
|
|
|
|
workMinutes,
|
|
|
|
|
weekday: weekdayNameFromDate(date),
|
|
|
|
|
targetMinutes: targetMinutesForDate(date, targetMinutes),
|
|
|
|
|
saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes),
|
|
|
|
|
holiday: holidayNameForDate(date, federalState),
|
|
|
|
|
targetMinutes: targetMinutesForDate(date, targetMinutes, federalState),
|
|
|
|
|
saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes, federalState),
|
|
|
|
|
breakOk: cleanCell(row[breakIndex]),
|
|
|
|
|
warnings: cleanCell(row[warningIndex])
|
|
|
|
|
});
|
|
|
|
@@ -141,7 +148,7 @@ function analyseDailyFormat(headers, dataRows, targetMinutes) {
|
|
|
|
|
return days;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function analyseMonthlyFormat(headers, dataRows, targetMinutes) {
|
|
|
|
|
function analyseMonthlyFormat(headers, dataRows, targetMinutes, federalState) {
|
|
|
|
|
const monthIndex = headers.indexOf('Monat');
|
|
|
|
|
const durationIndex = headers.indexOf('Dauer Arbeitstag');
|
|
|
|
|
const stampIndex = headers.indexOf('Stempelzeit');
|
|
|
|
@@ -161,8 +168,9 @@ function analyseMonthlyFormat(headers, dataRows, targetMinutes) {
|
|
|
|
|
end: '',
|
|
|
|
|
workMinutes,
|
|
|
|
|
weekday: weekdayNameFromDate(date),
|
|
|
|
|
targetMinutes: targetMinutesForDate(date, targetMinutes),
|
|
|
|
|
saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes),
|
|
|
|
|
holiday: holidayNameForDate(date, federalState),
|
|
|
|
|
targetMinutes: targetMinutesForDate(date, targetMinutes, federalState),
|
|
|
|
|
saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes, federalState),
|
|
|
|
|
breakOk: '',
|
|
|
|
|
warnings: ''
|
|
|
|
|
});
|
|
|
|
@@ -172,13 +180,111 @@ function analyseMonthlyFormat(headers, dataRows, targetMinutes) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function targetMinutesForDate(date, weekdayTargetMinutes) {
|
|
|
|
|
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;
|
|
|
|
@@ -330,6 +436,7 @@ function render(result) {
|
|
|
|
|
<tr>
|
|
|
|
|
<td>${escapeHtml(day.date)}</td>
|
|
|
|
|
<td>${escapeHtml(day.weekday || '')}</td>
|
|
|
|
|
<td>${escapeHtml(day.holiday || '')}</td>
|
|
|
|
|
<td>${escapeHtml(day.start || '')}</td>
|
|
|
|
|
<td>${escapeHtml(day.end || '')}</td>
|
|
|
|
|
<td>${formatDuration(day.workMinutes)}</td>
|
|
|
|
@@ -343,14 +450,14 @@ function render(result) {
|
|
|
|
|
|
|
|
|
|
function exportResultCsv(result) {
|
|
|
|
|
const rows = [
|
|
|
|
|
['Bereich', 'Monat/Datum', 'Arbeitstage', 'Arbeitszeit', 'Sollzeit', 'Saldo'],
|
|
|
|
|
['Gesamt', '', result.totals.days, formatDuration(result.totals.workMinutes), formatDuration(result.totals.targetMinutes), formatDuration(result.totals.saldoMinutes, true)],
|
|
|
|
|
['Bereich', 'Monat/Datum', 'Arbeitstage', 'Arbeitszeit', 'Sollzeit', 'Saldo', 'Bundesland'],
|
|
|
|
|
['Gesamt', '', result.totals.days, formatDuration(result.totals.workMinutes), formatDuration(result.totals.targetMinutes), formatDuration(result.totals.saldoMinutes, true), result.federalStateLabel || ''],
|
|
|
|
|
[],
|
|
|
|
|
['Monat', 'Monat/Datum', 'Arbeitstage', 'Arbeitszeit', 'Sollzeit', 'Saldo'],
|
|
|
|
|
...result.months.map(m => ['Monat', m.month, m.days, formatDuration(m.workMinutes), formatDuration(m.targetMinutes), formatDuration(m.saldoMinutes, true)]),
|
|
|
|
|
[],
|
|
|
|
|
['Tag', 'Datum', 'Wochentag', 'Beginn', 'Ende', 'Arbeitszeit', 'Sollzeit', 'Saldo', 'Ruhepause', 'Warnungen'],
|
|
|
|
|
...result.days.map(d => ['Tag', d.date, d.weekday || '', d.start, d.end, formatDuration(d.workMinutes), formatDuration(d.targetMinutes), formatDuration(d.saldoMinutes, true), d.breakOk, d.warnings])
|
|
|
|
|
['Tag', 'Datum', 'Wochentag', 'Feiertag', 'Beginn', 'Ende', 'Arbeitszeit', 'Sollzeit', 'Saldo', 'Ruhepause', 'Warnungen'],
|
|
|
|
|
...result.days.map(d => ['Tag', d.date, d.weekday || '', d.holiday || '', d.start, d.end, formatDuration(d.workMinutes), formatDuration(d.targetMinutes), formatDuration(d.saldoMinutes, true), d.breakOk, d.warnings])
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const csv = rows.map(row => row.map(csvEscape).join(';')).join('\n');
|
|
|
|
|