diff --git a/Dienstplan_Portable.html b/Dienstplan_Portable.html index 50cedd3..5fa79f0 100644 --- a/Dienstplan_Portable.html +++ b/Dienstplan_Portable.html @@ -461,11 +461,12 @@

Datenverwaltung

+
-

💡 Tipp: CSV-Dateien können direkt mit Excel, LibreOffice oder Google Sheets geöffnet werden.

+

💡 Tipp: CSV-Dateien können direkt mit Excel, LibreOffice oder Google Sheets geöffnet werden. Der Bonus-Bericht öffnet sich in einem neuen Fenster zum Drucken.

Gefahrenzone

@@ -1084,6 +1085,315 @@

Gefahrenzone

URL.revokeObjectURL(url); } + /** + * Export a formal bonus report in HTML format + * Opens in a new window for printing or saving as PDF + */ + exportBonusReport() { + const m = parseInt(document.getElementById('selectMonth').value); + const y = parseInt(document.getElementById('selectYear').value); + + // Calculate next month for payout date + const payoutMonth = (m + 1) % 12; + const payoutYear = m === 11 ? y + 1 : y; + + // Filter duties for selected month + const monthDuties = this.duties.filter(d => { + const date = new Date(d.date); + return date.getMonth() === m && date.getFullYear() === y; + }); + + if (monthDuties.length === 0) { + alert('Keine Dienste für diesen Monat vorhanden.'); + return; + } + + // Group duties by employee and by weekday + const employeeData = {}; + monthDuties.forEach(d => { + if (!employeeData[d.name]) { + employeeData[d.name] = { + duties: [], + byWeekday: { 0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: [] }, // Sun=0 to Sat=6 + wt: 0, + we_fr: 0, + we_other: 0 + }; + } + + const date = new Date(d.date); + const weekday = date.getDay(); + const isQual = this.isQualifyingDay(date); + const isFri = this.isFriday(date); + + employeeData[d.name].duties.push(d); + employeeData[d.name].byWeekday[weekday].push({ + ...d, + date: date, + isQual: isQual, + dayInfo: this.getDayTypeInfo(date) + }); + + if (!isQual) { + employeeData[d.name].wt += d.share; + } else if (isFri) { + employeeData[d.name].we_fr += d.share; + } else { + employeeData[d.name].we_other += d.share; + } + }); + + // Build HTML report + let html = ` + + + + Bonuszahlungen ${MONTHS[m]} ${y} + + + +
+ + Tipp: Beim Drucken "Als PDF speichern" wählen für eine PDF-Datei. +
+ +

Bonuszahlungen

+
Monat ${MONTHS[m]} ${y} mit Auszahlung Ende ${MONTHS[payoutMonth]} ${payoutYear}
+ +

Für die im ${MONTHS[m]} ${y} geleisteten Bereitschaftsdienste ergeben sich folgende Bonuszahlungen:

+ + + + + + + + + + + + + + + + `; + + let totalBonus = 0; + const employeeNotes = []; + + for (const [name, data] of Object.entries(employeeData)) { + const we_total = data.we_fr + data.we_other; + const thresholdReached = we_total >= (CONFIG.THRESHOLD - CONFIG.TOLERANCE); + + let bonus = 0; + let deductedFrom = ''; + + if (thresholdReached) { + const wt_pay = data.wt * CONFIG.RATE_WT; + let deduct = CONFIG.DEDUCTION; + const deduct_fr = Math.min(deduct, data.we_fr); + const deduct_other = Math.max(0, deduct - deduct_fr); + const paid_fr = Math.max(0, data.we_fr - deduct_fr); + const paid_other = Math.max(0, data.we_other - deduct_other); + const we_pay = (paid_fr + paid_other) * CONFIG.RATE_WE; + bonus = wt_pay + we_pay; + + // Determine what was deducted for the note + if (deduct_fr > 0 && deduct_other > 0) { + deductedFrom = 'Freitag und weiterer WE-Tag'; + } else if (deduct_fr > 0) { + deductedFrom = 'Freitag'; + } else { + deductedFrom = 'WE-Tag (Sa/So/Feiertag)'; + } + } + + totalBonus += bonus; + + // Generate note for this employee + const safeName = this.sanitizeName(name); + let note = `${safeName}: `; + + if (!thresholdReached) { + note += `Erreicht das Bonussystem nicht (nur ${we_total.toFixed(1)} WE-Einheiten, mind. 2,0 erforderlich).`; + } else { + const details = []; + if (data.wt > 0) details.push(`${data.wt.toFixed(1)} WT × 250€`); + if (data.we_fr > 0 || data.we_other > 0) { + const paid_we = we_total - 1.0; + details.push(`${paid_we.toFixed(1)} WE × 450€ (abzgl. 1,0 Abzug von ${deductedFrom})`); + } + note += `Erhält ${this.formatCurrency(bonus)}. ${details.join(', ')}.`; + } + employeeNotes.push(note); + + // Build table row + html += ` + + `; + + // Days: Mo(1), Di(2), Mi(3), Do(4), Fr(5), Sa(6), So(0) + const dayOrder = [1, 2, 3, 4, 5, 6, 0]; + + for (const dayIdx of dayOrder) { + const dayDuties = data.byWeekday[dayIdx]; + if (dayDuties.length === 0) { + html += ``; + } else { + let cellContent = ''; + dayDuties.forEach(duty => { + const dateStr = duty.date.getDate() + '.'; + const shareStr = duty.share === 0.5 ? '½' : ''; + const amountStr = duty.isQual ? `${Math.round(duty.share * CONFIG.RATE_WE)}€` : `${Math.round(duty.share * CONFIG.RATE_WT)}€`; + const tag = duty.isQual ? 'we-tag' : 'wt-tag'; + const extraInfo = duty.dayInfo.type === 'holiday' ? ' (Feiertag)' : + duty.dayInfo.type === 'preHoliday' ? ' (Vor Feiertag)' : ''; + + cellContent += `${shareStr}X${extraInfo}
${amountStr}
`; + }); + html += ``; + } + } + + html += ` + + `; + } + + html += ` + +
MitarbeiterMoDiMiDoFrSaSoBonus (€)
${safeName}${cellContent}${bonus > 0 ? this.formatCurrency(bonus) : '-'}
+ +
+

Gesamtsumme: ${this.formatCurrency(totalBonus)}

+
+ +

Erläuterungen zu den einzelnen Mitarbeitern:

+`; + + employeeNotes.forEach(note => { + html += `
${note}
\n`; + }); + + html += ` +
+

Berechnungsregeln (Variante 2 - Streng):

+ +
+ +

+ Erstellt am: ${new Date().toLocaleDateString('de-DE')} | Dienstplan NRW (Variante 2 - Streng) +

+ + +`; + + // Open in new window + const reportWindow = window.open('', '_blank'); + if (reportWindow) { + reportWindow.document.write(html); + reportWindow.document.close(); + } else { + alert('Popup wurde blockiert. Bitte erlauben Sie Popups für diese Seite.'); + } + } + importData(input) { const file = input.files[0]; if (!file) return; diff --git a/webapp/app.js b/webapp/app.js index ba13f91..45a3519 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -51,6 +51,7 @@ class DienstplanApp { // Settings document.getElementById('export-csv-btn').addEventListener('click', () => this.exportCSV()); + document.getElementById('export-report-btn').addEventListener('click', () => this.exportBonusReport()); document.getElementById('export-btn').addEventListener('click', () => this.exportData()); document.getElementById('import-btn').addEventListener('click', () => this.importData()); document.getElementById('clear-all-btn').addEventListener('click', () => this.clearAllData()); @@ -541,6 +542,321 @@ class DienstplanApp { this.showToast('CSV wurde exportiert. Öffnen Sie die Datei mit Excel oder LibreOffice.', 'success'); } + /** + * Export a formal bonus report in HTML format + * Opens in a new window for printing or saving as PDF + */ + exportBonusReport() { + const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; + const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; + + const month = parseInt(document.getElementById('calc-month-select').value); + const year = parseInt(document.getElementById('calc-year-select').value); + + // Calculate next month for payout date + const payoutMonth = month % 12; + const payoutYear = month === 12 ? year + 1 : year; + + const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month); + const employees = Object.keys(employeeDuties); + + if (employees.length === 0) { + this.showToast('Keine Dienste für diesen Monat vorhanden.', 'error'); + return; + } + + // Escape HTML function + const escapeHtml = (str) => { + return String(str).replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[c])); + }; + + // Group duties by employee and weekday + const employeeData = {}; + for (const [name, duties] of Object.entries(employeeDuties)) { + employeeData[name] = { + duties: duties, + byWeekday: { 0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: [] }, + wt: 0, + we_fr: 0, + we_other: 0 + }; + + duties.forEach(duty => { + const dayOfWeek = duty.date.getDay(); + const isQualifying = this.calculator.isQualifyingDay(duty.date); + const isFriday = dayOfWeek === 5; + + employeeData[name].byWeekday[dayOfWeek].push({ + ...duty, + isQual: isQualifying, + dayType: this.calculator.getDayTypeLabel(duty.date) + }); + + if (!isQualifying) { + employeeData[name].wt += duty.share; + } else if (isFriday) { + employeeData[name].we_fr += duty.share; + } else { + employeeData[name].we_other += duty.share; + } + }); + } + + // Build HTML report + let html = ` + + + + Bonuszahlungen ${monthNames[month - 1]} ${year} + + + +
+ + Tipp: Beim Drucken "Als PDF speichern" wählen für eine PDF-Datei. +
+ +

Bonuszahlungen

+
Monat ${monthNames[month - 1]} ${year} mit Auszahlung Ende ${monthNames[payoutMonth]} ${payoutYear}
+ +

Für die im ${monthNames[month - 1]} ${year} geleisteten Bereitschaftsdienste ergeben sich folgende Bonuszahlungen:

+ + + + + + + + + + + + + + + + `; + + let totalBonus = 0; + const employeeNotes = []; + + for (const [name, data] of Object.entries(employeeData)) { + const we_total = data.we_fr + data.we_other; + const thresholdReached = we_total >= this.calculator.MIN_QUALIFYING_DAYS - 0.0001; + + let bonus = 0; + let deductedFrom = ''; + + if (thresholdReached) { + const wt_pay = data.wt * this.calculator.RATE_NORMAL; + let deduct = 1.0; + const deduct_fr = Math.min(deduct, data.we_fr); + const deduct_other = Math.max(0, deduct - deduct_fr); + const paid_fr = Math.max(0, data.we_fr - deduct_fr); + const paid_other = Math.max(0, data.we_other - deduct_other); + const we_pay = (paid_fr + paid_other) * this.calculator.RATE_WEEKEND; + bonus = wt_pay + we_pay; + + if (deduct_fr > 0 && deduct_other > 0) { + deductedFrom = 'Freitag und weiterer WE-Tag'; + } else if (deduct_fr > 0) { + deductedFrom = 'Freitag'; + } else { + deductedFrom = 'WE-Tag (Sa/So/Feiertag)'; + } + } + + totalBonus += bonus; + + // Generate note + const safeName = escapeHtml(name); + let note = `${safeName}: `; + + if (!thresholdReached) { + note += `Erreicht das Bonussystem nicht (nur ${we_total.toFixed(1)} WE-Einheiten, mind. 2,0 erforderlich).`; + } else { + const details = []; + if (data.wt > 0) details.push(`${data.wt.toFixed(1)} WT × 250€`); + if (data.we_fr > 0 || data.we_other > 0) { + const paid_we = we_total - 1.0; + details.push(`${paid_we.toFixed(1)} WE × 450€ (abzgl. 1,0 Abzug von ${deductedFrom})`); + } + note += `Erhält ${this.calculator.formatCurrency(bonus)}. ${details.join(', ')}.`; + } + employeeNotes.push(note); + + // Build table row + html += ` + + `; + + // Days: Mo(1), Di(2), Mi(3), Do(4), Fr(5), Sa(6), So(0) + const dayOrder = [1, 2, 3, 4, 5, 6, 0]; + + for (const dayIdx of dayOrder) { + const dayDuties = data.byWeekday[dayIdx]; + if (dayDuties.length === 0) { + html += ``; + } else { + let cellContent = ''; + dayDuties.forEach(duty => { + const dateStr = duty.date.getDate() + '.'; + const shareStr = duty.share === 0.5 ? '½' : ''; + const amountStr = duty.isQual ? `${Math.round(duty.share * this.calculator.RATE_WEEKEND)}€` : `${Math.round(duty.share * this.calculator.RATE_NORMAL)}€`; + const tag = duty.isQual ? 'we-tag' : 'wt-tag'; + const isHoliday = this.holidayProvider.isHoliday(duty.date); + const isDayBefore = this.holidayProvider.isDayBeforeHoliday(duty.date); + const extraInfo = isHoliday ? ' (Feiertag)' : isDayBefore ? ' (Vor Feiertag)' : ''; + + cellContent += `${shareStr}X${extraInfo}
${amountStr}
`; + }); + html += ``; + } + } + + html += ` + + `; + } + + html += ` + +
MitarbeiterMoDiMiDoFrSaSoBonus (€)
${safeName}${cellContent}${bonus > 0 ? this.calculator.formatCurrency(bonus) : '-'}
+ +
+

Gesamtsumme: ${this.calculator.formatCurrency(totalBonus)}

+
+ +

Erläuterungen zu den einzelnen Mitarbeitern:

+`; + + employeeNotes.forEach(note => { + html += `
${note}
\n`; + }); + + html += ` +
+

Berechnungsregeln (Variante 2 - Streng):

+ +
+ +

+ Erstellt am: ${new Date().toLocaleDateString('de-DE')} | Dienstplan NRW (Variante 2 - Streng) +

+ + +`; + + // Open in new window + const reportWindow = window.open('', '_blank'); + if (reportWindow) { + reportWindow.document.write(html); + reportWindow.document.close(); + this.showToast('Bonus-Bericht wurde in einem neuen Fenster geöffnet.', 'success'); + } else { + this.showToast('Popup wurde blockiert. Bitte erlauben Sie Popups für diese Seite.', 'error'); + } + } + /** * Import data from JSON file */ diff --git a/webapp/index.html b/webapp/index.html index ce8f0da..87a5530 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -178,8 +178,9 @@

Wichtig:

Datenexport / Import

+ -

💡 Tipp: CSV-Dateien können direkt mit Excel, LibreOffice oder Google Sheets geöffnet werden.

+

💡 Tipp: CSV-Dateien können direkt mit Excel, LibreOffice oder Google Sheets geöffnet werden. Der Bonus-Bericht öffnet sich in einem neuen Fenster zum Drucken.