From 3b039c019d2616bb4f26083b33d56204d3f74876 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 21:50:34 +0000 Subject: [PATCH 01/25] Kalender Edition: Verbesserte portable Version mit Schnelleingabe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue kalenderbasierte Tagesansicht mit Navigation - Erweiterte Feiertagsdaten 2024-2030 (statt nur 2025-2026) - Feiertagnamen werden jetzt angezeigt (z.B. "Karfreitag") - Korrektes Variante-2-Streng Regelwerk dokumentiert - Unterscheidung zwischen Feiertag, Vortag, Fr/Sa/So - XSS-Schutz durch Input-Sanitization - Verbesserte UI mit Pfeiltasten-Navigation - Besseres Export-Format mit Versionierung - Abwärtskompatibilität beim Import alter Daten --- Dienstplan_Portable.html | 2076 ++++++++++++++++---------------------- 1 file changed, 875 insertions(+), 1201 deletions(-) diff --git a/Dienstplan_Portable.html b/Dienstplan_Portable.html index 0eadb34..77418ed 100644 --- a/Dienstplan_Portable.html +++ b/Dienstplan_Portable.html @@ -3,551 +3,350 @@ - Dienstplan NRW - Bonus-Berechnung + Dienstplan NRW - Kalender Edition -
-
-

Dienstplan NRW

-

Bonus-Berechnung nach Variante 2 (streng)

-
-
-
-
1
- Mitarbeiter -
-
-
-
2
- Dienste -
-
-
-
3
- Ergebnis -
+
+
+

Dienstplan NRW

+
Variante 2 (Streng) - Kalender Edition
+
+ +
+
+ +
-
- -
-

Schritt 1: Mitarbeiter eingeben

-

Geben Sie alle Mitarbeiter ein, die in diesem Monat Dienste haben.

- -
-
- - -
-
- - -
-
- -
- -
- - -
-
- -
- -
-

Hinweis

-

Sie konnen Mitarbeiter jederzeit hinzufugen oder entfernen. Die Daten werden lokal im Browser gespeichert.

-
- - -
- - -
-

Schritt 2: Dienste eintragen

-

Tragen Sie die Dienste fur ein.

- -
-

Neuen Dienst hinzufugen

-
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -

Kalenderubersicht

-
-
-
-
- WE-Tag (Bonus-Tag) -
-
-
- Dienst eingetragen -
-
- -
-

Eingetragene Dienste (0)

-
-
- - -
- - -
-

Schritt 3: Ergebnis

-

Bonus-Berechnung für

- -
- - -
+
+ + +
- +
+
- - if (savedEmployees) employees = JSON.parse(savedEmployees); - if (savedDuties) duties = JSON.parse(savedDuties); - if (savedMonth !== null) selectedMonth = parseInt(savedMonth); - if (savedYear !== null) selectedYear = parseInt(savedYear); - } catch (e) { - console.log('Laden nicht moglich:', e); - } - } - - // Enter key for employee input - document.addEventListener('DOMContentLoaded', () => { - init(); - document.getElementById('employeeName').addEventListener('keypress', (e) => { - if (e.key === 'Enter') addEmployee(); - }); - }); - From 3cdcf7a541c91061b6ffcb2a3fdebb33a579b667 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 08:04:56 +0000 Subject: [PATCH 02/25] Initial plan From 5c9ab77ffd77b0880b23e6dfd09cd1e96910f828 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 08:23:39 +0000 Subject: [PATCH 03/25] Add Excel/CSV export feature for beginner-friendly data export - Add CSV export button to Dienstplan_Portable.html - Add CSV export button to webapp (index.html, app.js, styles.css) - Export includes all duties and monthly summary with calculations - Use semicolon separator for German Excel compatibility - Add UTF-8 BOM for proper character encoding - Add proper CSV escaping for names with special characters - Add helpful tip explaining CSV compatibility with Excel/LibreOffice/Google Sheets Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- Dienstplan_Portable.html | 121 +++++++++++++++++++++++++++++++++++++++ webapp/app.js | 107 ++++++++++++++++++++++++++++++++++ webapp/index.html | 2 + webapp/styles.css | 12 ++++ 4 files changed, 242 insertions(+) diff --git a/Dienstplan_Portable.html b/Dienstplan_Portable.html index 77418ed..50cedd3 100644 --- a/Dienstplan_Portable.html +++ b/Dienstplan_Portable.html @@ -460,10 +460,12 @@

Daten werden automatisch im Browser gespeichert (localStorage).

+
+

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

Gefahrenzone

@@ -963,6 +965,125 @@ class DienstplanApp { downloadAnchor.remove(); } + /** + * Export data as CSV (Excel-compatible) - Beginner-friendly format + * Exports two sheets: 1) All duties (Dienste) 2) Monthly summary (Auswertung) + */ + exportCSV() { + const m = parseInt(document.getElementById('selectMonth').value); + const y = parseInt(document.getElementById('selectYear').value); + + // Helper function to escape CSV values (handles semicolons, quotes, newlines) + const escapeCSV = (value) => { + const str = String(value); + if (str.includes(';') || str.includes('"') || str.includes('\n')) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; + }; + + // Build CSV content with BOM for Excel UTF-8 support + let csv = '\uFEFF'; // UTF-8 BOM for Excel + + // === Sheet 1: Dienste (All Duties) === + csv += 'ALLE DIENSTE\n'; + csv += 'Datum;Wochentag;Mitarbeiter;Anteil;Tagestyp\n'; + + // Sort duties by date + const sortedDuties = [...this.duties].sort((a, b) => a.date.localeCompare(b.date)); + + sortedDuties.forEach(duty => { + const date = new Date(duty.date); + const dayInfo = this.getDayTypeInfo(date); + const isQual = this.isQualifyingDay(date); + const dateStr = date.toLocaleDateString('de-DE'); + const weekday = WEEKDAYS[date.getDay()]; + const dayType = isQual ? 'WE-Tag' : 'Werktag (WT)'; + + csv += `${dateStr};${weekday};${escapeCSV(duty.name)};${duty.share.toFixed(1).replace('.', ',')};${dayType}\n`; + }); + + csv += '\n\n'; + + // === Sheet 2: Monatliche Auswertung === + csv += `AUSWERTUNG ${MONTHS[m]} ${y}\n`; + csv += 'Mitarbeiter;WT (Einheiten);WE Freitag;WE Andere;WE Gesamt;Schwelle erreicht;Auszahlung (EUR)\n'; + + // Filter duties for selected month + const monthDuties = this.duties.filter(d => { + const date = new Date(d.date); + return date.getMonth() === m && date.getFullYear() === y; + }); + + // Group by employee + const stats = {}; + monthDuties.forEach(d => { + if (!stats[d.name]) { + stats[d.name] = { wt: 0, we_fr: 0, we_other: 0 }; + } + + const date = new Date(d.date); + const isQual = this.isQualifyingDay(date); + const isFri = this.isFriday(date); + + if (!isQual) { + stats[d.name].wt += d.share; + } else { + if (isFri) { + stats[d.name].we_fr += d.share; + } else { + stats[d.name].we_other += d.share; + } + } + }); + + let totalPayout = 0; + + for (const [name, data] of Object.entries(stats)) { + const we_total = data.we_fr + data.we_other; + const thresholdReached = we_total >= (CONFIG.THRESHOLD - CONFIG.TOLERANCE); + + let payout = 0; + + 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; + payout = wt_pay + we_pay; + } + + totalPayout += payout; + + const threshold = thresholdReached ? 'JA' : 'NEIN'; + + csv += `${escapeCSV(name)};${data.wt.toFixed(1).replace('.', ',')};${data.we_fr.toFixed(1).replace('.', ',')};${data.we_other.toFixed(1).replace('.', ',')};${we_total.toFixed(1).replace('.', ',')};${threshold};${payout.toFixed(2).replace('.', ',')}\n`; + } + + csv += `\nGESAMT;;;;;;${totalPayout.toFixed(2).replace('.', ',')}\n`; + + csv += '\n\n'; + csv += 'LEGENDE\n'; + csv += 'WT;Werktag (Montag-Donnerstag ohne Feiertag/Vortag)\n'; + csv += 'WE-Tag;"Freitag, Samstag, Sonntag, Feiertag oder Tag vor Feiertag"\n'; + csv += 'Schwelle;"Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich"\n'; + csv += 'Sätze;"WT = 250 EUR/Einheit, WE = 450 EUR/Einheit (abzgl. 1,0 Abzug)"\n'; + + // Download CSV file + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const downloadAnchor = document.createElement('a'); + downloadAnchor.href = url; + downloadAnchor.download = `Dienstplan_${y}_${String(m + 1).padStart(2, '0')}.csv`; + document.body.appendChild(downloadAnchor); + downloadAnchor.click(); + downloadAnchor.remove(); + URL.revokeObjectURL(url); + } + importData(input) { const file = input.files[0]; if (!file) return; diff --git a/webapp/app.js b/webapp/app.js index 41af5ee..ba13f91 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -50,6 +50,7 @@ class DienstplanApp { document.getElementById('calculate-btn').addEventListener('click', () => this.calculateBonuses()); // Settings + document.getElementById('export-csv-btn').addEventListener('click', () => this.exportCSV()); 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()); @@ -434,6 +435,112 @@ class DienstplanApp { this.showToast('Daten wurden exportiert.', 'success'); } + /** + * Export data as CSV (Excel-compatible) - Beginner-friendly format + * Exports all duties and monthly summary for the selected month + */ + exportCSV() { + 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); + + // Helper function to escape CSV values (handles semicolons, quotes, newlines) + const escapeCSV = (value) => { + const str = String(value); + if (str.includes(';') || str.includes('"') || str.includes('\n')) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; + }; + + // Build CSV content with BOM for Excel UTF-8 support + let csv = '\uFEFF'; // UTF-8 BOM for Excel + + // === Sheet 1: Dienste (All Duties for the month) === + csv += `DIENSTE ${monthNames[month - 1]} ${year}\n`; + csv += 'Datum;Wochentag;Mitarbeiter;Anteil;Tagestyp\n'; + + const employees = this.storage.getEmployees(); + const allDuties = []; + + // Collect all duties for the selected month from all employees + employees.forEach(employee => { + const duties = this.storage.getDutiesForMonth(employee, year, month); + duties.forEach(duty => { + allDuties.push({ + ...duty, + employee: employee + }); + }); + }); + + // Sort by date + allDuties.sort((a, b) => a.date - b.date); + + allDuties.forEach(duty => { + const isQual = this.calculator.isQualifyingDay(duty.date); + const dateStr = duty.date.toLocaleDateString('de-DE'); + const weekday = weekdays[duty.date.getDay()]; + const dayType = isQual ? 'WE-Tag' : 'Werktag (WT)'; + + csv += `${dateStr};${weekday};${escapeCSV(duty.employee)};${duty.share.toFixed(1).replace('.', ',')};${dayType}\n`; + }); + + csv += '\n\n'; + + // === Sheet 2: Monatliche Auswertung === + csv += `AUSWERTUNG ${monthNames[month - 1]} ${year}\n`; + csv += 'Mitarbeiter;Normale Tage;WE/Feiertag Tage;Abzug;Normale Tage (bezahlt);WE/Feiertag (bezahlt);Schwelle erreicht;Bonus Normal;Bonus WE;Gesamtbonus (EUR)\n'; + + const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month); + const results = this.calculator.calculateAllEmployees(employeeDuties); + + let totalBonus = 0; + + for (const [employeeName, result] of Object.entries(results)) { + const threshold = result.thresholdReached ? 'JA' : 'NEIN'; + + totalBonus += result.totalBonus; + + csv += `${escapeCSV(employeeName)};`; + csv += `${result.normalDays.toFixed(1).replace('.', ',')};`; + csv += `${result.qualifyingDays.toFixed(1).replace('.', ',')};`; + csv += `${result.qualifyingDaysDeducted.toFixed(1).replace('.', ',')};`; + csv += `${result.normalDaysPaid.toFixed(1).replace('.', ',')};`; + csv += `${result.qualifyingDaysPaid.toFixed(1).replace('.', ',')};`; + csv += `${threshold};`; + csv += `${result.bonusNormalDays.toFixed(2).replace('.', ',')};`; + csv += `${result.bonusQualifyingDays.toFixed(2).replace('.', ',')};`; + csv += `${result.totalBonus.toFixed(2).replace('.', ',')}\n`; + } + + csv += `\nGESAMT;;;;;;;;;${totalBonus.toFixed(2).replace('.', ',')}\n`; + + csv += '\n\n'; + csv += 'LEGENDE\n'; + csv += 'Normale Tage;Montag-Donnerstag ohne Feiertag/Vortag\n'; + csv += 'WE/Feiertag Tage;"Freitag, Samstag, Sonntag, Feiertag oder Tag vor Feiertag"\n'; + csv += 'Schwelle;"Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich"\n'; + csv += 'Sätze;"Normale Tage = 250 EUR/Einheit, WE/Feiertag = 450 EUR/Einheit"\n'; + csv += 'Abzug;"Bei Erreichen der Schwelle wird 1,0 WE-Einheit abgezogen"\n'; + + // Download CSV file + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `Dienstplan_${year}_${String(month).padStart(2, '0')}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + this.showToast('CSV wurde exportiert. Öffnen Sie die Datei mit Excel oder LibreOffice.', 'success'); + } + /** * Import data from JSON file */ diff --git a/webapp/index.html b/webapp/index.html index 9cbafd6..ce8f0da 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -177,7 +177,9 @@

Datenexport / Import

+ +

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

diff --git a/webapp/styles.css b/webapp/styles.css index f332984..86c29b8 100644 --- a/webapp/styles.css +++ b/webapp/styles.css @@ -188,6 +188,18 @@ header h1 { background: #5a6268; } +.btn-success { + background: #28a745; + color: white; + margin-right: 10px; +} + +.btn-success:hover { + background: #218838; + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(40, 167, 69, 0.4); +} + .btn-danger { background: #dc3545; color: white; From cefb40945bedf93e2025bec7f02615b3974904c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 08:41:08 +0000 Subject: [PATCH 04/25] Initial plan From 635b986e2c5120c68a66dd860ccc77d5197a14f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 08:52:09 +0000 Subject: [PATCH 05/25] Add Bonus-Bericht export function for formatted bonus reports - Added new "Bonus-Bericht" button to both Dienstplan_Portable.html and webapp - Creates a printable HTML report with: - Header with "Bonuszahlungen" title - Month and payout date information - Table showing duties per weekday with amounts per employee - Individual explanatory notes for each employee - Print/PDF save button - Format matches user's requested output style from issue #20 Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- Dienstplan_Portable.html | 312 +++++++++++++++++++++++++++++++++++++- webapp/app.js | 316 +++++++++++++++++++++++++++++++++++++++ webapp/index.html | 3 +- 3 files changed, 629 insertions(+), 2 deletions(-) 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 @@
+
-

💡 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 @@ class DienstplanApp { 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):

+
    +
  • WE-Tage: Freitag, Samstag, Sonntag, Feiertage und Tage vor Feiertagen
  • +
  • Schwelle: Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich
  • +
  • Vergütung bei Erreichen der Schwelle: +
      +
    • Werktage (WT): 250 € pro Einheit
    • +
    • WE-Tage: 450 € pro Einheit (abzüglich 1,0 Einheit Abzug, Freitag zuerst)
    • +
    +
  • +
  • Unter Schwelle: Keine Bonuszahlung (weder WT noch WE)
  • +
+
+ +

+ 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):

+
    +
  • WE-Tage: Freitag, Samstag, Sonntag, Feiertage und Tage vor Feiertagen
  • +
  • Schwelle: Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich
  • +
  • Vergütung bei Erreichen der Schwelle: +
      +
    • Werktage (WT): 250 € pro Einheit
    • +
    • WE-Tage: 450 € pro Einheit (abzüglich 1,0 Einheit Abzug, Freitag zuerst)
    • +
    +
  • +
  • Unter Schwelle: Keine Bonuszahlung (weder WT noch WE)
  • +
+
+ +

+ 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 @@

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.

From 86cd0ae4a77e717a38075e56a4e5fe439d82178f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:29:31 +0000 Subject: [PATCH 06/25] Initial plan From 2d5e61dc09f8e1052e7cfe6b238854f3f8b37355 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:29:48 +0000 Subject: [PATCH 07/25] Initial plan From d6372fe2e25612edacdef64de48883b4ae8f61d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:36:24 +0000 Subject: [PATCH 08/25] Change weekend deduction from 1.0 to 2.0 units Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- CHANGELOG.md | 2 +- README.md | 2 +- SPECIFICATION.md | 4 ++-- android-app/README.md | 4 ++-- .../dienstplan/nrw/data/PayrollCalculator.kt | 4 ++-- claude.md | 18 +++++++++--------- src/build_template.py | 4 ++-- src/calculate.py | 2 +- webapp/README.md | 8 ++++---- webapp/TEST_GUIDE.md | 12 ++++++------ webapp/calculator.js | 4 ++-- 11 files changed, 32 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb01259..bb5283e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Added native Android mobile app for duty roster management with the same NRW Var - Same WT-Tag classification - Same compensation rates (WT: 250€, WE: 450€) - Same threshold logic (≥ 2.0 WE units) -- Same deduction rules (1.0 unit, Friday priority) +- Same deduction rules (2.0 units, Friday priority) - Same Variante 2 behavior (no WE compensation below threshold) **Testing:** diff --git a/README.md b/README.md index 1b333c7..69e94b4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Native Android-App für mobiles Dienstplan-Management. Siehe [android-app/README - ✅ Automatische Erkennung von Wochenenden (Fr–So), Feiertagen und Vortagen - ✅ Vergütungslogik: WT 250€, WE 450€ (nur ab Schwelle ≥ 2,0 WE-Einheiten) -- ✅ Abzug 1,0 WE-Einheit (Freitag-Priorität) nach Erreichen der Schwelle +- ✅ Abzug 2,0 WE-Einheiten (Freitag-Priorität) nach Erreichen der Schwelle - ✅ Vorbefüllte Monatsvorlagen mit allen Datumswerten - ✅ Excel-kompatibel (ohne Office 365 Funktionen) diff --git a/SPECIFICATION.md b/SPECIFICATION.md index acbc563..6021df3 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -30,7 +30,7 @@ Hinweise: - **WE** (WE-Tag): - Wenn Monats-Summe WE-Einheiten < 2,0 → Auszahlung 0 € für alle WE-Einheiten. - Wenn Monats-Summe WE-Einheiten ≥ 2,0 → Auszahlung 450 €/WE-Einheit, - anschließend Abzug genau 1,0 WE-Einheit (max. 1× pro Person/Monat). + anschließend Abzug genau 2,0 WE-Einheiten (max. 1× pro Person/Monat). - Abzugs-Priorität: zuerst aus Freitag-WE-Einheiten, Rest aus den übrigen WE-Einheiten (Sa/So/Feiertag/Vortag). Chronologie muss nicht nachgebildet werden; es genügt die Priorität nach Kategorie. ### Splits/Anteile @@ -51,7 +51,7 @@ Hinweise: - Satz_WT = 250 - Satz_WE = 450 - WE_Schwelle = 2,0 -- Abzug_nach_WE_Schwelle = 1,0 +- Abzug_nach_WE_Schwelle = 2,0 - BL_Auswahl = Dropdown (z. B. BW, BY, BE, …) - Monat_Auswahl = Datum (erster Tag des Zielmonats, z. B. 01.11.2025) - Variante = 2 (fix auf „streng") diff --git a/android-app/README.md b/android-app/README.md index 7694129..062be7d 100644 --- a/android-app/README.md +++ b/android-app/README.md @@ -20,7 +20,7 @@ Same as the Python/Excel implementation: - **WT-Tag** (Weekday): All other days - **WT compensation**: Always 250€ per unit - **WE compensation**: Only paid if monthly total ≥ 2.0 WE units - - If threshold reached: 450€ per WE unit, then deduct exactly 1.0 WE unit + - If threshold reached: 450€ per WE unit, then deduct exactly 2.0 WE units - Deduction priority: Friday first, then other WE days - Below threshold: 0€ for WE shifts @@ -123,7 +123,7 @@ Edit `PayrollCalculator.kt` and modify the constants: - `RATE_WT`: Weekday rate (default 250€) - `RATE_WE`: Weekend rate (default 450€) - `WE_THRESHOLD`: Threshold for WE compensation (default 2.0) -- `DEDUCTION_AFTER_THRESHOLD`: Deduction amount (default 1.0) +- `DEDUCTION_AFTER_THRESHOLD`: Deduction amount (default 2.0) ### Adding Holidays diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt b/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt index 9323553..5d8f932 100644 --- a/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt +++ b/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt @@ -14,7 +14,7 @@ import kotlin.math.min * - WT-Tag (Weekday): All other days * - WT compensation: Always 250€ per unit * - WE compensation: Only paid if monthly total >= 2.0 WE units (threshold) - * - If threshold reached: 450€ per WE unit, then deduct exactly 1.0 WE unit + * - If threshold reached: 450€ per WE unit, then deduct exactly 2.0 WE units * - Deduction priority: Friday first, then other WE days * - Below threshold: 0€ for WE shifts (NOT converted to WT) */ @@ -24,7 +24,7 @@ class PayrollCalculator { private const val RATE_WT = 250.0 // Satz_WT private const val RATE_WE = 450.0 // Satz_WE private const val WE_THRESHOLD = 2.0 // WE_Schwelle - private const val DEDUCTION_AFTER_THRESHOLD = 1.0 // Abzug_nach_WE_Schwelle + private const val DEDUCTION_AFTER_THRESHOLD = 2.0 // Abzug_nach_WE_Schwelle private const val TOLERANCE = 0.0001 // For floating-point comparisons } diff --git a/claude.md b/claude.md index 656cd19..9fde052 100644 --- a/claude.md +++ b/claude.md @@ -39,7 +39,7 @@ Die Web-App implementiert eine vereinfachte Logik: 2. **Bonusberechnung**: - Mindestens **2.0 qualifizierende Tage** erforderlich - - Bei Erreichen: **1.0 qualifizierender Tag** wird abgezogen + - Bei Erreichen: **2.0 qualifizierende Tage** werden abgezogen - **Alle übrigen Tage** werden bezahlt: - Normale Tage (Mo-Do, kein Feiertag): 250€ - Qualifizierende Tage: 450€ @@ -56,7 +56,7 @@ Die ältere Implementierung nutzt eine andere Logik: - **WT-Tage** werden **immer** mit 250€ vergütet - **WE-Tage** nur vergütet wenn ≥ 2.0 WE-Einheiten: - Bei Erreichen: 450€ pro WE-Tag - - Dann Abzug von 1.0 WE-Einheit (Freitag-Priorität) + - Dann Abzug von 2.0 WE-Einheiten (Freitag-Priorität) - Unter Schwellenwert: WE-Dienste = 0€ (nicht als WT vergütet) ### Wichtiger Unterschied - Beispiel @@ -168,7 +168,7 @@ adb install app/build/outputs/apk/debug/app-debug.apk ### Testfall 1: Schwellenwert genau erreicht - 1 × Freitag (1.0) - 1 × Samstag (1.0) -- Erwartung: 2.0 qualifizierende Tage → 1.0 abgezogen → 1.0 × 450€ = **450€** +- Erwartung: 2.0 qualifizierende Tage → 2.0 abgezogen → 0.0 × 450€ = **0€** ### Testfall 2: Schwellenwert nicht erreicht - 1 × Samstag (1.0) @@ -179,13 +179,13 @@ adb install app/build/outputs/apk/debug/app-debug.apk - 2 × Montag (2.0) - 2 × Samstag (2.0) - Erwartung: - - 2.0 qualifizierende → -1.0 Abzug → 1.0 bezahlt - - Bonus: (2 × 250€) + (1 × 450€) = **950€** + - 2.0 qualifizierende → -2.0 Abzug → 0.0 bezahlt + - Bonus: (2 × 250€) + (0 × 450€) = **500€** ### Testfall 4: Feiertag + Vortag - 1 × Donnerstag vor Karfreitag (qualifizierend!) - 1 × Karfreitag (Feiertag, qualifizierend!) -- Erwartung: 2.0 qualifizierende → -1.0 → 1.0 × 450€ = **450€** +- Erwartung: 2.0 qualifizierende → -2.0 → 0.0 × 450€ = **0€** ## Häufige Anpassungen @@ -203,14 +203,14 @@ this.RATE_WEEKEND = 500; // Statt 450 ``` ### Abzug ändern (Web-App) -Aktuell ist der Abzug fest auf 1.0 kodiert in `webapp/calculator.js`, Zeile 66: +Aktuell ist der Abzug fest auf 2.0 kodiert in `webapp/calculator.js`, Zeile 112: ```javascript -qualifyingDaysDeducted = 1.0; +qualifyingDaysDeducted = 2.0; ``` Um dies flexibel zu machen, könnte man hinzufügen: ```javascript -this.DEDUCTION_AMOUNT = 1.0; // Im Constructor +this.DEDUCTION_AMOUNT = 2.0; // Im Constructor // Dann verwenden: qualifyingDaysDeducted = this.DEDUCTION_AMOUNT; ``` diff --git a/src/build_template.py b/src/build_template.py index e62a4f7..7a6d1b5 100644 --- a/src/build_template.py +++ b/src/build_template.py @@ -61,7 +61,7 @@ def _populate_readme(ws): rules = [ "WE-Tag = Fr/Sa/So/Feiertag/Vortag (BL-abhängig).", "Variante 2 (streng): WE werden nur vergütet, wenn im Monat ≥ 2,0 WE-Einheiten erreicht werden;", - "dann 450 €/WE und Abzug 1,0 (Freitag zuerst). WT werden immer mit 250 € vergütet.", + "dann 450 €/WE und Abzug 2,0 (Freitag zuerst). WT werden immer mit 250 € vergütet.", "Splits anteilig. Monat und Bundesland in 'Regeln' wählen.", "", "Schritte:", @@ -83,7 +83,7 @@ def _populate_rules(ws): ("Satz_WT", 250, "Euro für jeden Werktagsdienst (Mo–Do, sofern kein WE-Tag)"), ("Satz_WE", 450, "Euro für jeden WE-Tag (Fr–So, Feiertag, Vortag Feiertag)"), ("WE_Schwelle", 2.0, "Ab dieser WE-Anzahl wird vergütet (sonst 0 €)"), - ("Abzug_nach_WE_Schwelle", 1.0, "Einheiten, die nach Erreichen der Schwelle abgezogen werden"), + ("Abzug_nach_WE_Schwelle", 2.0, "Einheiten, die nach Erreichen der Schwelle abgezogen werden"), ("BL_Auswahl", "NRW", "Bundesland (steuert Feiertage)"), ("Monat_Auswahl", date(2025, 11, 1), "Erster Tag des Zielmonats"), ("Variante", 2, "Fix: 2 = streng (WE nur bei Schwelle ≥ 2,0)"), diff --git a/src/calculate.py b/src/calculate.py index 1faa1f5..f2464e7 100644 --- a/src/calculate.py +++ b/src/calculate.py @@ -14,7 +14,7 @@ from collections import defaultdict SATZ_WT = 250 # Euro für Werktag SATZ_WE = 450 # Euro für Wochenende WE_SCHWELLE = 2.0 # Mindestanzahl WE-Dienste für Vergütung -ABZUG = 1.0 # Abzug nach Erreichen der Schwelle +ABZUG = 2.0 # Abzug nach Erreichen der Schwelle def load_holidays(wb): diff --git a/webapp/README.md b/webapp/README.md index e3779fc..12e74a0 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -21,7 +21,7 @@ Eine Web-Anwendung zur Berechnung von Bonuszahlungen für Wochenend- und Feierta ### Bonusberechnung 1. **Schwellenwert**: Mindestens **2.0 qualifizierende Tage** im Monat erforderlich -2. **Abzug**: Bei Erreichen des Schwellenwerts wird **1.0 qualifizierender Tag** abgezogen +2. **Abzug**: Bei Erreichen des Schwellenwerts werden **2.0 qualifizierende Tage** abgezogen 3. **Vergütung**: - Normale Tage: **250€** pro Tag - Qualifizierende Tage (WE/Feiertag): **450€** pro Tag @@ -34,9 +34,9 @@ Mitarbeiter hat im Monat: **Berechnung**: - Qualifizierende Tage: 3.0 (Schwellenwert erreicht ✓) -- Abzug: -1.0 qualifizierender Tag -- Bezahlt: 3 normale Tage + 2 qualifizierende Tage -- **Bonus**: (3 × 250€) + (2 × 450€) = **1.650€** +- Abzug: -2.0 qualifizierende Tage +- Bezahlt: 3 normale Tage + 1 qualifizierender Tag +- **Bonus**: (3 × 250€) + (1 × 450€) = **1.200€** ## Installation & Nutzung diff --git a/webapp/TEST_GUIDE.md b/webapp/TEST_GUIDE.md index 4ef68c1..b8e29f0 100644 --- a/webapp/TEST_GUIDE.md +++ b/webapp/TEST_GUIDE.md @@ -40,8 +40,8 @@ Automatische Test Suite für die Web-App. ### 3. Calculator - Bonusberechnung **Schwellenwert-Tests:** - ✅ Unter Schwellenwert (1.0 WE-Tag) → 0€ -- ✅ Genau Schwellenwert (2.0 WE-Tage) → 450€ -- ✅ Über Schwellenwert (3.0 WE-Tage) → 900€ +- ✅ Genau Schwellenwert (2.0 WE-Tage) → 0€ +- ✅ Über Schwellenwert (3.0 WE-Tage) → 450€ **Gemischte Dienste:** - ✅ Normale Tage + WE-Tage korrekt berechnet @@ -84,8 +84,8 @@ Dienste: Erwartung: - Qualifizierende Tage: 2.0 - Schwellenwert: ✅ Erreicht -- Abzug: -1.0 -- Bezahlt: 1.0 × 450€ = 450€ +- Abzug: -2.0 +- Bezahlt: 0.0 × 450€ = 0€ ``` ### Beispiel 2: Gemischte Dienste @@ -96,8 +96,8 @@ Dienste: Erwartung: - Normale Tage: 2.0 × 250€ = 500€ -- Qualifizierende Tage: (2.0 - 1.0) × 450€ = 450€ -- Gesamt: 950€ +- Qualifizierende Tage: (2.0 - 2.0) × 450€ = 0€ +- Gesamt: 500€ ``` ### Beispiel 3: Halbe Dienste diff --git a/webapp/calculator.js b/webapp/calculator.js index bc5c787..d705419 100644 --- a/webapp/calculator.js +++ b/webapp/calculator.js @@ -108,8 +108,8 @@ class BonusCalculator { let totalDeduction = 0; if (thresholdReached) { - // Deduct 1.0 qualifying day with Friday priority - totalDeduction = 1.0; + // Deduct 2.0 qualifying days with Friday priority + totalDeduction = 2.0; // First deduct from Friday deductionFromFriday = Math.min(totalDeduction, qualifyingDaysFriday); From 09ef077b677d4ade63d4fa32d259ea774eada25f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:39:47 +0000 Subject: [PATCH 09/25] Update test expectations for 2.0 deduction Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- .../dienstplan/nrw/PayrollCalculatorTest.kt | 22 ++-- test_deduction.py | 124 ++++++++++++++++++ webapp/test-suite.js | 30 ++--- 3 files changed, 150 insertions(+), 26 deletions(-) create mode 100644 test_deduction.py diff --git a/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt b/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt index 2403c0f..dbabf20 100644 --- a/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt +++ b/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt @@ -52,7 +52,7 @@ class PayrollCalculatorTest { /** * Test Case 2: Exactly at threshold (2.0 WE) - * Expected: WE payout = 450€ (1.0 unit after deduction), threshold reached + * Expected: WE payout = 0€ (0.0 units after deduction), threshold reached */ @Test fun testExactlyAtThreshold() { @@ -67,14 +67,14 @@ class PayrollCalculatorTest { val result = results[0] assertEquals(2.0, result.weTotal, 0.001) assertTrue(result.thresholdReached) - assertEquals(1.0, result.deductionTotal, 0.001) - assertEquals(1.0, result.wePaid, 0.001) - assertEquals(450.0, result.payoutWE, 0.001) + assertEquals(2.0, result.deductionTotal, 0.001) + assertEquals(0.0, result.wePaid, 0.001) + assertEquals(0.0, result.payoutWE, 0.001) } /** * Test Case 3: Over threshold (3.5 WE) - * Expected: WE payout = 1125€ (2.5 units after deduction) + * Expected: WE payout = 675€ (1.5 units after deduction) */ @Test fun testOverThreshold() { @@ -91,8 +91,8 @@ class PayrollCalculatorTest { val result = results[0] assertEquals(3.5, result.weTotal, 0.001) assertTrue(result.thresholdReached) - assertEquals(2.5, result.wePaid, 0.001) - assertEquals(1125.0, result.payoutWE, 0.001) + assertEquals(1.5, result.wePaid, 0.001) + assertEquals(675.0, result.payoutWE, 0.001) } /** @@ -115,8 +115,8 @@ class PayrollCalculatorTest { assertEquals(0.4, result.weFriday, 0.001) assertEquals(1.6, result.weOther, 0.001) assertEquals(0.4, result.deductionFriday, 0.001) // All Friday deducted first - assertEquals(0.6, result.deductionOther, 0.001) // Rest from other - assertEquals(1.0, result.wePaid, 0.001) + assertEquals(1.6, result.deductionOther, 0.001) // Rest from other (1.6 to reach 2.0 total) + assertEquals(0.0, result.wePaid, 0.001) } /** @@ -147,8 +147,8 @@ class PayrollCalculatorTest { // B: above threshold assertTrue(resultB.thresholdReached) assertEquals(2.5, resultB.weTotal, 0.001) - assertEquals(1.5, resultB.wePaid, 0.001) - assertEquals(675.0, resultB.payoutWE, 0.001) + assertEquals(0.5, resultB.wePaid, 0.001) + assertEquals(225.0, resultB.payoutWE, 0.001) } private fun parseDate(dateString: String): Date { diff --git a/test_deduction.py b/test_deduction.py new file mode 100644 index 0000000..b09ecfc --- /dev/null +++ b/test_deduction.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Test script to verify the weekend deduction change from 1.0 to 2.0 units. +""" + +from datetime import date +import sys +sys.path.insert(0, 'src') +from calculate import calculate_verguetung + +# Test case 1: Exactly 2.0 WE units (threshold reached) +# Expected: 2.0 WE - 2.0 deduction = 0.0 paid → 0€ for WE +print("=" * 60) +print("Test 1: Exactly 2.0 WE units (threshold reached)") +print("=" * 60) + +holidays = set() +plan_data = [ + (date(2025, 11, 7), "Alice"), # Friday (WE) + (date(2025, 11, 8), "Alice"), # Saturday (WE) +] + +results = calculate_verguetung(plan_data, holidays) +alice = results[0] +print(f"Employee: {alice['mitarbeiter']}") +print(f"WE Friday: {alice['we_freitag']}") +print(f"WE Other: {alice['we_andere']}") +print(f"WE Total: {alice['we_gesamt']}") +print(f"Threshold reached: {alice['schwelle_erreicht']}") +print(f"WE paid: {alice['we_bezahlt']}") +print(f"Payout WE: {alice['auszahlung_we']}€") +print(f"Payout Total: {alice['auszahlung_gesamt']}€") + +if alice['we_gesamt'] == 2.0 and alice['we_bezahlt'] == 0.0 and alice['auszahlung_we'] == 0: + print("✅ PASS: Correctly deducts 2.0 WE units, resulting in 0€") +else: + print(f"❌ FAIL: Expected 0€ for WE, got {alice['auszahlung_we']}€") + +# Test case 2: 3.0 WE units +# Expected: 3.0 WE - 2.0 deduction = 1.0 paid → 450€ +print("\n" + "=" * 60) +print("Test 2: 3.0 WE units") +print("=" * 60) + +plan_data = [ + (date(2025, 11, 7), "Bob"), # Friday (WE) + (date(2025, 11, 8), "Bob"), # Saturday (WE) + (date(2025, 11, 9), "Bob"), # Sunday (WE) +] + +results = calculate_verguetung(plan_data, holidays) +bob = results[0] +print(f"Employee: {bob['mitarbeiter']}") +print(f"WE Friday: {bob['we_freitag']}") +print(f"WE Other: {bob['we_andere']}") +print(f"WE Total: {bob['we_gesamt']}") +print(f"Threshold reached: {bob['schwelle_erreicht']}") +print(f"WE paid: {bob['we_bezahlt']}") +print(f"Payout WE: {bob['auszahlung_we']}€") +print(f"Payout Total: {bob['auszahlung_gesamt']}€") + +if bob['we_gesamt'] == 3.0 and bob['we_bezahlt'] == 1.0 and bob['auszahlung_we'] == 450: + print("✅ PASS: Correctly deducts 2.0 WE units, resulting in 450€") +else: + print(f"❌ FAIL: Expected 450€ for WE, got {bob['auszahlung_we']}€") + +# Test case 3: 1.0 WE unit (below threshold) +# Expected: No payment (threshold not reached) +print("\n" + "=" * 60) +print("Test 3: 1.0 WE units (below threshold)") +print("=" * 60) + +plan_data = [ + (date(2025, 11, 8), "Charlie"), # Saturday (WE) +] + +results = calculate_verguetung(plan_data, holidays) +charlie = results[0] +print(f"Employee: {charlie['mitarbeiter']}") +print(f"WE Total: {charlie['we_gesamt']}") +print(f"Threshold reached: {charlie['schwelle_erreicht']}") +print(f"WE paid: {charlie['we_bezahlt']}") +print(f"Payout WE: {charlie['auszahlung_we']}€") +print(f"Payout Total: {charlie['auszahlung_gesamt']}€") + +if charlie['we_gesamt'] == 1.0 and charlie['we_bezahlt'] == 0.0 and charlie['auszahlung_we'] == 0: + print("✅ PASS: Below threshold, no payment") +else: + print(f"❌ FAIL: Expected 0€, got {charlie['auszahlung_we']}€") + +# Test case 4: Mixed WT and WE (2 WT + 2 WE) +# Expected: WT always paid (500€), WE: 2.0 - 2.0 = 0 paid (0€), Total: 500€ +print("\n" + "=" * 60) +print("Test 4: 2.0 WT + 2.0 WE units") +print("=" * 60) + +plan_data = [ + (date(2025, 11, 3), "Diana"), # Monday (WT) + (date(2025, 11, 4), "Diana"), # Tuesday (WT) + (date(2025, 11, 7), "Diana"), # Friday (WE) + (date(2025, 11, 8), "Diana"), # Saturday (WE) +] + +results = calculate_verguetung(plan_data, holidays) +diana = results[0] +print(f"Employee: {diana['mitarbeiter']}") +print(f"WT units: {diana['wt_einheiten']}") +print(f"WE Total: {diana['we_gesamt']}") +print(f"Threshold reached: {diana['schwelle_erreicht']}") +print(f"WE paid: {diana['we_bezahlt']}") +print(f"Payout WT: {diana['auszahlung_wt']}€") +print(f"Payout WE: {diana['auszahlung_we']}€") +print(f"Payout Total: {diana['auszahlung_gesamt']}€") + +if diana['wt_einheiten'] == 2.0 and diana['auszahlung_wt'] == 500 and diana['we_bezahlt'] == 0.0 and diana['auszahlung_we'] == 0 and diana['auszahlung_gesamt'] == 500: + print("✅ PASS: WT paid (500€), WE deducted completely (0€), Total: 500€") +else: + print(f"❌ FAIL: Expected total 500€, got {diana['auszahlung_gesamt']}€") + +print("\n" + "=" * 60) +print("Test Summary") +print("=" * 60) +print("All tests verify that the deduction is now 2.0 WE units (not 1.0)") +print("This matches the business requirement from the issue.") diff --git a/webapp/test-suite.js b/webapp/test-suite.js index 4fff30b..03e85a9 100644 --- a/webapp/test-suite.js +++ b/webapp/test-suite.js @@ -204,9 +204,9 @@ runner.test('Berechnung: Genau 2.0 WE-Tage = 450€', (t) => { t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben'); t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein'); - t.assertEqual(result.qualifyingDaysDeducted, 1.0, 'Sollte 1.0 Tag abziehen'); - t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 Tag bezahlen'); - t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein'); + t.assertEqual(result.qualifyingDaysDeducted, 2.0, 'Sollte 2.0 Tage abziehen'); + t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 Tage bezahlen'); + t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein'); }); runner.test('Berechnung: 2x halbe WE-Dienste = 0€ (genau Schwelle, aber nach Abzug nichts)', (t) => { @@ -224,11 +224,11 @@ runner.test('Berechnung: 2x halbe WE-Dienste = 0€ (genau Schwelle, aber nach A t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben (4×0.5)'); t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein'); - t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 Tag bezahlen nach Abzug'); - t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein'); + t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 Tage bezahlen nach Abzug'); + t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein'); }); -runner.test('Berechnung: 3 WE-Tage = 900€', (t) => { +runner.test('Berechnung: 3 WE-Tage = 450€', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); @@ -241,8 +241,8 @@ runner.test('Berechnung: 3 WE-Tage = 900€', (t) => { const result = calculator.calculateMonthlyBonus(duties); t.assertEqual(result.qualifyingDays, 3.0, 'Sollte 3.0 qualifizierende Tage haben'); - t.assertEqual(result.qualifyingDaysPaid, 2.0, 'Sollte 2.0 Tage bezahlen (3-1)'); - t.assertEqual(result.totalBonus, 900, 'Bonus sollte 900€ sein (2×450€)'); + t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 Tag bezahlen (3-2)'); + t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein (1×450€)'); }); runner.test('Berechnung: Normale Tage + WE-Tage gemischt', (t) => { @@ -261,10 +261,10 @@ runner.test('Berechnung: Normale Tage + WE-Tage gemischt', (t) => { t.assertEqual(result.normalDays, 2.0, 'Sollte 2.0 normale Tage haben'); t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben'); t.assertEqual(result.normalDaysPaid, 2.0, 'Sollte 2.0 normale Tage bezahlen'); - t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 qualifizierenden Tag bezahlen'); + t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 qualifizierenden Tag bezahlen'); t.assertEqual(result.bonusNormalDays, 500, 'Normale Tage: 2×250€ = 500€'); - t.assertEqual(result.bonusQualifyingDays, 450, 'WE-Tage: 1×450€ = 450€'); - t.assertEqual(result.totalBonus, 950, 'Gesamt: 950€'); + t.assertEqual(result.bonusQualifyingDays, 0, 'WE-Tage: 0×450€ = 0€'); + t.assertEqual(result.totalBonus, 500, 'Gesamt: 500€'); }); runner.test('Berechnung: Halbe Dienste korrekt berechnet', (t) => { @@ -282,10 +282,10 @@ runner.test('Berechnung: Halbe Dienste korrekt berechnet', (t) => { t.assertEqual(result.normalDays, 0.5, 'Sollte 0.5 normale Tage haben'); t.assertEqual(result.qualifyingDays, 2.5, 'Sollte 2.5 qualifizierende Tage haben'); - t.assertEqual(result.qualifyingDaysPaid, 1.5, 'Sollte 1.5 qualifizierende Tage bezahlen'); + t.assertEqual(result.qualifyingDaysPaid, 0.5, 'Sollte 0.5 qualifizierende Tage bezahlen'); t.assertEqual(result.bonusNormalDays, 125, 'Normale Tage: 0.5×250€ = 125€'); - t.assertEqual(result.bonusQualifyingDays, 675, 'WE-Tage: 1.5×450€ = 675€'); - t.assertEqual(result.totalBonus, 800, 'Gesamt: 800€'); + t.assertEqual(result.bonusQualifyingDays, 225, 'WE-Tage: 0.5×450€ = 225€'); + t.assertEqual(result.totalBonus, 350, 'Gesamt: 350€'); }); runner.test('Berechnung: Feiertag + Vortag', (t) => { @@ -301,7 +301,7 @@ runner.test('Berechnung: Feiertag + Vortag', (t) => { t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben'); t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein'); - t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein'); + t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein'); }); runner.test('Berechnung: Keine Dienste = 0€', (t) => { From 57335915f558734f9c99d0a54c4f01bfc2c58ed8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:40:01 +0000 Subject: [PATCH 10/25] Remove temporary test file --- test_deduction.py | 124 ---------------------------------------------- 1 file changed, 124 deletions(-) delete mode 100644 test_deduction.py diff --git a/test_deduction.py b/test_deduction.py deleted file mode 100644 index b09ecfc..0000000 --- a/test_deduction.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify the weekend deduction change from 1.0 to 2.0 units. -""" - -from datetime import date -import sys -sys.path.insert(0, 'src') -from calculate import calculate_verguetung - -# Test case 1: Exactly 2.0 WE units (threshold reached) -# Expected: 2.0 WE - 2.0 deduction = 0.0 paid → 0€ for WE -print("=" * 60) -print("Test 1: Exactly 2.0 WE units (threshold reached)") -print("=" * 60) - -holidays = set() -plan_data = [ - (date(2025, 11, 7), "Alice"), # Friday (WE) - (date(2025, 11, 8), "Alice"), # Saturday (WE) -] - -results = calculate_verguetung(plan_data, holidays) -alice = results[0] -print(f"Employee: {alice['mitarbeiter']}") -print(f"WE Friday: {alice['we_freitag']}") -print(f"WE Other: {alice['we_andere']}") -print(f"WE Total: {alice['we_gesamt']}") -print(f"Threshold reached: {alice['schwelle_erreicht']}") -print(f"WE paid: {alice['we_bezahlt']}") -print(f"Payout WE: {alice['auszahlung_we']}€") -print(f"Payout Total: {alice['auszahlung_gesamt']}€") - -if alice['we_gesamt'] == 2.0 and alice['we_bezahlt'] == 0.0 and alice['auszahlung_we'] == 0: - print("✅ PASS: Correctly deducts 2.0 WE units, resulting in 0€") -else: - print(f"❌ FAIL: Expected 0€ for WE, got {alice['auszahlung_we']}€") - -# Test case 2: 3.0 WE units -# Expected: 3.0 WE - 2.0 deduction = 1.0 paid → 450€ -print("\n" + "=" * 60) -print("Test 2: 3.0 WE units") -print("=" * 60) - -plan_data = [ - (date(2025, 11, 7), "Bob"), # Friday (WE) - (date(2025, 11, 8), "Bob"), # Saturday (WE) - (date(2025, 11, 9), "Bob"), # Sunday (WE) -] - -results = calculate_verguetung(plan_data, holidays) -bob = results[0] -print(f"Employee: {bob['mitarbeiter']}") -print(f"WE Friday: {bob['we_freitag']}") -print(f"WE Other: {bob['we_andere']}") -print(f"WE Total: {bob['we_gesamt']}") -print(f"Threshold reached: {bob['schwelle_erreicht']}") -print(f"WE paid: {bob['we_bezahlt']}") -print(f"Payout WE: {bob['auszahlung_we']}€") -print(f"Payout Total: {bob['auszahlung_gesamt']}€") - -if bob['we_gesamt'] == 3.0 and bob['we_bezahlt'] == 1.0 and bob['auszahlung_we'] == 450: - print("✅ PASS: Correctly deducts 2.0 WE units, resulting in 450€") -else: - print(f"❌ FAIL: Expected 450€ for WE, got {bob['auszahlung_we']}€") - -# Test case 3: 1.0 WE unit (below threshold) -# Expected: No payment (threshold not reached) -print("\n" + "=" * 60) -print("Test 3: 1.0 WE units (below threshold)") -print("=" * 60) - -plan_data = [ - (date(2025, 11, 8), "Charlie"), # Saturday (WE) -] - -results = calculate_verguetung(plan_data, holidays) -charlie = results[0] -print(f"Employee: {charlie['mitarbeiter']}") -print(f"WE Total: {charlie['we_gesamt']}") -print(f"Threshold reached: {charlie['schwelle_erreicht']}") -print(f"WE paid: {charlie['we_bezahlt']}") -print(f"Payout WE: {charlie['auszahlung_we']}€") -print(f"Payout Total: {charlie['auszahlung_gesamt']}€") - -if charlie['we_gesamt'] == 1.0 and charlie['we_bezahlt'] == 0.0 and charlie['auszahlung_we'] == 0: - print("✅ PASS: Below threshold, no payment") -else: - print(f"❌ FAIL: Expected 0€, got {charlie['auszahlung_we']}€") - -# Test case 4: Mixed WT and WE (2 WT + 2 WE) -# Expected: WT always paid (500€), WE: 2.0 - 2.0 = 0 paid (0€), Total: 500€ -print("\n" + "=" * 60) -print("Test 4: 2.0 WT + 2.0 WE units") -print("=" * 60) - -plan_data = [ - (date(2025, 11, 3), "Diana"), # Monday (WT) - (date(2025, 11, 4), "Diana"), # Tuesday (WT) - (date(2025, 11, 7), "Diana"), # Friday (WE) - (date(2025, 11, 8), "Diana"), # Saturday (WE) -] - -results = calculate_verguetung(plan_data, holidays) -diana = results[0] -print(f"Employee: {diana['mitarbeiter']}") -print(f"WT units: {diana['wt_einheiten']}") -print(f"WE Total: {diana['we_gesamt']}") -print(f"Threshold reached: {diana['schwelle_erreicht']}") -print(f"WE paid: {diana['we_bezahlt']}") -print(f"Payout WT: {diana['auszahlung_wt']}€") -print(f"Payout WE: {diana['auszahlung_we']}€") -print(f"Payout Total: {diana['auszahlung_gesamt']}€") - -if diana['wt_einheiten'] == 2.0 and diana['auszahlung_wt'] == 500 and diana['we_bezahlt'] == 0.0 and diana['auszahlung_we'] == 0 and diana['auszahlung_gesamt'] == 500: - print("✅ PASS: WT paid (500€), WE deducted completely (0€), Total: 500€") -else: - print(f"❌ FAIL: Expected total 500€, got {diana['auszahlung_gesamt']}€") - -print("\n" + "=" * 60) -print("Test Summary") -print("=" * 60) -print("All tests verify that the deduction is now 2.0 WE units (not 1.0)") -print("This matches the business requirement from the issue.") From 89862e918cfb277bb7b2877be4e809974e2bba23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:57:27 +0000 Subject: [PATCH 11/25] Initial plan From a2cc8340ee5bed530ba2376daee9e9d12080b7e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:04:38 +0000 Subject: [PATCH 12/25] Fix deduction value from 2.0 to 1.0 across all files for consistency Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- CHANGELOG.md | 2 +- README.md | 2 +- SPECIFICATION.md | 6 ++-- android-app/README.md | 2 +- .../dienstplan/nrw/data/PayrollCalculator.kt | 8 ++--- .../dienstplan/nrw/PayrollCalculatorTest.kt | 23 ++++++------- src/build_template.py | 4 +-- src/calculate.py | 2 +- webapp/README.md | 8 ++--- webapp/TEST_GUIDE.md | 8 ++--- webapp/calculator.js | 4 +-- webapp/test-suite.js | 32 +++++++++---------- 12 files changed, 51 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb5283e..bb01259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Added native Android mobile app for duty roster management with the same NRW Var - Same WT-Tag classification - Same compensation rates (WT: 250€, WE: 450€) - Same threshold logic (≥ 2.0 WE units) -- Same deduction rules (2.0 units, Friday priority) +- Same deduction rules (1.0 unit, Friday priority) - Same Variante 2 behavior (no WE compensation below threshold) **Testing:** diff --git a/README.md b/README.md index 69e94b4..1b333c7 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Native Android-App für mobiles Dienstplan-Management. Siehe [android-app/README - ✅ Automatische Erkennung von Wochenenden (Fr–So), Feiertagen und Vortagen - ✅ Vergütungslogik: WT 250€, WE 450€ (nur ab Schwelle ≥ 2,0 WE-Einheiten) -- ✅ Abzug 2,0 WE-Einheiten (Freitag-Priorität) nach Erreichen der Schwelle +- ✅ Abzug 1,0 WE-Einheit (Freitag-Priorität) nach Erreichen der Schwelle - ✅ Vorbefüllte Monatsvorlagen mit allen Datumswerten - ✅ Excel-kompatibel (ohne Office 365 Funktionen) diff --git a/SPECIFICATION.md b/SPECIFICATION.md index 6021df3..213d6a5 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -4,7 +4,7 @@ Stand: 14.11.2025 (Deutschland) ## Ziel -Diese README beschreibt vollständig, wie eine Excel-Arbeitsmappe aufgebaut wird, die Monatsdienste erfasst und automatisch die Vergütung ermittelt – inkl. Erkennung von Wochenend-/Feiertagsdiensten (inkl. Vortag), Schwellenlogik und Abzug 1,0 WE-Einheit. Variante 2 (streng) ist aktiv: WE-Dienste werden nur vergütet, wenn im Monat mindestens 2,0 WE-Einheiten erreicht werden; sonst 0 €. Wochentage (kein WE) werden stets vergütet. +Diese README beschreibt vollständig, wie eine Excel-Arbeitsmappe aufgebaut wird, die Monatsdienste erfasst und automatisch die Vergütung ermittelt – inkl. Erkennung von Wochenend-/Feiertagsdiensten (inkl. Vortag), Schwellenlogik und Abzug 1,0 WE-Einheit nach Erreichen der Schwelle. Variante 2 (streng) ist aktiv: WE-Dienste werden nur vergütet, wenn im Monat mindestens 2,0 WE-Einheiten erreicht werden; sonst 0 €. Wochentage (kein WE) werden ebenfalls nur bei Erreichen der WE-Schwelle vergütet. Hinweise: - Region: Deutschland, Bundesland wählbar (steuert Feiertage). @@ -30,7 +30,7 @@ Hinweise: - **WE** (WE-Tag): - Wenn Monats-Summe WE-Einheiten < 2,0 → Auszahlung 0 € für alle WE-Einheiten. - Wenn Monats-Summe WE-Einheiten ≥ 2,0 → Auszahlung 450 €/WE-Einheit, - anschließend Abzug genau 2,0 WE-Einheiten (max. 1× pro Person/Monat). + anschließend Abzug genau 1,0 WE-Einheit (max. 1× pro Person/Monat). - Abzugs-Priorität: zuerst aus Freitag-WE-Einheiten, Rest aus den übrigen WE-Einheiten (Sa/So/Feiertag/Vortag). Chronologie muss nicht nachgebildet werden; es genügt die Priorität nach Kategorie. ### Splits/Anteile @@ -51,7 +51,7 @@ Hinweise: - Satz_WT = 250 - Satz_WE = 450 - WE_Schwelle = 2,0 -- Abzug_nach_WE_Schwelle = 2,0 +- Abzug_nach_WE_Schwelle = 1,0 - BL_Auswahl = Dropdown (z. B. BW, BY, BE, …) - Monat_Auswahl = Datum (erster Tag des Zielmonats, z. B. 01.11.2025) - Variante = 2 (fix auf „streng") diff --git a/android-app/README.md b/android-app/README.md index 062be7d..7326ceb 100644 --- a/android-app/README.md +++ b/android-app/README.md @@ -123,7 +123,7 @@ Edit `PayrollCalculator.kt` and modify the constants: - `RATE_WT`: Weekday rate (default 250€) - `RATE_WE`: Weekend rate (default 450€) - `WE_THRESHOLD`: Threshold for WE compensation (default 2.0) -- `DEDUCTION_AFTER_THRESHOLD`: Deduction amount (default 2.0) +- `DEDUCTION_AFTER_THRESHOLD`: Deduction amount (default 1.0) ### Adding Holidays diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt b/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt index 5d8f932..4269a88 100644 --- a/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt +++ b/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt @@ -12,11 +12,11 @@ import kotlin.math.min * Business rules: * - WE-Tag (Weekend/Holiday): Friday, Saturday, Sunday, public holiday, day before public holiday * - WT-Tag (Weekday): All other days - * - WT compensation: Always 250€ per unit + * - WT compensation: 250€ per unit (only if threshold reached) * - WE compensation: Only paid if monthly total >= 2.0 WE units (threshold) - * - If threshold reached: 450€ per WE unit, then deduct exactly 2.0 WE units + * - If threshold reached: 450€ per WE unit, then deduct exactly 1.0 WE unit * - Deduction priority: Friday first, then other WE days - * - Below threshold: 0€ for WE shifts (NOT converted to WT) + * - Below threshold: 0€ for all shifts (neither WT nor WE) */ class PayrollCalculator { @@ -24,7 +24,7 @@ class PayrollCalculator { private const val RATE_WT = 250.0 // Satz_WT private const val RATE_WE = 450.0 // Satz_WE private const val WE_THRESHOLD = 2.0 // WE_Schwelle - private const val DEDUCTION_AFTER_THRESHOLD = 2.0 // Abzug_nach_WE_Schwelle + private const val DEDUCTION_AFTER_THRESHOLD = 1.0 // Abzug_nach_WE_Schwelle private const val TOLERANCE = 0.0001 // For floating-point comparisons } diff --git a/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt b/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt index dbabf20..0cbef49 100644 --- a/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt +++ b/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt @@ -52,7 +52,7 @@ class PayrollCalculatorTest { /** * Test Case 2: Exactly at threshold (2.0 WE) - * Expected: WE payout = 0€ (0.0 units after deduction), threshold reached + * Expected: WE payout = 450€ (1.0 unit after deduction), threshold reached */ @Test fun testExactlyAtThreshold() { @@ -67,14 +67,14 @@ class PayrollCalculatorTest { val result = results[0] assertEquals(2.0, result.weTotal, 0.001) assertTrue(result.thresholdReached) - assertEquals(2.0, result.deductionTotal, 0.001) - assertEquals(0.0, result.wePaid, 0.001) - assertEquals(0.0, result.payoutWE, 0.001) + assertEquals(1.0, result.deductionTotal, 0.001) + assertEquals(1.0, result.wePaid, 0.001) + assertEquals(450.0, result.payoutWE, 0.001) } /** * Test Case 3: Over threshold (3.5 WE) - * Expected: WE payout = 675€ (1.5 units after deduction) + * Expected: WE payout = 1125€ (2.5 units after 1.0 deduction) */ @Test fun testOverThreshold() { @@ -91,8 +91,8 @@ class PayrollCalculatorTest { val result = results[0] assertEquals(3.5, result.weTotal, 0.001) assertTrue(result.thresholdReached) - assertEquals(1.5, result.wePaid, 0.001) - assertEquals(675.0, result.payoutWE, 0.001) + assertEquals(2.5, result.wePaid, 0.001) + assertEquals(1125.0, result.payoutWE, 0.001) } /** @@ -115,8 +115,9 @@ class PayrollCalculatorTest { assertEquals(0.4, result.weFriday, 0.001) assertEquals(1.6, result.weOther, 0.001) assertEquals(0.4, result.deductionFriday, 0.001) // All Friday deducted first - assertEquals(1.6, result.deductionOther, 0.001) // Rest from other (1.6 to reach 2.0 total) - assertEquals(0.0, result.wePaid, 0.001) + assertEquals(0.6, result.deductionOther, 0.001) // Rest from other (0.6 to reach 1.0 total) + assertEquals(1.0, result.wePaid, 0.001) + assertEquals(450.0, result.payoutWE, 0.001) } /** @@ -147,8 +148,8 @@ class PayrollCalculatorTest { // B: above threshold assertTrue(resultB.thresholdReached) assertEquals(2.5, resultB.weTotal, 0.001) - assertEquals(0.5, resultB.wePaid, 0.001) - assertEquals(225.0, resultB.payoutWE, 0.001) + assertEquals(1.5, resultB.wePaid, 0.001) + assertEquals(675.0, resultB.payoutWE, 0.001) } private fun parseDate(dateString: String): Date { diff --git a/src/build_template.py b/src/build_template.py index 7a6d1b5..1e32aff 100644 --- a/src/build_template.py +++ b/src/build_template.py @@ -61,7 +61,7 @@ def _populate_readme(ws): rules = [ "WE-Tag = Fr/Sa/So/Feiertag/Vortag (BL-abhängig).", "Variante 2 (streng): WE werden nur vergütet, wenn im Monat ≥ 2,0 WE-Einheiten erreicht werden;", - "dann 450 €/WE und Abzug 2,0 (Freitag zuerst). WT werden immer mit 250 € vergütet.", + "dann 450 €/WE und Abzug 1,0 (Freitag zuerst). WT werden bei Erreichen der WE-Schwelle mit 250 € vergütet.", "Splits anteilig. Monat und Bundesland in 'Regeln' wählen.", "", "Schritte:", @@ -83,7 +83,7 @@ def _populate_rules(ws): ("Satz_WT", 250, "Euro für jeden Werktagsdienst (Mo–Do, sofern kein WE-Tag)"), ("Satz_WE", 450, "Euro für jeden WE-Tag (Fr–So, Feiertag, Vortag Feiertag)"), ("WE_Schwelle", 2.0, "Ab dieser WE-Anzahl wird vergütet (sonst 0 €)"), - ("Abzug_nach_WE_Schwelle", 2.0, "Einheiten, die nach Erreichen der Schwelle abgezogen werden"), + ("Abzug_nach_WE_Schwelle", 1.0, "Einheiten, die nach Erreichen der Schwelle abgezogen werden"), ("BL_Auswahl", "NRW", "Bundesland (steuert Feiertage)"), ("Monat_Auswahl", date(2025, 11, 1), "Erster Tag des Zielmonats"), ("Variante", 2, "Fix: 2 = streng (WE nur bei Schwelle ≥ 2,0)"), diff --git a/src/calculate.py b/src/calculate.py index f2464e7..1faa1f5 100644 --- a/src/calculate.py +++ b/src/calculate.py @@ -14,7 +14,7 @@ from collections import defaultdict SATZ_WT = 250 # Euro für Werktag SATZ_WE = 450 # Euro für Wochenende WE_SCHWELLE = 2.0 # Mindestanzahl WE-Dienste für Vergütung -ABZUG = 2.0 # Abzug nach Erreichen der Schwelle +ABZUG = 1.0 # Abzug nach Erreichen der Schwelle def load_holidays(wb): diff --git a/webapp/README.md b/webapp/README.md index 12e74a0..bd6db7e 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -21,7 +21,7 @@ Eine Web-Anwendung zur Berechnung von Bonuszahlungen für Wochenend- und Feierta ### Bonusberechnung 1. **Schwellenwert**: Mindestens **2.0 qualifizierende Tage** im Monat erforderlich -2. **Abzug**: Bei Erreichen des Schwellenwerts werden **2.0 qualifizierende Tage** abgezogen +2. **Abzug**: Bei Erreichen des Schwellenwerts wird **1.0 qualifizierender Tag** abgezogen (Freitag-Priorität) 3. **Vergütung**: - Normale Tage: **250€** pro Tag - Qualifizierende Tage (WE/Feiertag): **450€** pro Tag @@ -34,9 +34,9 @@ Mitarbeiter hat im Monat: **Berechnung**: - Qualifizierende Tage: 3.0 (Schwellenwert erreicht ✓) -- Abzug: -2.0 qualifizierende Tage -- Bezahlt: 3 normale Tage + 1 qualifizierender Tag -- **Bonus**: (3 × 250€) + (1 × 450€) = **1.200€** +- Abzug: -1.0 qualifizierender Tag +- Bezahlt: 3 normale Tage + 2 qualifizierende Tage +- **Bonus**: (3 × 250€) + (2 × 450€) = **1.650€** ## Installation & Nutzung diff --git a/webapp/TEST_GUIDE.md b/webapp/TEST_GUIDE.md index b8e29f0..7722ab8 100644 --- a/webapp/TEST_GUIDE.md +++ b/webapp/TEST_GUIDE.md @@ -84,8 +84,8 @@ Dienste: Erwartung: - Qualifizierende Tage: 2.0 - Schwellenwert: ✅ Erreicht -- Abzug: -2.0 -- Bezahlt: 0.0 × 450€ = 0€ +- Abzug: -1.0 +- Bezahlt: 1.0 × 450€ = 450€ ``` ### Beispiel 2: Gemischte Dienste @@ -96,8 +96,8 @@ Dienste: Erwartung: - Normale Tage: 2.0 × 250€ = 500€ -- Qualifizierende Tage: (2.0 - 2.0) × 450€ = 0€ -- Gesamt: 500€ +- Qualifizierende Tage: (2.0 - 1.0) × 450€ = 450€ +- Gesamt: 950€ ``` ### Beispiel 3: Halbe Dienste diff --git a/webapp/calculator.js b/webapp/calculator.js index d705419..bc5c787 100644 --- a/webapp/calculator.js +++ b/webapp/calculator.js @@ -108,8 +108,8 @@ class BonusCalculator { let totalDeduction = 0; if (thresholdReached) { - // Deduct 2.0 qualifying days with Friday priority - totalDeduction = 2.0; + // Deduct 1.0 qualifying day with Friday priority + totalDeduction = 1.0; // First deduct from Friday deductionFromFriday = Math.min(totalDeduction, qualifyingDaysFriday); diff --git a/webapp/test-suite.js b/webapp/test-suite.js index 03e85a9..9b7272b 100644 --- a/webapp/test-suite.js +++ b/webapp/test-suite.js @@ -204,12 +204,12 @@ runner.test('Berechnung: Genau 2.0 WE-Tage = 450€', (t) => { t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben'); t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein'); - t.assertEqual(result.qualifyingDaysDeducted, 2.0, 'Sollte 2.0 Tage abziehen'); - t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 Tage bezahlen'); - t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein'); + t.assertEqual(result.qualifyingDaysDeducted, 1.0, 'Sollte 1.0 Tag abziehen'); + t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 Tag bezahlen'); + t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein'); }); -runner.test('Berechnung: 2x halbe WE-Dienste = 0€ (genau Schwelle, aber nach Abzug nichts)', (t) => { +runner.test('Berechnung: 2x halbe WE-Dienste = 450€ (genau Schwelle, nach Abzug 1.0)', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); @@ -224,11 +224,11 @@ runner.test('Berechnung: 2x halbe WE-Dienste = 0€ (genau Schwelle, aber nach A t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben (4×0.5)'); t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein'); - t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 Tage bezahlen nach Abzug'); - t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein'); + t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 Tag bezahlen nach Abzug'); + t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein'); }); -runner.test('Berechnung: 3 WE-Tage = 450€', (t) => { +runner.test('Berechnung: 3 WE-Tage = 900€', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); @@ -241,8 +241,8 @@ runner.test('Berechnung: 3 WE-Tage = 450€', (t) => { const result = calculator.calculateMonthlyBonus(duties); t.assertEqual(result.qualifyingDays, 3.0, 'Sollte 3.0 qualifizierende Tage haben'); - t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 Tag bezahlen (3-2)'); - t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein (1×450€)'); + t.assertEqual(result.qualifyingDaysPaid, 2.0, 'Sollte 2.0 Tage bezahlen (3-1)'); + t.assertEqual(result.totalBonus, 900, 'Bonus sollte 900€ sein (2×450€)'); }); runner.test('Berechnung: Normale Tage + WE-Tage gemischt', (t) => { @@ -261,10 +261,10 @@ runner.test('Berechnung: Normale Tage + WE-Tage gemischt', (t) => { t.assertEqual(result.normalDays, 2.0, 'Sollte 2.0 normale Tage haben'); t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben'); t.assertEqual(result.normalDaysPaid, 2.0, 'Sollte 2.0 normale Tage bezahlen'); - t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 qualifizierenden Tag bezahlen'); + t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 qualifizierenden Tag bezahlen'); t.assertEqual(result.bonusNormalDays, 500, 'Normale Tage: 2×250€ = 500€'); - t.assertEqual(result.bonusQualifyingDays, 0, 'WE-Tage: 0×450€ = 0€'); - t.assertEqual(result.totalBonus, 500, 'Gesamt: 500€'); + t.assertEqual(result.bonusQualifyingDays, 450, 'WE-Tage: 1×450€ = 450€'); + t.assertEqual(result.totalBonus, 950, 'Gesamt: 950€'); }); runner.test('Berechnung: Halbe Dienste korrekt berechnet', (t) => { @@ -282,10 +282,10 @@ runner.test('Berechnung: Halbe Dienste korrekt berechnet', (t) => { t.assertEqual(result.normalDays, 0.5, 'Sollte 0.5 normale Tage haben'); t.assertEqual(result.qualifyingDays, 2.5, 'Sollte 2.5 qualifizierende Tage haben'); - t.assertEqual(result.qualifyingDaysPaid, 0.5, 'Sollte 0.5 qualifizierende Tage bezahlen'); + t.assertEqual(result.qualifyingDaysPaid, 1.5, 'Sollte 1.5 qualifizierende Tage bezahlen'); t.assertEqual(result.bonusNormalDays, 125, 'Normale Tage: 0.5×250€ = 125€'); - t.assertEqual(result.bonusQualifyingDays, 225, 'WE-Tage: 0.5×450€ = 225€'); - t.assertEqual(result.totalBonus, 350, 'Gesamt: 350€'); + t.assertEqual(result.bonusQualifyingDays, 675, 'WE-Tage: 1.5×450€ = 675€'); + t.assertEqual(result.totalBonus, 800, 'Gesamt: 800€'); }); runner.test('Berechnung: Feiertag + Vortag', (t) => { @@ -301,7 +301,7 @@ runner.test('Berechnung: Feiertag + Vortag', (t) => { t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben'); t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein'); - t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein'); + t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein (2.0 - 1.0 = 1.0 × 450€)'); }); runner.test('Berechnung: Keine Dienste = 0€', (t) => { From f5d300bfed56f84a974577a3f2f305c27b4bf4d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:06:28 +0000 Subject: [PATCH 13/25] Improve bonus report text format for cleaner output Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- Dienstplan_Portable.html | 21 ++++++++++++--------- webapp/app.js | 21 ++++++++++++--------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/Dienstplan_Portable.html b/Dienstplan_Portable.html index 5fa79f0..0d8931f 100644 --- a/Dienstplan_Portable.html +++ b/Dienstplan_Portable.html @@ -1296,20 +1296,23 @@ class DienstplanApp { totalBonus += bonus; - // Generate note for this employee + // Generate note for this employee - cleaner, more professional format const safeName = this.sanitizeName(name); - let note = `${safeName}: `; + let note = ''; if (!thresholdReached) { - note += `Erreicht das Bonussystem nicht (nur ${we_total.toFixed(1)} WE-Einheiten, mind. 2,0 erforderlich).`; + note = `${safeName} erreicht die Mindestschwelle nicht (${we_total.toFixed(1)} von 2,0 WE-Einheiten) und erhält daher keine Bonuszahlung.`; } 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})`); + const paid_we = we_total - 1.0; + let breakdown = []; + if (data.wt > 0) breakdown.push(`${data.wt.toFixed(1)} WT-Einheiten à 250 €`); + if (paid_we > 0) breakdown.push(`${paid_we.toFixed(1)} WE-Einheiten à 450 €`); + + note = `${safeName} erhält eine Bonuszahlung von ${this.formatCurrency(bonus)}`; + if (breakdown.length > 0) { + note += ` (${breakdown.join(' + ')})`; } - note += `Erhält ${this.formatCurrency(bonus)}. ${details.join(', ')}.`; + note += '.'; } employeeNotes.push(note); diff --git a/webapp/app.js b/webapp/app.js index 45a3519..dd97592 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -757,20 +757,23 @@ class DienstplanApp { totalBonus += bonus; - // Generate note + // Generate note - cleaner, more professional format const safeName = escapeHtml(name); - let note = `${safeName}: `; + let note = ''; if (!thresholdReached) { - note += `Erreicht das Bonussystem nicht (nur ${we_total.toFixed(1)} WE-Einheiten, mind. 2,0 erforderlich).`; + note = `${safeName} erreicht die Mindestschwelle nicht (${we_total.toFixed(1)} von 2,0 WE-Einheiten) und erhält daher keine Bonuszahlung.`; } 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})`); + const paid_we = we_total - 1.0; + let breakdown = []; + if (data.wt > 0) breakdown.push(`${data.wt.toFixed(1)} WT-Einheiten à 250 €`); + if (paid_we > 0) breakdown.push(`${paid_we.toFixed(1)} WE-Einheiten à 450 €`); + + note = `${safeName} erhält eine Bonuszahlung von ${this.calculator.formatCurrency(bonus)}`; + if (breakdown.length > 0) { + note += ` (${breakdown.join(' + ')})`; } - note += `Erhält ${this.calculator.formatCurrency(bonus)}. ${details.join(', ')}.`; + note += '.'; } employeeNotes.push(note); From feb9c9f712612de652bf31c97cd46dac648973b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:11:07 +0000 Subject: [PATCH 14/25] Address code review feedback: use constants for deduction values Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- Dienstplan_Portable.html | 8 ++++---- webapp/app.js | 10 +++++----- webapp/calculator.js | 5 +++-- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Dienstplan_Portable.html b/Dienstplan_Portable.html index 0d8931f..3b84e6c 100644 --- a/Dienstplan_Portable.html +++ b/Dienstplan_Portable.html @@ -1301,12 +1301,12 @@ class DienstplanApp { let note = ''; if (!thresholdReached) { - note = `${safeName} erreicht die Mindestschwelle nicht (${we_total.toFixed(1)} von 2,0 WE-Einheiten) und erhält daher keine Bonuszahlung.`; + note = `${safeName} erreicht die Mindestschwelle nicht (${we_total.toFixed(1)} von ${CONFIG.THRESHOLD.toFixed(1)} WE-Einheiten) und erhält daher keine Bonuszahlung.`; } else { - const paid_we = we_total - 1.0; + const paid_we = we_total - CONFIG.DEDUCTION; let breakdown = []; - if (data.wt > 0) breakdown.push(`${data.wt.toFixed(1)} WT-Einheiten à 250 €`); - if (paid_we > 0) breakdown.push(`${paid_we.toFixed(1)} WE-Einheiten à 450 €`); + if (data.wt > 0) breakdown.push(`${data.wt.toFixed(1)} WT-Einheiten à ${CONFIG.RATE_WT} €`); + if (paid_we > 0) breakdown.push(`${paid_we.toFixed(1)} WE-Einheiten à ${CONFIG.RATE_WE} €`); note = `${safeName} erhält eine Bonuszahlung von ${this.formatCurrency(bonus)}`; if (breakdown.length > 0) { diff --git a/webapp/app.js b/webapp/app.js index dd97592..b03568f 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -738,7 +738,7 @@ class DienstplanApp { if (thresholdReached) { const wt_pay = data.wt * this.calculator.RATE_NORMAL; - let deduct = 1.0; + let deduct = this.calculator.DEDUCTION_AMOUNT; 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); @@ -762,12 +762,12 @@ class DienstplanApp { let note = ''; if (!thresholdReached) { - note = `${safeName} erreicht die Mindestschwelle nicht (${we_total.toFixed(1)} von 2,0 WE-Einheiten) und erhält daher keine Bonuszahlung.`; + note = `${safeName} erreicht die Mindestschwelle nicht (${we_total.toFixed(1)} von ${this.calculator.MIN_QUALIFYING_DAYS.toFixed(1)} WE-Einheiten) und erhält daher keine Bonuszahlung.`; } else { - const paid_we = we_total - 1.0; + const paid_we = we_total - this.calculator.DEDUCTION_AMOUNT; let breakdown = []; - if (data.wt > 0) breakdown.push(`${data.wt.toFixed(1)} WT-Einheiten à 250 €`); - if (paid_we > 0) breakdown.push(`${paid_we.toFixed(1)} WE-Einheiten à 450 €`); + if (data.wt > 0) breakdown.push(`${data.wt.toFixed(1)} WT-Einheiten à ${this.calculator.RATE_NORMAL} €`); + if (paid_we > 0) breakdown.push(`${paid_we.toFixed(1)} WE-Einheiten à ${this.calculator.RATE_WEEKEND} €`); note = `${safeName} erhält eine Bonuszahlung von ${this.calculator.formatCurrency(bonus)}`; if (breakdown.length > 0) { diff --git a/webapp/calculator.js b/webapp/calculator.js index bc5c787..2dd855b 100644 --- a/webapp/calculator.js +++ b/webapp/calculator.js @@ -8,6 +8,7 @@ class BonusCalculator { this.RATE_NORMAL = 250; // Normal day rate (not weekend/holiday) this.RATE_WEEKEND = 450; // Weekend/holiday rate this.MIN_QUALIFYING_DAYS = 2.0; // Minimum qualifying days to trigger bonus + this.DEDUCTION_AMOUNT = 1.0; // Deduction after reaching threshold } /** @@ -108,8 +109,8 @@ class BonusCalculator { let totalDeduction = 0; if (thresholdReached) { - // Deduct 1.0 qualifying day with Friday priority - totalDeduction = 1.0; + // Deduct qualifying days with Friday priority + totalDeduction = this.DEDUCTION_AMOUNT; // First deduct from Friday deductionFromFriday = Math.min(totalDeduction, qualifyingDaysFriday); From 6ecfd895e149568f8d8387ea837ab779d81f260c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:12:49 +0000 Subject: [PATCH 15/25] Update claude.md documentation to reflect correct 1.0 deduction value Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- claude.md | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/claude.md b/claude.md index 9fde052..3755eaa 100644 --- a/claude.md +++ b/claude.md @@ -53,11 +53,11 @@ Die ältere Implementierung nutzt eine andere Logik: - **WE-Tag** (Weekend): Fr-So + Feiertag + Vortag Feiertag 2. **Bonusberechnung**: - - **WT-Tage** werden **immer** mit 250€ vergütet + - **WT-Tage** werden bei Erreichen der Schwelle mit 250€ vergütet - **WE-Tage** nur vergütet wenn ≥ 2.0 WE-Einheiten: - Bei Erreichen: 450€ pro WE-Tag - - Dann Abzug von 2.0 WE-Einheiten (Freitag-Priorität) - - Unter Schwellenwert: WE-Dienste = 0€ (nicht als WT vergütet) + - Dann Abzug von 1.0 WE-Einheit (Freitag-Priorität) + - Unter Schwellenwert: Keine Bonuszahlung (weder WE noch WT) ### Wichtiger Unterschied - Beispiel @@ -179,13 +179,13 @@ adb install app/build/outputs/apk/debug/app-debug.apk - 2 × Montag (2.0) - 2 × Samstag (2.0) - Erwartung: - - 2.0 qualifizierende → -2.0 Abzug → 0.0 bezahlt - - Bonus: (2 × 250€) + (0 × 450€) = **500€** + - 2.0 qualifizierende → -1.0 Abzug → 1.0 bezahlt + - Bonus: (2 × 250€) + (1 × 450€) = **950€** ### Testfall 4: Feiertag + Vortag - 1 × Donnerstag vor Karfreitag (qualifizierend!) - 1 × Karfreitag (Feiertag, qualifizierend!) -- Erwartung: 2.0 qualifizierende → -2.0 → 0.0 × 450€ = **0€** +- Erwartung: 2.0 qualifizierende → -1.0 → 1.0 × 450€ = **450€** ## Häufige Anpassungen @@ -203,17 +203,12 @@ this.RATE_WEEKEND = 500; // Statt 450 ``` ### Abzug ändern (Web-App) -Aktuell ist der Abzug fest auf 2.0 kodiert in `webapp/calculator.js`, Zeile 112: +Der Abzug ist als Konstante in `webapp/calculator.js` definiert: ```javascript -qualifyingDaysDeducted = 2.0; +this.DEDUCTION_AMOUNT = 1.0; // Im Constructor ``` -Um dies flexibel zu machen, könnte man hinzufügen: -```javascript -this.DEDUCTION_AMOUNT = 2.0; // Im Constructor -// Dann verwenden: -qualifyingDaysDeducted = this.DEDUCTION_AMOUNT; -``` +Um den Abzugswert zu ändern, einfach diesen Wert anpassen. ## Code-Architektur From db6e95f45ce00cbaa07f29c42895dd38713e631c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:26:34 +0000 Subject: [PATCH 16/25] Revert deduction value to 2.0 across all files as per user feedback Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- CHANGELOG.md | 2 +- README.md | 2 +- SPECIFICATION.md | 6 ++-- android-app/README.md | 2 +- .../dienstplan/nrw/data/PayrollCalculator.kt | 2 +- .../dienstplan/nrw/PayrollCalculatorTest.kt | 26 +++++++------- claude.md | 10 +++--- src/build_template.py | 4 +-- src/calculate.py | 2 +- webapp/README.md | 8 ++--- webapp/TEST_GUIDE.md | 12 +++---- webapp/calculator.js | 2 +- webapp/test-suite.js | 34 +++++++++---------- 13 files changed, 56 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb01259..bb5283e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Added native Android mobile app for duty roster management with the same NRW Var - Same WT-Tag classification - Same compensation rates (WT: 250€, WE: 450€) - Same threshold logic (≥ 2.0 WE units) -- Same deduction rules (1.0 unit, Friday priority) +- Same deduction rules (2.0 units, Friday priority) - Same Variante 2 behavior (no WE compensation below threshold) **Testing:** diff --git a/README.md b/README.md index 1b333c7..69e94b4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Native Android-App für mobiles Dienstplan-Management. Siehe [android-app/README - ✅ Automatische Erkennung von Wochenenden (Fr–So), Feiertagen und Vortagen - ✅ Vergütungslogik: WT 250€, WE 450€ (nur ab Schwelle ≥ 2,0 WE-Einheiten) -- ✅ Abzug 1,0 WE-Einheit (Freitag-Priorität) nach Erreichen der Schwelle +- ✅ Abzug 2,0 WE-Einheiten (Freitag-Priorität) nach Erreichen der Schwelle - ✅ Vorbefüllte Monatsvorlagen mit allen Datumswerten - ✅ Excel-kompatibel (ohne Office 365 Funktionen) diff --git a/SPECIFICATION.md b/SPECIFICATION.md index 213d6a5..f135c53 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -4,7 +4,7 @@ Stand: 14.11.2025 (Deutschland) ## Ziel -Diese README beschreibt vollständig, wie eine Excel-Arbeitsmappe aufgebaut wird, die Monatsdienste erfasst und automatisch die Vergütung ermittelt – inkl. Erkennung von Wochenend-/Feiertagsdiensten (inkl. Vortag), Schwellenlogik und Abzug 1,0 WE-Einheit nach Erreichen der Schwelle. Variante 2 (streng) ist aktiv: WE-Dienste werden nur vergütet, wenn im Monat mindestens 2,0 WE-Einheiten erreicht werden; sonst 0 €. Wochentage (kein WE) werden ebenfalls nur bei Erreichen der WE-Schwelle vergütet. +Diese README beschreibt vollständig, wie eine Excel-Arbeitsmappe aufgebaut wird, die Monatsdienste erfasst und automatisch die Vergütung ermittelt – inkl. Erkennung von Wochenend-/Feiertagsdiensten (inkl. Vortag), Schwellenlogik und Abzug 2,0 WE-Einheiten nach Erreichen der Schwelle. Variante 2 (streng) ist aktiv: WE-Dienste werden nur vergütet, wenn im Monat mindestens 2,0 WE-Einheiten erreicht werden; sonst 0 €. Wochentage (kein WE) werden ebenfalls nur bei Erreichen der WE-Schwelle vergütet. Hinweise: - Region: Deutschland, Bundesland wählbar (steuert Feiertage). @@ -30,7 +30,7 @@ Hinweise: - **WE** (WE-Tag): - Wenn Monats-Summe WE-Einheiten < 2,0 → Auszahlung 0 € für alle WE-Einheiten. - Wenn Monats-Summe WE-Einheiten ≥ 2,0 → Auszahlung 450 €/WE-Einheit, - anschließend Abzug genau 1,0 WE-Einheit (max. 1× pro Person/Monat). + anschließend Abzug genau 2,0 WE-Einheiten (max. 1× pro Person/Monat). - Abzugs-Priorität: zuerst aus Freitag-WE-Einheiten, Rest aus den übrigen WE-Einheiten (Sa/So/Feiertag/Vortag). Chronologie muss nicht nachgebildet werden; es genügt die Priorität nach Kategorie. ### Splits/Anteile @@ -51,7 +51,7 @@ Hinweise: - Satz_WT = 250 - Satz_WE = 450 - WE_Schwelle = 2,0 -- Abzug_nach_WE_Schwelle = 1,0 +- Abzug_nach_WE_Schwelle = 2,0 - BL_Auswahl = Dropdown (z. B. BW, BY, BE, …) - Monat_Auswahl = Datum (erster Tag des Zielmonats, z. B. 01.11.2025) - Variante = 2 (fix auf „streng") diff --git a/android-app/README.md b/android-app/README.md index 7326ceb..062be7d 100644 --- a/android-app/README.md +++ b/android-app/README.md @@ -123,7 +123,7 @@ Edit `PayrollCalculator.kt` and modify the constants: - `RATE_WT`: Weekday rate (default 250€) - `RATE_WE`: Weekend rate (default 450€) - `WE_THRESHOLD`: Threshold for WE compensation (default 2.0) -- `DEDUCTION_AFTER_THRESHOLD`: Deduction amount (default 1.0) +- `DEDUCTION_AFTER_THRESHOLD`: Deduction amount (default 2.0) ### Adding Holidays diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt b/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt index 4269a88..48aafb4 100644 --- a/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt +++ b/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt @@ -24,7 +24,7 @@ class PayrollCalculator { private const val RATE_WT = 250.0 // Satz_WT private const val RATE_WE = 450.0 // Satz_WE private const val WE_THRESHOLD = 2.0 // WE_Schwelle - private const val DEDUCTION_AFTER_THRESHOLD = 1.0 // Abzug_nach_WE_Schwelle + private const val DEDUCTION_AFTER_THRESHOLD = 2.0 // Abzug_nach_WE_Schwelle private const val TOLERANCE = 0.0001 // For floating-point comparisons } diff --git a/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt b/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt index 0cbef49..6a03b0a 100644 --- a/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt +++ b/android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt @@ -52,7 +52,7 @@ class PayrollCalculatorTest { /** * Test Case 2: Exactly at threshold (2.0 WE) - * Expected: WE payout = 450€ (1.0 unit after deduction), threshold reached + * Expected: WE payout = 0€ (0.0 units after 2.0 deduction), threshold reached */ @Test fun testExactlyAtThreshold() { @@ -67,14 +67,14 @@ class PayrollCalculatorTest { val result = results[0] assertEquals(2.0, result.weTotal, 0.001) assertTrue(result.thresholdReached) - assertEquals(1.0, result.deductionTotal, 0.001) - assertEquals(1.0, result.wePaid, 0.001) - assertEquals(450.0, result.payoutWE, 0.001) + assertEquals(2.0, result.deductionTotal, 0.001) + assertEquals(0.0, result.wePaid, 0.001) + assertEquals(0.0, result.payoutWE, 0.001) } /** * Test Case 3: Over threshold (3.5 WE) - * Expected: WE payout = 1125€ (2.5 units after 1.0 deduction) + * Expected: WE payout = 675€ (1.5 units after 2.0 deduction) */ @Test fun testOverThreshold() { @@ -91,8 +91,8 @@ class PayrollCalculatorTest { val result = results[0] assertEquals(3.5, result.weTotal, 0.001) assertTrue(result.thresholdReached) - assertEquals(2.5, result.wePaid, 0.001) - assertEquals(1125.0, result.payoutWE, 0.001) + assertEquals(1.5, result.wePaid, 0.001) + assertEquals(675.0, result.payoutWE, 0.001) } /** @@ -115,9 +115,9 @@ class PayrollCalculatorTest { assertEquals(0.4, result.weFriday, 0.001) assertEquals(1.6, result.weOther, 0.001) assertEquals(0.4, result.deductionFriday, 0.001) // All Friday deducted first - assertEquals(0.6, result.deductionOther, 0.001) // Rest from other (0.6 to reach 1.0 total) - assertEquals(1.0, result.wePaid, 0.001) - assertEquals(450.0, result.payoutWE, 0.001) + assertEquals(1.6, result.deductionOther, 0.001) // Rest from other (1.6 to reach 2.0 total) + assertEquals(0.0, result.wePaid, 0.001) + assertEquals(0.0, result.payoutWE, 0.001) } /** @@ -145,11 +145,11 @@ class PayrollCalculatorTest { assertFalse(resultA.thresholdReached) assertEquals(0.0, resultA.payoutWE, 0.001) - // B: above threshold + // B: above threshold (2.5 WE - 2.0 deduction = 0.5 paid) assertTrue(resultB.thresholdReached) assertEquals(2.5, resultB.weTotal, 0.001) - assertEquals(1.5, resultB.wePaid, 0.001) - assertEquals(675.0, resultB.payoutWE, 0.001) + assertEquals(0.5, resultB.wePaid, 0.001) + assertEquals(225.0, resultB.payoutWE, 0.001) } private fun parseDate(dateString: String): Date { diff --git a/claude.md b/claude.md index 3755eaa..0d18bd2 100644 --- a/claude.md +++ b/claude.md @@ -56,7 +56,7 @@ Die ältere Implementierung nutzt eine andere Logik: - **WT-Tage** werden bei Erreichen der Schwelle mit 250€ vergütet - **WE-Tage** nur vergütet wenn ≥ 2.0 WE-Einheiten: - Bei Erreichen: 450€ pro WE-Tag - - Dann Abzug von 1.0 WE-Einheit (Freitag-Priorität) + - Dann Abzug von 2.0 WE-Einheiten (Freitag-Priorität) - Unter Schwellenwert: Keine Bonuszahlung (weder WE noch WT) ### Wichtiger Unterschied - Beispiel @@ -179,13 +179,13 @@ adb install app/build/outputs/apk/debug/app-debug.apk - 2 × Montag (2.0) - 2 × Samstag (2.0) - Erwartung: - - 2.0 qualifizierende → -1.0 Abzug → 1.0 bezahlt - - Bonus: (2 × 250€) + (1 × 450€) = **950€** + - 2.0 qualifizierende → -2.0 Abzug → 0.0 bezahlt + - Bonus: (2 × 250€) + (0 × 450€) = **500€** ### Testfall 4: Feiertag + Vortag - 1 × Donnerstag vor Karfreitag (qualifizierend!) - 1 × Karfreitag (Feiertag, qualifizierend!) -- Erwartung: 2.0 qualifizierende → -1.0 → 1.0 × 450€ = **450€** +- Erwartung: 2.0 qualifizierende → -2.0 → 0.0 × 450€ = **0€** ## Häufige Anpassungen @@ -205,7 +205,7 @@ this.RATE_WEEKEND = 500; // Statt 450 ### Abzug ändern (Web-App) Der Abzug ist als Konstante in `webapp/calculator.js` definiert: ```javascript -this.DEDUCTION_AMOUNT = 1.0; // Im Constructor +this.DEDUCTION_AMOUNT = 2.0; // Im Constructor ``` Um den Abzugswert zu ändern, einfach diesen Wert anpassen. diff --git a/src/build_template.py b/src/build_template.py index 1e32aff..f9f3cfd 100644 --- a/src/build_template.py +++ b/src/build_template.py @@ -61,7 +61,7 @@ def _populate_readme(ws): rules = [ "WE-Tag = Fr/Sa/So/Feiertag/Vortag (BL-abhängig).", "Variante 2 (streng): WE werden nur vergütet, wenn im Monat ≥ 2,0 WE-Einheiten erreicht werden;", - "dann 450 €/WE und Abzug 1,0 (Freitag zuerst). WT werden bei Erreichen der WE-Schwelle mit 250 € vergütet.", + "dann 450 €/WE und Abzug 2,0 (Freitag zuerst). WT werden bei Erreichen der WE-Schwelle mit 250 € vergütet.", "Splits anteilig. Monat und Bundesland in 'Regeln' wählen.", "", "Schritte:", @@ -83,7 +83,7 @@ def _populate_rules(ws): ("Satz_WT", 250, "Euro für jeden Werktagsdienst (Mo–Do, sofern kein WE-Tag)"), ("Satz_WE", 450, "Euro für jeden WE-Tag (Fr–So, Feiertag, Vortag Feiertag)"), ("WE_Schwelle", 2.0, "Ab dieser WE-Anzahl wird vergütet (sonst 0 €)"), - ("Abzug_nach_WE_Schwelle", 1.0, "Einheiten, die nach Erreichen der Schwelle abgezogen werden"), + ("Abzug_nach_WE_Schwelle", 2.0, "Einheiten, die nach Erreichen der Schwelle abgezogen werden"), ("BL_Auswahl", "NRW", "Bundesland (steuert Feiertage)"), ("Monat_Auswahl", date(2025, 11, 1), "Erster Tag des Zielmonats"), ("Variante", 2, "Fix: 2 = streng (WE nur bei Schwelle ≥ 2,0)"), diff --git a/src/calculate.py b/src/calculate.py index 1faa1f5..f2464e7 100644 --- a/src/calculate.py +++ b/src/calculate.py @@ -14,7 +14,7 @@ from collections import defaultdict SATZ_WT = 250 # Euro für Werktag SATZ_WE = 450 # Euro für Wochenende WE_SCHWELLE = 2.0 # Mindestanzahl WE-Dienste für Vergütung -ABZUG = 1.0 # Abzug nach Erreichen der Schwelle +ABZUG = 2.0 # Abzug nach Erreichen der Schwelle def load_holidays(wb): diff --git a/webapp/README.md b/webapp/README.md index bd6db7e..00da5ad 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -21,7 +21,7 @@ Eine Web-Anwendung zur Berechnung von Bonuszahlungen für Wochenend- und Feierta ### Bonusberechnung 1. **Schwellenwert**: Mindestens **2.0 qualifizierende Tage** im Monat erforderlich -2. **Abzug**: Bei Erreichen des Schwellenwerts wird **1.0 qualifizierender Tag** abgezogen (Freitag-Priorität) +2. **Abzug**: Bei Erreichen des Schwellenwerts werden **2.0 qualifizierende Tage** abgezogen (Freitag-Priorität) 3. **Vergütung**: - Normale Tage: **250€** pro Tag - Qualifizierende Tage (WE/Feiertag): **450€** pro Tag @@ -34,9 +34,9 @@ Mitarbeiter hat im Monat: **Berechnung**: - Qualifizierende Tage: 3.0 (Schwellenwert erreicht ✓) -- Abzug: -1.0 qualifizierender Tag -- Bezahlt: 3 normale Tage + 2 qualifizierende Tage -- **Bonus**: (3 × 250€) + (2 × 450€) = **1.650€** +- Abzug: -2.0 qualifizierende Tage +- Bezahlt: 3 normale Tage + 1 qualifizierender Tag +- **Bonus**: (3 × 250€) + (1 × 450€) = **1.200€** ## Installation & Nutzung diff --git a/webapp/TEST_GUIDE.md b/webapp/TEST_GUIDE.md index 7722ab8..6a43fbf 100644 --- a/webapp/TEST_GUIDE.md +++ b/webapp/TEST_GUIDE.md @@ -84,8 +84,8 @@ Dienste: Erwartung: - Qualifizierende Tage: 2.0 - Schwellenwert: ✅ Erreicht -- Abzug: -1.0 -- Bezahlt: 1.0 × 450€ = 450€ +- Abzug: -2.0 +- Bezahlt: 0.0 × 450€ = 0€ ``` ### Beispiel 2: Gemischte Dienste @@ -96,8 +96,8 @@ Dienste: Erwartung: - Normale Tage: 2.0 × 250€ = 500€ -- Qualifizierende Tage: (2.0 - 1.0) × 450€ = 450€ -- Gesamt: 950€ +- Qualifizierende Tage: (2.0 - 2.0) × 450€ = 0€ +- Gesamt: 500€ ``` ### Beispiel 3: Halbe Dienste @@ -110,8 +110,8 @@ Dienste: Erwartung: - Normale Tage: 0.5 × 250€ = 125€ -- Qualifizierende Tage: (2.5 - 1.0) × 450€ = 675€ -- Gesamt: 800€ +- Qualifizierende Tage: (2.5 - 2.0) × 450€ = 225€ +- Gesamt: 350€ ``` ## Tests erweitern diff --git a/webapp/calculator.js b/webapp/calculator.js index 2dd855b..a3edb2e 100644 --- a/webapp/calculator.js +++ b/webapp/calculator.js @@ -8,7 +8,7 @@ class BonusCalculator { this.RATE_NORMAL = 250; // Normal day rate (not weekend/holiday) this.RATE_WEEKEND = 450; // Weekend/holiday rate this.MIN_QUALIFYING_DAYS = 2.0; // Minimum qualifying days to trigger bonus - this.DEDUCTION_AMOUNT = 1.0; // Deduction after reaching threshold + this.DEDUCTION_AMOUNT = 2.0; // Deduction after reaching threshold } /** diff --git a/webapp/test-suite.js b/webapp/test-suite.js index 9b7272b..420306e 100644 --- a/webapp/test-suite.js +++ b/webapp/test-suite.js @@ -191,7 +191,7 @@ runner.test('Berechnung: Unter Schwellenwert (1.0 WE-Tag) = 0€', (t) => { t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein'); }); -runner.test('Berechnung: Genau 2.0 WE-Tage = 450€', (t) => { +runner.test('Berechnung: Genau 2.0 WE-Tage = 0€', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); @@ -204,12 +204,12 @@ runner.test('Berechnung: Genau 2.0 WE-Tage = 450€', (t) => { t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben'); t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein'); - t.assertEqual(result.qualifyingDaysDeducted, 1.0, 'Sollte 1.0 Tag abziehen'); - t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 Tag bezahlen'); - t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein'); + t.assertEqual(result.qualifyingDaysDeducted, 2.0, 'Sollte 2.0 Tage abziehen'); + t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 Tage bezahlen'); + t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein'); }); -runner.test('Berechnung: 2x halbe WE-Dienste = 450€ (genau Schwelle, nach Abzug 1.0)', (t) => { +runner.test('Berechnung: 2x halbe WE-Dienste = 0€ (genau Schwelle, nach Abzug 2.0)', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); @@ -224,11 +224,11 @@ runner.test('Berechnung: 2x halbe WE-Dienste = 450€ (genau Schwelle, nach Abzu t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben (4×0.5)'); t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein'); - t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 Tag bezahlen nach Abzug'); - t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein'); + t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 Tage bezahlen nach Abzug'); + t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein'); }); -runner.test('Berechnung: 3 WE-Tage = 900€', (t) => { +runner.test('Berechnung: 3 WE-Tage = 450€', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); @@ -241,8 +241,8 @@ runner.test('Berechnung: 3 WE-Tage = 900€', (t) => { const result = calculator.calculateMonthlyBonus(duties); t.assertEqual(result.qualifyingDays, 3.0, 'Sollte 3.0 qualifizierende Tage haben'); - t.assertEqual(result.qualifyingDaysPaid, 2.0, 'Sollte 2.0 Tage bezahlen (3-1)'); - t.assertEqual(result.totalBonus, 900, 'Bonus sollte 900€ sein (2×450€)'); + t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 Tage bezahlen (3-2)'); + t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein (1×450€)'); }); runner.test('Berechnung: Normale Tage + WE-Tage gemischt', (t) => { @@ -261,10 +261,10 @@ runner.test('Berechnung: Normale Tage + WE-Tage gemischt', (t) => { t.assertEqual(result.normalDays, 2.0, 'Sollte 2.0 normale Tage haben'); t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben'); t.assertEqual(result.normalDaysPaid, 2.0, 'Sollte 2.0 normale Tage bezahlen'); - t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 qualifizierenden Tag bezahlen'); + t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 qualifizierende Tage bezahlen'); t.assertEqual(result.bonusNormalDays, 500, 'Normale Tage: 2×250€ = 500€'); - t.assertEqual(result.bonusQualifyingDays, 450, 'WE-Tage: 1×450€ = 450€'); - t.assertEqual(result.totalBonus, 950, 'Gesamt: 950€'); + t.assertEqual(result.bonusQualifyingDays, 0, 'WE-Tage: 0×450€ = 0€'); + t.assertEqual(result.totalBonus, 500, 'Gesamt: 500€'); }); runner.test('Berechnung: Halbe Dienste korrekt berechnet', (t) => { @@ -282,10 +282,10 @@ runner.test('Berechnung: Halbe Dienste korrekt berechnet', (t) => { t.assertEqual(result.normalDays, 0.5, 'Sollte 0.5 normale Tage haben'); t.assertEqual(result.qualifyingDays, 2.5, 'Sollte 2.5 qualifizierende Tage haben'); - t.assertEqual(result.qualifyingDaysPaid, 1.5, 'Sollte 1.5 qualifizierende Tage bezahlen'); + t.assertEqual(result.qualifyingDaysPaid, 0.5, 'Sollte 0.5 qualifizierende Tage bezahlen'); t.assertEqual(result.bonusNormalDays, 125, 'Normale Tage: 0.5×250€ = 125€'); - t.assertEqual(result.bonusQualifyingDays, 675, 'WE-Tage: 1.5×450€ = 675€'); - t.assertEqual(result.totalBonus, 800, 'Gesamt: 800€'); + t.assertEqual(result.bonusQualifyingDays, 225, 'WE-Tage: 0.5×450€ = 225€'); + t.assertEqual(result.totalBonus, 350, 'Gesamt: 350€'); }); runner.test('Berechnung: Feiertag + Vortag', (t) => { @@ -301,7 +301,7 @@ runner.test('Berechnung: Feiertag + Vortag', (t) => { t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben'); t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein'); - t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein (2.0 - 1.0 = 1.0 × 450€)'); + t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein (2.0 - 2.0 = 0.0 × 450€)'); }); runner.test('Berechnung: Keine Dienste = 0€', (t) => { From af4473f4b9c09ec096d15789c2c27f76779f1d4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:03:54 +0000 Subject: [PATCH 17/25] Fix remaining 1.0 deduction references to 2.0 based on code review feedback Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- Dienstplan_Portable.html | 8 ++++---- README.md | 2 +- SPECIFICATION.md | 18 +++++++++--------- android-app/IMPLEMENTATION_SUMMARY.md | 4 ++-- .../dienstplan/nrw/data/PayrollCalculator.kt | 2 +- webapp/app.js | 4 ++-- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Dienstplan_Portable.html b/Dienstplan_Portable.html index 3b84e6c..af9cbb1 100644 --- a/Dienstplan_Portable.html +++ b/Dienstplan_Portable.html @@ -448,7 +448,7 @@

Berechnungsregeln (Variante 2 - Streng)

Schwelle: Gesamter Bonus wird nur gezahlt, wenn WE-Einheiten ≥ 2,0.
- Bei Erreichen: WT = 250 EUR/Einheit, WE = 450 EUR/Einheit (abzgl. 1,0 Einheit Abzug).
+ Bei Erreichen: WT = 250 EUR/Einheit, WE = 450 EUR/Einheit (abzgl. 2,0 Einheiten Abzug).
Unter Schwelle: Keine Auszahlung (weder WT noch WE).
WE-Tage: Fr, Sa, So, Feiertage und Vortage von Feiertagen.

@@ -576,7 +576,7 @@ const CONFIG = { RATE_WT: 250, RATE_WE: 450, THRESHOLD: 2.0, - DEDUCTION: 1.0, + DEDUCTION: 2.0, TOLERANCE: 0.0001 }; @@ -1071,7 +1071,7 @@ class DienstplanApp { csv += 'WT;Werktag (Montag-Donnerstag ohne Feiertag/Vortag)\n'; csv += 'WE-Tag;"Freitag, Samstag, Sonntag, Feiertag oder Tag vor Feiertag"\n'; csv += 'Schwelle;"Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich"\n'; - csv += 'Sätze;"WT = 250 EUR/Einheit, WE = 450 EUR/Einheit (abzgl. 1,0 Abzug)"\n'; + csv += 'Sätze;"WT = 250 EUR/Einheit, WE = 450 EUR/Einheit (abzgl. 2,0 Abzug)"\n'; // Download CSV file const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); @@ -1373,7 +1373,7 @@ class DienstplanApp {
  • Vergütung bei Erreichen der Schwelle:
    • Werktage (WT): 250 € pro Einheit
    • -
    • WE-Tage: 450 € pro Einheit (abzüglich 1,0 Einheit Abzug, Freitag zuerst)
    • +
    • WE-Tage: 450 € pro Einheit (abzüglich 2,0 Einheiten Abzug, Freitag zuerst)
  • Unter Schwelle: Keine Bonuszahlung (weder WT noch WE)
  • diff --git a/README.md b/README.md index 69e94b4..4e0e728 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Die Datei landet in `output/Dienstplan_YYYY_MM_NRW.xlsx`. - **WE-Tag**: Fr/Sa/So + Feiertag + Vortag Feiertag - **WT-Tag**: Alle anderen Tage (250 € pro Einheit) -- **WE-Vergütung**: Nur wenn Monatssumme ≥ 2,0 WE-Einheiten → 450 €/Einheit, dann Abzug 1,0 (zuerst von Freitag) +- **WE-Vergütung**: Nur wenn Monatssumme ≥ 2,0 WE-Einheiten → 450 €/Einheit, dann Abzug 2,0 (zuerst von Freitag) - **Unter Schwelle**: WE-Dienste = 0 € (nicht als WT vergütet) Details siehe `SPECIFICATION.md`. diff --git a/SPECIFICATION.md b/SPECIFICATION.md index f135c53..b0a7d7c 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -279,17 +279,17 @@ Beispiel-Formel (als hilfsweise Matrix in Checks): A hat 1,75 WE und 1,0 WT → Auszahlung_WE = 0 €; Auszahlung_WT = 0 €; Auszahlung_Gesamt = 0 €. 2) **Genau Schwelle**: - A hat 2,0 WE (Fr 1,0 + Sa 1,0) → Abzug 1,0 (zuerst Fr) → WE_bezahlt = 1,0 → 450 €. + A hat 2,0 WE (Fr 1,0 + Sa 1,0) → Abzug 2,0 (zuerst Fr) → WE_bezahlt = 0,0 → 0 €. 3) **Über Schwelle ohne Freitag**: - A hat 2,0 WE (nur Sa+So) → Abzug 1,0 aus „Andere" → WE_bezahlt = 1,0 → 450 €. + A hat 2,0 WE (nur Sa+So) → Abzug 2,0 aus „Andere" → WE_bezahlt = 0,0 → 0 €. 4) **Starke Überdeckung**: - A hat 3,5 WE → Abzug 1,0 → WE_bezahlt = 2,5 → 2,5×450 €. + A hat 3,5 WE → Abzug 2,0 → WE_bezahlt = 1,5 → 1,5×450 € = 675 €. 5) **Splits rund um 2,0**: - A hat Fr 0,4 + Sa 0,6 + So 1,0 → Summe 2,0 → Abzug 1,0 - (0,4 von Fr, 0,6 von Andere) → WE_bezahlt = 1,0 → 450 €. + A hat Fr 0,4 + Sa 0,6 + So 1,0 → Summe 2,0 → Abzug 2,0 + (0,4 von Fr, 1,6 von Andere) → WE_bezahlt = 0,0 → 0 €. 6) **Unter Schwelle, nur WE-Tage**: A hat 1,0 WE, 0 WT → Auszahlung_WE = 0 €; Auszahlung_Gesamt = 0 €. @@ -300,9 +300,9 @@ Beispiel-Formel (als hilfsweise Matrix in Checks): ## Edge-Cases und Präzisierungen -- Abzug nur einmal pro Person/Monat (fix 1,0), und nur wenn Schwelle erreicht. +- Abzug nur einmal pro Person/Monat (fix 2,0), und nur wenn Schwelle erreicht. - Der Vortag eines Feiertags ist WE-Tag – unabhängig davon, welcher Wochentag er ist. -- Wenn WE_Freitag < 1,0, wird der restliche Abzug (bis 1,0) von WE_Andere genommen. +- Wenn WE_Freitag < 2,0, wird der restliche Abzug (bis 2,0) von WE_Andere genommen. - Monatswechsel: Daten genau per >=Monat_Auswahl und <=EOMONAT(Monat_Auswahl;0) filtern. - Rundungstoleranz 1e-4 bei Schwelle und Datumssummen (Splits wie 0,33/0,67). - Tabellen-Namen („tblPlan", „tblFeiertage", „tblAuswertung") konsequent verwenden. @@ -324,11 +324,11 @@ Lieferumfang (empfohlen): - 18.11.2025: Korrektur Variante 2: **Gesamter Bonus (WT + WE) wird nur gezahlt, wenn WE_Summe ≥ 2,0**. Unter Schwelle: Auszahlung_Gesamt = 0 € (weder WT noch WE). - 14.11.2025: Umstellung auf Variante 2 (streng). WE-Vergütung nur bei WE_Summe ≥ 2,0, - anschließend Abzug 1,0 (Freitag zuerst). Unterhalb der Schwelle: WE-Auszahlung = 0 €. + anschließend Abzug 2,0 (Freitag zuerst). Unterhalb der Schwelle: WE-Auszahlung = 0 €. - 13.11.2025: Vorversion (Variante 1) mit WE-Auszahlung ab erstem WE-Dienst und Abzug nach Schwelle (ersetzt). ## Kurztext (für Blatt „Regeln" als Readme-Hinweis) -„WE-Tag = Fr/Sa/So/Feiertag/Vortag (BL-abhängig). Variante 2 (streng): Gesamter Bonus (WT + WE) wird nur gezahlt, wenn im Monat ≥ 2,0 WE-Einheiten erreicht werden. Bei Erreichen der Schwelle: WT 250 €/Einheit, WE 450 €/Einheit mit Abzug 1,0 (Freitag zuerst). Unter Schwelle: 0 € Auszahlung. Splits anteilig. Monat und Bundesland oben wählen." +„WE-Tag = Fr/Sa/So/Feiertag/Vortag (BL-abhängig). Variante 2 (streng): Gesamter Bonus (WT + WE) wird nur gezahlt, wenn im Monat ≥ 2,0 WE-Einheiten erreicht werden. Bei Erreichen der Schwelle: WT 250 €/Einheit, WE 450 €/Einheit mit Abzug 2,0 (Freitag zuerst). Unter Schwelle: 0 € Auszahlung. Splits anteilig. Monat und Bundesland oben wählen." — Ende der README — diff --git a/android-app/IMPLEMENTATION_SUMMARY.md b/android-app/IMPLEMENTATION_SUMMARY.md index 55e510f..392424c 100644 --- a/android-app/IMPLEMENTATION_SUMMARY.md +++ b/android-app/IMPLEMENTATION_SUMMARY.md @@ -78,8 +78,8 @@ Implements NRW Variante 2 (streng) rules: #### Unit Tests (PayrollCalculatorTest.kt) Comprehensive test coverage including: 1. **Under threshold test**: 1.75 WE + 1.0 WT → WE payout 0€, WT payout 250€ -2. **Exactly at threshold test**: 2.0 WE → WE payout 450€ (1.0 unit after deduction) -3. **Over threshold test**: 3.5 WE → WE payout 1125€ (2.5 units after deduction) +2. **Exactly at threshold test**: 2.0 WE → WE payout 0€ (0.0 units after 2.0 deduction) +3. **Over threshold test**: 3.5 WE → WE payout 675€ (1.5 units after 2.0 deduction) 4. **Friday deduction priority test**: Verifies deduction comes from Friday first 5. **Multiple employees test**: Separate calculations per employee diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt b/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt index 48aafb4..25ebe44 100644 --- a/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt +++ b/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt @@ -14,7 +14,7 @@ import kotlin.math.min * - WT-Tag (Weekday): All other days * - WT compensation: 250€ per unit (only if threshold reached) * - WE compensation: Only paid if monthly total >= 2.0 WE units (threshold) - * - If threshold reached: 450€ per WE unit, then deduct exactly 1.0 WE unit + * - If threshold reached: 450€ per WE unit, then deduct exactly 2.0 WE units * - Deduction priority: Friday first, then other WE days * - Below threshold: 0€ for all shifts (neither WT nor WE) */ diff --git a/webapp/app.js b/webapp/app.js index b03568f..0b5193a 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -526,7 +526,7 @@ class DienstplanApp { csv += 'WE/Feiertag Tage;"Freitag, Samstag, Sonntag, Feiertag oder Tag vor Feiertag"\n'; csv += 'Schwelle;"Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich"\n'; csv += 'Sätze;"Normale Tage = 250 EUR/Einheit, WE/Feiertag = 450 EUR/Einheit"\n'; - csv += 'Abzug;"Bei Erreichen der Schwelle wird 1,0 WE-Einheit abgezogen"\n'; + csv += 'Abzug;"Bei Erreichen der Schwelle werden 2,0 WE-Einheiten abgezogen"\n'; // Download CSV file const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); @@ -835,7 +835,7 @@ class DienstplanApp {
  • Vergütung bei Erreichen der Schwelle:
    • Werktage (WT): 250 € pro Einheit
    • -
    • WE-Tage: 450 € pro Einheit (abzüglich 1,0 Einheit Abzug, Freitag zuerst)
    • +
    • WE-Tage: 450 € pro Einheit (abzüglich 2,0 Einheiten Abzug, Freitag zuerst)
  • Unter Schwelle: Keine Bonuszahlung (weder WT noch WE)
  • From 5a72140240d3cb7ab8d2b3b949d8e398716de95e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:36:33 +0000 Subject: [PATCH 18/25] Initial plan From 1ee73105dd5516d77505592c8658fc384dfb2a1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:41:15 +0000 Subject: [PATCH 19/25] Add Heiligabend (Dec 24) as holiday for NRW 2025 and 2026 This fixes the issue where: - December 24 was not recognized as a special day - December 23 was not marked as Vortag (pre-holiday day) By adding Heiligabend to the holiday list: - Dec 24 is now Ist_FEIERTAG=TRUE (treated as WE-Tag) - Dec 23 is now Ist_VORTAG=TRUE (day before holiday, also WE-Tag) Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- src/build_template.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/build_template.py b/src/build_template.py index f9f3cfd..c0856c2 100644 --- a/src/build_template.py +++ b/src/build_template.py @@ -23,6 +23,7 @@ NRW_HOLIDAYS_2025 = [ ("2025-06-19", "Fronleichnam", "NRW"), ("2025-10-03", "Tag der Deutschen Einheit", "NRW"), ("2025-11-01", "Allerheiligen", "NRW"), + ("2025-12-24", "Heiligabend", "NRW"), ("2025-12-25", "1. Weihnachtstag", "NRW"), ("2025-12-26", "2. Weihnachtstag", "NRW"), ] @@ -37,6 +38,7 @@ NRW_HOLIDAYS_2026 = [ ("2026-06-04", "Fronleichnam", "NRW"), ("2026-10-03", "Tag der Deutschen Einheit", "NRW"), ("2026-11-01", "Allerheiligen", "NRW"), + ("2026-12-24", "Heiligabend", "NRW"), ("2026-12-25", "1. Weihnachtstag", "NRW"), ("2026-12-26", "2. Weihnachtstag", "NRW"), ] From b1d0827fc9016a36dfd15867d3d3c3ac6aeef8eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 20:00:39 +0000 Subject: [PATCH 20/25] Add Silvester (Dec 31) as holiday for NRW 2025 and 2026 This also makes December 30 a Vortag (pre-holiday day). Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- src/build_template.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/build_template.py b/src/build_template.py index c0856c2..a921480 100644 --- a/src/build_template.py +++ b/src/build_template.py @@ -26,6 +26,7 @@ NRW_HOLIDAYS_2025 = [ ("2025-12-24", "Heiligabend", "NRW"), ("2025-12-25", "1. Weihnachtstag", "NRW"), ("2025-12-26", "2. Weihnachtstag", "NRW"), + ("2025-12-31", "Silvester", "NRW"), ] NRW_HOLIDAYS_2026 = [ @@ -41,6 +42,7 @@ NRW_HOLIDAYS_2026 = [ ("2026-12-24", "Heiligabend", "NRW"), ("2026-12-25", "1. Weihnachtstag", "NRW"), ("2026-12-26", "2. Weihnachtstag", "NRW"), + ("2026-12-31", "Silvester", "NRW"), ] From 278ded35702ad2fd4c7d41e9acd10b563efe3110 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 20:02:56 +0000 Subject: [PATCH 21/25] Initial plan From 8b1c182fd8ef817b6f793ccb785bf956570b1b37 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 20:10:34 +0000 Subject: [PATCH 22/25] Improve export to mark deducted days differently and hide euro values for deducted days Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- webapp/app.js | 57 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/webapp/app.js b/webapp/app.js index 0b5193a..caed7a0 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -673,6 +673,14 @@ class DienstplanApp { border-radius: 3px; font-size: 0.9em; } + .duty-cell .deducted-tag { + background: #fff3cd; + color: #856404; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.9em; + border: 1px dashed #856404; + } .employee-note { margin: 10px 0; padding: 10px; @@ -735,12 +743,14 @@ class DienstplanApp { let bonus = 0; let deductedFrom = ''; + let deduct_fr = 0; + let deduct_other = 0; if (thresholdReached) { const wt_pay = data.wt * this.calculator.RATE_NORMAL; let deduct = this.calculator.DEDUCTION_AMOUNT; - const deduct_fr = Math.min(deduct, data.we_fr); - const deduct_other = Math.max(0, deduct - deduct_fr); + deduct_fr = Math.min(deduct, data.we_fr); + 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; @@ -777,6 +787,10 @@ class DienstplanApp { } employeeNotes.push(note); + // Track remaining deduction for each duty (Friday first, then others) + let remainingDeductFr = deduct_fr; + let remainingDeductOther = deduct_other; + // Build table row html += ` @@ -794,13 +808,46 @@ class DienstplanApp { 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 isFriday = duty.date.getDay() === 5; 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}
    `; + // Determine if this duty is deducted + let deductedAmount = 0; + let paidAmount = duty.share; + + if (thresholdReached && duty.isQual) { + if (isFriday && remainingDeductFr > 0) { + deductedAmount = Math.min(duty.share, remainingDeductFr); + remainingDeductFr -= deductedAmount; + } else if (!isFriday && remainingDeductOther > 0) { + deductedAmount = Math.min(duty.share, remainingDeductOther); + remainingDeductOther -= deductedAmount; + } + paidAmount = duty.share - deductedAmount; + } + + const isFullyDeducted = thresholdReached && duty.isQual && deductedAmount >= duty.share - 0.0001; + const isPartiallyDeducted = thresholdReached && duty.isQual && deductedAmount > 0 && paidAmount > 0; + + // Calculate euro amount only for paid portion + const rate = duty.isQual ? this.calculator.RATE_WEEKEND : this.calculator.RATE_NORMAL; + const amountStr = `${Math.round(paidAmount * rate)}€`; + + // Determine tag style + let tag = duty.isQual ? 'we-tag' : 'wt-tag'; + if (isFullyDeducted) { + tag = 'deducted-tag'; + } + + // Build cell content + cellContent += `${shareStr}X${extraInfo}
    `; + + // Only show euro amount for non-deducted or partially-paid days + if (!isFullyDeducted && (paidAmount > 0 || !duty.isQual)) { + cellContent += `${amountStr}
    `; + } }); html += `${cellContent}`; } From e28178e286af7a80443520c12c267a3e76e5fc12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 20:12:29 +0000 Subject: [PATCH 23/25] Remove unused isPartiallyDeducted variable Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- webapp/app.js | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/app.js b/webapp/app.js index caed7a0..837c2cc 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -829,7 +829,6 @@ class DienstplanApp { } const isFullyDeducted = thresholdReached && duty.isQual && deductedAmount >= duty.share - 0.0001; - const isPartiallyDeducted = thresholdReached && duty.isQual && deductedAmount > 0 && paidAmount > 0; // Calculate euro amount only for paid portion const rate = duty.isQual ? this.calculator.RATE_WEEKEND : this.calculator.RATE_NORMAL; From b9d8e7094bab0068599b2ceb5423e92147157427 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Dec 2025 13:31:18 +0000 Subject: [PATCH 24/25] Add comprehensive error handling to Python and JavaScript files This commit enhances error handling across the codebase with clear, user-friendly error messages: Python Files: - main.py: Added error handling for directory creation and file operations - read_excel.py: Added error handling for file loading, JSON parsing, and data output - fill_plan_dates.py: Added input validation and error handling for file operations and date calculations - calculate.py: Improved error handling in load_holidays() and process_file() with detailed warnings for invalid data - build_template.py: Added error handling for directory creation, workbook creation, and file saving JavaScript Files: - storage.js: Added comprehensive error handling for: - JSON parsing in getEmployees() and getAllDuties() - Type validation in save operations - Date conversion in getDutiesForMonth() - Data validation in saveDutiesForMonth() - Export/import operations Benefits: - Clear error messages in German for better user experience - Graceful degradation when data is corrupted - Type checking to prevent invalid data from being stored - Row-level error reporting for Excel processing - Invalid data filtering to prevent application crashes --- src/build_template.py | 76 +++++++++---- src/calculate.py | 250 +++++++++++++++++++++++++---------------- src/fill_plan_dates.py | 120 +++++++++++++------- src/main.py | 111 ++++++++++-------- src/read_excel.py | 103 ++++++++++------- webapp/storage.js | 144 +++++++++++++++++++----- 6 files changed, 534 insertions(+), 270 deletions(-) diff --git a/src/build_template.py b/src/build_template.py index a921480..c7531a4 100644 --- a/src/build_template.py +++ b/src/build_template.py @@ -284,33 +284,71 @@ def _populate_checks(ws): def build_template(): - TEMPLATE_PATH.parent.mkdir(parents=True, exist_ok=True) - wb = Workbook() + """Builds the complete Excel template with all sheets and formulas.""" + try: + # Create output directory + try: + TEMPLATE_PATH.parent.mkdir(parents=True, exist_ok=True) + except PermissionError: + print(f"❌ Fehler: Keine Berechtigung zum Erstellen des Verzeichnisses '{TEMPLATE_PATH.parent}'") + raise + except OSError as e: + print(f"❌ Fehler beim Erstellen des Verzeichnisses '{TEMPLATE_PATH.parent}': {e}") + raise - readme_ws = wb.active - readme_ws.title = "README" - _populate_readme(readme_ws) + # Create workbook + try: + wb = Workbook() + except Exception as e: + print(f"❌ Fehler beim Erstellen des Workbooks: {e}") + raise - rules_ws = wb.create_sheet("Regeln") - _populate_rules(rules_ws) + try: + readme_ws = wb.active + readme_ws.title = "README" + _populate_readme(readme_ws) - holiday_ws = wb.create_sheet("Feiertage") - _populate_holidays(holiday_ws) + rules_ws = wb.create_sheet("Regeln") + _populate_rules(rules_ws) - plan_ws = wb.create_sheet("Plan") - _populate_plan(plan_ws) + holiday_ws = wb.create_sheet("Feiertage") + _populate_holidays(holiday_ws) - auswertung_ws = wb.create_sheet("Auswertung") - _populate_auswertung(auswertung_ws) + plan_ws = wb.create_sheet("Plan") + _populate_plan(plan_ws) - checks_ws = wb.create_sheet("Checks") - _populate_checks(checks_ws) + auswertung_ws = wb.create_sheet("Auswertung") + _populate_auswertung(auswertung_ws) - wb.save(TEMPLATE_PATH) - return TEMPLATE_PATH + checks_ws = wb.create_sheet("Checks") + _populate_checks(checks_ws) + except Exception as e: + print(f"❌ Fehler beim Erstellen der Arbeitsblätter: {e}") + raise + + # Save template + try: + wb.save(TEMPLATE_PATH) + except PermissionError: + print(f"❌ Fehler: Keine Berechtigung zum Speichern der Datei '{TEMPLATE_PATH}'") + raise + except OSError as e: + print(f"❌ Fehler beim Speichern der Datei '{TEMPLATE_PATH}': {e}") + raise + + return TEMPLATE_PATH + + except Exception as e: + print(f"❌ Unerwarteter Fehler beim Erstellen der Vorlage: {e}") + raise if __name__ == "__main__": - path = build_template() - print(f"✅ Vorlage (Variante 2 – streng) erstellt: {path}") + try: + path = build_template() + print(f"✅ Vorlage (Variante 2 – streng) erstellt: {path}") + except Exception: + # Error already printed in build_template + import sys + sys.exit(1) diff --git a/src/calculate.py b/src/calculate.py index f2464e7..b4db94e 100644 --- a/src/calculate.py +++ b/src/calculate.py @@ -20,25 +20,39 @@ ABZUG = 2.0 # Abzug nach Erreichen der Schwelle def load_holidays(wb): """Lädt Feiertage aus dem Feiertage-Blatt.""" if "Feiertage" not in wb.sheetnames: + print("⚠️ Warnung: Blatt 'Feiertage' nicht gefunden. Keine Feiertage geladen") return set() - + holidays = set() - ws = wb["Feiertage"] - - for row in ws.iter_rows(min_row=2, values_only=True): - if row[0] and row[2] == "NRW": # Datum und BL prüfen - date_raw = row[0] - if isinstance(date_raw, str): - try: - parsed_date = datetime.strptime(date_raw, '%d.%m.%Y').date() - holidays.add(parsed_date) - except: - pass - elif isinstance(date_raw, datetime): - holidays.add(date_raw.date()) - elif isinstance(date_raw, date): - holidays.add(date_raw) - + + try: + ws = wb["Feiertage"] + + for row_num, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + try: + if row[0] and len(row) > 2 and row[2] == "NRW": # Datum und BL prüfen + date_raw = row[0] + if isinstance(date_raw, str): + try: + parsed_date = datetime.strptime(date_raw, '%d.%m.%Y').date() + holidays.add(parsed_date) + except ValueError as e: + print(f"⚠️ Warnung: Ungültiges Datumsformat in Zeile {row_num}: '{date_raw}' - {e}") + continue + elif isinstance(date_raw, datetime): + holidays.add(date_raw.date()) + elif isinstance(date_raw, date): + holidays.add(date_raw) + except IndexError: + print(f"⚠️ Warnung: Unvollständige Zeile {row_num} im Feiertage-Blatt übersprungen") + continue + except Exception as e: + print(f"⚠️ Warnung: Fehler beim Verarbeiten von Zeile {row_num}: {e}") + continue + except Exception as e: + print(f"❌ Fehler beim Laden der Feiertage: {e}") + return set() + return holidays @@ -154,89 +168,131 @@ def calculate_verguetung(plan_data, holidays): def process_file(filepath): """Verarbeitet die Excel-Datei und schreibt Auswertung.""" - - wb = load_workbook(filepath) - - # Lade Feiertage - holidays = load_holidays(wb) - print(f"📅 {len(holidays)} Feiertage geladen") - - # Lade Plan-Daten - if "Plan" not in wb.sheetnames: - print("❌ Blatt 'Plan' nicht gefunden!") + + # Load workbook + try: + wb = load_workbook(filepath) + except FileNotFoundError: + print(f"❌ Fehler: Datei '{filepath}' nicht gefunden") return - - plan_ws = wb["Plan"] - plan_data = [] - - for row in plan_ws.iter_rows(min_row=2, values_only=True): - if row[0]: # Wenn Datum vorhanden - datum_raw = row[0] - mitarbeiter = row[1] if len(row) > 1 else None - - # Parse Datum (kann String oder date sein) - if isinstance(datum_raw, str): - try: - datum = datetime.strptime(datum_raw, '%d.%m.%Y').date() - except: - continue - elif isinstance(datum_raw, datetime): - datum = datum_raw.date() - elif isinstance(datum_raw, date): - datum = datum_raw - else: + except PermissionError: + print(f"❌ Fehler: Keine Berechtigung zum Lesen der Datei '{filepath}'") + return + except Exception as e: + print(f"❌ Fehler beim Laden der Datei '{filepath}': {e}") + return + + try: + # Lade Feiertage + holidays = load_holidays(wb) + print(f"📅 {len(holidays)} Feiertage geladen") + + # Lade Plan-Daten + if "Plan" not in wb.sheetnames: + print("❌ Blatt 'Plan' nicht gefunden!") + return + + plan_ws = wb["Plan"] + plan_data = [] + + for row_num, row in enumerate(plan_ws.iter_rows(min_row=2, values_only=True), start=2): + try: + if row[0]: # Wenn Datum vorhanden + datum_raw = row[0] + mitarbeiter = row[1] if len(row) > 1 else None + + # Parse Datum (kann String oder date sein) + if isinstance(datum_raw, str): + try: + datum = datetime.strptime(datum_raw, '%d.%m.%Y').date() + except ValueError as e: + print(f"⚠️ Warnung: Ungültiges Datumsformat in Zeile {row_num}: '{datum_raw}' - übersprungen") + continue + elif isinstance(datum_raw, datetime): + datum = datum_raw.date() + elif isinstance(datum_raw, date): + datum = datum_raw + else: + print(f"⚠️ Warnung: Unbekannter Datumstyp in Zeile {row_num}: {type(datum_raw)} - übersprungen") + continue + + if mitarbeiter: + plan_data.append((datum, mitarbeiter)) + except Exception as e: + print(f"⚠️ Warnung: Fehler beim Verarbeiten von Plan-Zeile {row_num}: {e}") continue - - if mitarbeiter: - plan_data.append((datum, mitarbeiter)) - - print(f"📋 {len(plan_data)} Einträge im Plan") - - # Berechne Vergütung - results = calculate_verguetung(plan_data, holidays) - - # Schreibe Auswertung - if "Auswertung" not in wb.sheetnames: - print("❌ Blatt 'Auswertung' nicht gefunden!") + + print(f"📋 {len(plan_data)} Einträge im Plan") + + if not plan_data: + print("⚠️ Warnung: Keine gültigen Plan-Einträge gefunden") + + # Berechne Vergütung + try: + results = calculate_verguetung(plan_data, holidays) + except Exception as e: + print(f"❌ Fehler bei der Vergütungsberechnung: {e}") + return + + # Schreibe Auswertung + if "Auswertung" not in wb.sheetnames: + print("❌ Blatt 'Auswertung' nicht gefunden!") + return + + try: + auswertung_ws = wb["Auswertung"] + + # Lösche alte Daten (ab Zeile 2) + auswertung_ws.delete_rows(2, auswertung_ws.max_row) + + # Schreibe neue Daten + for idx, result in enumerate(results, start=2): + auswertung_ws[f"A{idx}"] = result['mitarbeiter'] + auswertung_ws[f"B{idx}"] = round(result['wt_einheiten'], 2) + auswertung_ws[f"C{idx}"] = round(result['we_freitag'], 2) + auswertung_ws[f"D{idx}"] = round(result['we_andere'], 2) + auswertung_ws[f"E{idx}"] = round(result['we_gesamt'], 2) + auswertung_ws[f"F{idx}"] = result['schwelle_erreicht'] + auswertung_ws[f"G{idx}"] = round(result['abzug_freitag'], 2) + auswertung_ws[f"H{idx}"] = round(result['abzug_andere'], 2) + auswertung_ws[f"I{idx}"] = round(result['we_bezahlt'], 2) + auswertung_ws[f"J{idx}"] = round(result['auszahlung_wt'], 2) + auswertung_ws[f"K{idx}"] = round(result['auszahlung_we'], 2) + auswertung_ws[f"L{idx}"] = round(result['auszahlung_gesamt'], 2) + + # Formatierung für Schwelle + if result['schwelle_erreicht'] == 'JA': + auswertung_ws[f"F{idx}"].fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") + else: + auswertung_ws[f"F{idx}"].fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") + except Exception as e: + print(f"❌ Fehler beim Schreiben der Auswertung: {e}") + return + + # Save file + try: + wb.save(filepath) + except PermissionError: + print(f"❌ Fehler: Keine Berechtigung zum Speichern der Datei '{filepath}'") + return + except OSError as e: + print(f"❌ Fehler beim Speichern der Datei '{filepath}': {e}") + return + + print(f"\n✅ Auswertung geschrieben: {len(results)} Mitarbeiter") + print(f" Datei: {filepath}") + + # Zeige Zusammenfassung + print(f"\n{'='*70}") + print(f"{'Mitarbeiter':<20} {'WT':<8} {'WE':<8} {'Schwelle':<10} {'Gesamt':>10}") + print(f"{'='*70}") + for r in results: + print(f"{r['mitarbeiter']:<20} {r['wt_einheiten']:>6.1f} {r['we_gesamt']:>6.1f} {r['schwelle_erreicht']:<10} {r['auszahlung_gesamt']:>9.2f} €") + print(f"{'='*70}") + + except Exception as e: + print(f"❌ Unerwarteter Fehler beim Verarbeiten der Datei: {e}") return - - auswertung_ws = wb["Auswertung"] - - # Lösche alte Daten (ab Zeile 2) - auswertung_ws.delete_rows(2, auswertung_ws.max_row) - - # Schreibe neue Daten - for idx, result in enumerate(results, start=2): - auswertung_ws[f"A{idx}"] = result['mitarbeiter'] - auswertung_ws[f"B{idx}"] = round(result['wt_einheiten'], 2) - auswertung_ws[f"C{idx}"] = round(result['we_freitag'], 2) - auswertung_ws[f"D{idx}"] = round(result['we_andere'], 2) - auswertung_ws[f"E{idx}"] = round(result['we_gesamt'], 2) - auswertung_ws[f"F{idx}"] = result['schwelle_erreicht'] - auswertung_ws[f"G{idx}"] = round(result['abzug_freitag'], 2) - auswertung_ws[f"H{idx}"] = round(result['abzug_andere'], 2) - auswertung_ws[f"I{idx}"] = round(result['we_bezahlt'], 2) - auswertung_ws[f"J{idx}"] = round(result['auszahlung_wt'], 2) - auswertung_ws[f"K{idx}"] = round(result['auszahlung_we'], 2) - auswertung_ws[f"L{idx}"] = round(result['auszahlung_gesamt'], 2) - - # Formatierung für Schwelle - if result['schwelle_erreicht'] == 'JA': - auswertung_ws[f"F{idx}"].fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") - else: - auswertung_ws[f"F{idx}"].fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") - - wb.save(filepath) - print(f"\n✅ Auswertung geschrieben: {len(results)} Mitarbeiter") - print(f" Datei: {filepath}") - - # Zeige Zusammenfassung - print(f"\n{'='*70}") - print(f"{'Mitarbeiter':<20} {'WT':<8} {'WE':<8} {'Schwelle':<10} {'Gesamt':>10}") - print(f"{'='*70}") - for r in results: - print(f"{r['mitarbeiter']:<20} {r['wt_einheiten']:>6.1f} {r['we_gesamt']:>6.1f} {r['schwelle_erreicht']:<10} {r['auszahlung_gesamt']:>9.2f} €") - print(f"{'='*70}") if __name__ == "__main__": diff --git a/src/fill_plan_dates.py b/src/fill_plan_dates.py index 8636113..08d5984 100644 --- a/src/fill_plan_dates.py +++ b/src/fill_plan_dates.py @@ -15,46 +15,88 @@ def fill_plan_with_dates(template_path, output_path, year, month): Lädt die Vorlage und füllt Spalte A (Datum) im Plan-Blatt mit allen Tagen des angegebenen Monats. """ - wb = load_workbook(template_path) - - # Regeln-Blatt: Monat_Auswahl setzen - if "Regeln" in wb.sheetnames: - regeln_ws = wb["Regeln"] - # Zeile 7, Spalte B = Monat_Auswahl - regeln_ws["B7"] = date(year, month, 1) - - # Plan-Blatt füllen - if "Plan" not in wb.sheetnames: - print("❌ Blatt 'Plan' nicht gefunden!") + # Validate input parameters + if not (1 <= month <= 12): + print(f"❌ Fehler: Ungültiger Monat '{month}'. Monat muss zwischen 1 und 12 liegen") + return + + if year < 1900 or year > 2100: + print(f"❌ Fehler: Ungültiges Jahr '{year}'. Jahr muss zwischen 1900 und 2100 liegen") + return + + # Load template workbook + try: + wb = load_workbook(template_path) + except FileNotFoundError: + print(f"❌ Fehler: Vorlagendatei '{template_path}' nicht gefunden") + return + except PermissionError: + print(f"❌ Fehler: Keine Berechtigung zum Lesen der Datei '{template_path}'") + return + except Exception as e: + print(f"❌ Fehler beim Laden der Vorlagendatei '{template_path}': {e}") + return + + try: + # Regeln-Blatt: Monat_Auswahl setzen + if "Regeln" in wb.sheetnames: + regeln_ws = wb["Regeln"] + # Zeile 7, Spalte B = Monat_Auswahl + regeln_ws["B7"] = date(year, month, 1) + + # Plan-Blatt füllen + if "Plan" not in wb.sheetnames: + print("❌ Blatt 'Plan' nicht gefunden!") + return + + plan_ws = wb["Plan"] + + # Startdatum + try: + start_date = date(year, month, 1) + except ValueError as e: + print(f"❌ Fehler: Ungültiges Datum für Jahr {year}, Monat {month}: {e}") + return + + # Letzter Tag des Monats + try: + if month == 12: + end_date = date(year + 1, 1, 1) - timedelta(days=1) + else: + end_date = date(year, month + 1, 1) - timedelta(days=1) + except ValueError as e: + print(f"❌ Fehler beim Berechnen des Enddatums: {e}") + return + + # Alle Tage durchgehen + current_date = start_date + row = 2 # Zeile 2 = erste Datenzeile nach Header + + while current_date <= end_date: + cell = plan_ws[f"A{row}"] + cell.value = current_date + cell.number_format = 'DD.MM.YYYY' # Deutsches Datumsformat + # Spalten B (Mitarbeiter) und C (Anteil) bleiben leer zum Ausfüllen + current_date += timedelta(days=1) + row += 1 + + # Save output file + try: + wb.save(output_path) + except PermissionError: + print(f"❌ Fehler: Keine Berechtigung zum Speichern der Datei '{output_path}'") + return + except OSError as e: + print(f"❌ Fehler beim Speichern der Datei '{output_path}': {e}") + return + + print(f"✅ Plan-Blatt vorbefüllt für {month:02d}/{year}") + print(f" Ausgabe: {output_path}") + print(f" Trage jetzt nur noch in Spalte B (Mitarbeiter) und C (Anteil) die Namen ein!") + + except Exception as e: + print(f"❌ Unerwarteter Fehler beim Füllen des Plan-Blatts: {e}") return - - plan_ws = wb["Plan"] - - # Startdatum - start_date = date(year, month, 1) - - # Letzter Tag des Monats - if month == 12: - end_date = date(year + 1, 1, 1) - timedelta(days=1) - else: - end_date = date(year, month + 1, 1) - timedelta(days=1) - - # Alle Tage durchgehen - current_date = start_date - row = 2 # Zeile 2 = erste Datenzeile nach Header - - while current_date <= end_date: - cell = plan_ws[f"A{row}"] - cell.value = current_date - cell.number_format = 'DD.MM.YYYY' # Deutsches Datumsformat - # Spalten B (Mitarbeiter) und C (Anteil) bleiben leer zum Ausfüllen - current_date += timedelta(days=1) - row += 1 - - wb.save(output_path) - print(f"✅ Plan-Blatt vorbefüllt für {month:02d}/{year}") - print(f" Ausgabe: {output_path}") - print(f" Trage jetzt nur noch in Spalte B (Mitarbeiter) und C (Anteil) die Namen ein!") if __name__ == "__main__": diff --git a/src/main.py b/src/main.py index 0d23dde..80be83f 100644 --- a/src/main.py +++ b/src/main.py @@ -11,52 +11,71 @@ from datetime import datetime def create_example_excel(): """Erstellt eine Beispiel-Excel-Datei mit formatierten Daten.""" - - # Neues Workbook erstellen - wb = Workbook() - ws = wb.active - ws.title = "Beispiel" - - # Überschriften hinzufügen - headers = ["Name", "Alter", "Stadt", "Beruf"] - ws.append(headers) - - # Überschriften formatieren - header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") - header_font = Font(bold=True, color="FFFFFF", size=12) - - for cell in ws[1]: - cell.fill = header_fill - cell.font = header_font - cell.alignment = Alignment(horizontal="center", vertical="center") - - # Beispieldaten hinzufügen - data = [ - ["Max Mustermann", 30, "Berlin", "Entwickler"], - ["Erika Musterfrau", 28, "München", "Designerin"], - ["Hans Schmidt", 35, "Hamburg", "Manager"], - ["Anna Weber", 27, "Köln", "Analyst"], - ] - - for row in data: - ws.append(row) - - # Spaltenbreiten anpassen - ws.column_dimensions['A'].width = 20 - ws.column_dimensions['B'].width = 10 - ws.column_dimensions['C'].width = 15 - ws.column_dimensions['D'].width = 15 - - # Ausgabeverzeichnis erstellen - output_dir = Path("output") - output_dir.mkdir(exist_ok=True) - - # Datei speichern - output_file = output_dir / f"example_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" - wb.save(output_file) - - print(f"Excel-Datei erfolgreich erstellt: {output_file}") - return output_file + + try: + # Neues Workbook erstellen + wb = Workbook() + ws = wb.active + ws.title = "Beispiel" + + # Überschriften hinzufügen + headers = ["Name", "Alter", "Stadt", "Beruf"] + ws.append(headers) + + # Überschriften formatieren + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF", size=12) + + for cell in ws[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center", vertical="center") + + # Beispieldaten hinzufügen + data = [ + ["Max Mustermann", 30, "Berlin", "Entwickler"], + ["Erika Musterfrau", 28, "München", "Designerin"], + ["Hans Schmidt", 35, "Hamburg", "Manager"], + ["Anna Weber", 27, "Köln", "Analyst"], + ] + + for row in data: + ws.append(row) + + # Spaltenbreiten anpassen + ws.column_dimensions['A'].width = 20 + ws.column_dimensions['B'].width = 10 + ws.column_dimensions['C'].width = 15 + ws.column_dimensions['D'].width = 15 + + # Ausgabeverzeichnis erstellen + output_dir = Path("output") + try: + output_dir.mkdir(exist_ok=True) + except PermissionError: + print(f"❌ Fehler: Keine Berechtigung zum Erstellen des Verzeichnisses '{output_dir}'") + raise + except OSError as e: + print(f"❌ Fehler beim Erstellen des Verzeichnisses '{output_dir}': {e}") + raise + + # Datei speichern + output_file = output_dir / f"example_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + try: + wb.save(output_file) + except PermissionError: + print(f"❌ Fehler: Keine Berechtigung zum Speichern der Datei '{output_file}'") + raise + except OSError as e: + print(f"❌ Fehler beim Speichern der Datei '{output_file}': {e}") + raise + + print(f"Excel-Datei erfolgreich erstellt: {output_file}") + return output_file + + except Exception as e: + print(f"❌ Unerwarteter Fehler beim Erstellen der Excel-Datei: {e}") + raise if __name__ == "__main__": diff --git a/src/read_excel.py b/src/read_excel.py index c5e09ef..5e19a1d 100644 --- a/src/read_excel.py +++ b/src/read_excel.py @@ -9,53 +9,80 @@ from pathlib import Path def read_excel_to_dict(filepath): """Liest eine Excel-Datei und gibt die Daten als Dictionary zurück.""" - - wb = load_workbook(filepath, data_only=True) + + try: + wb = load_workbook(filepath, data_only=True) + except FileNotFoundError: + print(f"❌ Fehler: Datei '{filepath}' nicht gefunden") + raise + except PermissionError: + print(f"❌ Fehler: Keine Berechtigung zum Lesen der Datei '{filepath}'") + raise + except Exception as e: + print(f"❌ Fehler beim Laden der Excel-Datei '{filepath}': {e}") + raise + result = {} - - for sheet_name in wb.sheetnames: - ws = wb[sheet_name] - - # Daten aus dem Sheet lesen - data = [] - for row in ws.iter_rows(values_only=True): - # Nur Zeilen mit Inhalt - if any(cell is not None for cell in row): - data.append(list(row)) - - result[sheet_name] = data - + + try: + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + + # Daten aus dem Sheet lesen + data = [] + for row in ws.iter_rows(values_only=True): + # Nur Zeilen mit Inhalt + if any(cell is not None for cell in row): + data.append(list(row)) + + result[sheet_name] = data + except Exception as e: + print(f"❌ Fehler beim Lesen der Daten aus der Excel-Datei: {e}") + raise + return result def print_excel_content(filepath): """Gibt den Inhalt einer Excel-Datei formatiert aus.""" - + print(f"\n{'='*60}") print(f"Excel-Datei: {filepath}") print(f"{'='*60}\n") - - data = read_excel_to_dict(filepath) - - for sheet_name, rows in data.items(): - print(f"\n📊 Sheet: {sheet_name}") - print(f"{'-'*60}") - - if not rows: - print(" (leer)") - continue - - # Tabelle ausgeben - for i, row in enumerate(rows, 1): - row_str = " | ".join(str(cell) if cell is not None else "" for cell in row) - print(f" {i:3d}: {row_str}") - - print(f"\n{'='*60}\n") - - # Als JSON ausgeben - print("📄 JSON-Format:") - print(json.dumps(data, indent=2, ensure_ascii=False)) - + + try: + data = read_excel_to_dict(filepath) + except Exception: + # Error already printed in read_excel_to_dict + raise + + try: + for sheet_name, rows in data.items(): + print(f"\n📊 Sheet: {sheet_name}") + print(f"{'-'*60}") + + if not rows: + print(" (leer)") + continue + + # Tabelle ausgeben + for i, row in enumerate(rows, 1): + row_str = " | ".join(str(cell) if cell is not None else "" for cell in row) + print(f" {i:3d}: {row_str}") + + print(f"\n{'='*60}\n") + + # Als JSON ausgeben + print("📄 JSON-Format:") + try: + print(json.dumps(data, indent=2, ensure_ascii=False)) + except (TypeError, ValueError) as e: + print(f"❌ Fehler beim Konvertieren zu JSON: {e}") + raise + except Exception as e: + print(f"❌ Fehler beim Ausgeben der Excel-Daten: {e}") + raise + return data diff --git a/webapp/storage.js b/webapp/storage.js index 631d0e3..3df3678 100644 --- a/webapp/storage.js +++ b/webapp/storage.js @@ -13,8 +13,21 @@ class DataStorage { * @returns {Array} Array of employee names */ getEmployees() { - const data = localStorage.getItem(this.STORAGE_KEY_EMPLOYEES); - return data ? JSON.parse(data) : []; + try { + const data = localStorage.getItem(this.STORAGE_KEY_EMPLOYEES); + if (!data) { + return []; + } + const parsed = JSON.parse(data); + if (!Array.isArray(parsed)) { + console.error('Fehler: Mitarbeiter-Daten sind kein Array. Zurücksetzen auf leeres Array'); + return []; + } + return parsed; + } catch (e) { + console.error('Fehler beim Laden der Mitarbeiter-Daten:', e); + return []; + } } /** @@ -22,7 +35,16 @@ class DataStorage { * @param {Array} employees - Array of employee names */ saveEmployees(employees) { - localStorage.setItem(this.STORAGE_KEY_EMPLOYEES, JSON.stringify(employees)); + try { + if (!Array.isArray(employees)) { + console.error('Fehler: employees muss ein Array sein'); + throw new TypeError('employees muss ein Array sein'); + } + localStorage.setItem(this.STORAGE_KEY_EMPLOYEES, JSON.stringify(employees)); + } catch (e) { + console.error('Fehler beim Speichern der Mitarbeiter-Daten:', e); + throw e; + } } /** @@ -63,8 +85,21 @@ class DataStorage { * @returns {Object} Object with structure: {employeeName: {year-month: [duties]}} */ getAllDuties() { - const data = localStorage.getItem(this.STORAGE_KEY_DUTIES); - return data ? JSON.parse(data) : {}; + try { + const data = localStorage.getItem(this.STORAGE_KEY_DUTIES); + if (!data) { + return {}; + } + const parsed = JSON.parse(data); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + console.error('Fehler: Dienst-Daten sind kein gültiges Objekt. Zurücksetzen auf leeres Objekt'); + return {}; + } + return parsed; + } catch (e) { + console.error('Fehler beim Laden der Dienst-Daten:', e); + return {}; + } } /** @@ -72,7 +107,16 @@ class DataStorage { * @param {Object} duties */ saveAllDuties(duties) { - localStorage.setItem(this.STORAGE_KEY_DUTIES, JSON.stringify(duties)); + try { + if (typeof duties !== 'object' || duties === null || Array.isArray(duties)) { + console.error('Fehler: duties muss ein gültiges Objekt sein'); + throw new TypeError('duties muss ein gültiges Objekt sein'); + } + localStorage.setItem(this.STORAGE_KEY_DUTIES, JSON.stringify(duties)); + } catch (e) { + console.error('Fehler beim Speichern der Dienst-Daten:', e); + throw e; + } } /** @@ -83,18 +127,35 @@ class DataStorage { * @returns {Array} Array of duty objects */ getDutiesForMonth(employeeName, year, month) { - const allDuties = this.getAllDuties(); - const monthKey = `${year}-${String(month).padStart(2, '0')}`; + try { + const allDuties = this.getAllDuties(); + const monthKey = `${year}-${String(month).padStart(2, '0')}`; - if (!allDuties[employeeName] || !allDuties[employeeName][monthKey]) { + if (!allDuties[employeeName] || !allDuties[employeeName][monthKey]) { + return []; + } + + // Convert date strings back to Date objects + return allDuties[employeeName][monthKey].map(duty => { + try { + const dateObj = new Date(duty.date); + if (isNaN(dateObj.getTime())) { + console.error(`Fehler: Ungültiges Datum für Dienst: ${duty.date}`); + return null; + } + return { + ...duty, + date: dateObj + }; + } catch (e) { + console.error('Fehler beim Konvertieren des Datums:', e); + return null; + } + }).filter(duty => duty !== null); // Filter out invalid entries + } catch (e) { + console.error('Fehler beim Laden der Dienste für Monat:', e); return []; } - - // Convert date strings back to Date objects - return allDuties[employeeName][monthKey].map(duty => ({ - ...duty, - date: new Date(duty.date) - })); } /** @@ -105,20 +166,36 @@ class DataStorage { * @param {Array} duties - Array of duty objects */ saveDutiesForMonth(employeeName, year, month, duties) { - const allDuties = this.getAllDuties(); - const monthKey = `${year}-${String(month).padStart(2, '0')}`; + try { + if (!Array.isArray(duties)) { + console.error('Fehler: duties muss ein Array sein'); + throw new TypeError('duties muss ein Array sein'); + } - if (!allDuties[employeeName]) { - allDuties[employeeName] = {}; + const allDuties = this.getAllDuties(); + const monthKey = `${year}-${String(month).padStart(2, '0')}`; + + if (!allDuties[employeeName]) { + allDuties[employeeName] = {}; + } + + // Convert Date objects to strings for storage + allDuties[employeeName][monthKey] = duties.map(duty => { + if (!duty.date || !(duty.date instanceof Date)) { + console.error('Fehler: Dienst hat kein gültiges Datum:', duty); + throw new TypeError('Dienst muss ein gültiges Date-Objekt haben'); + } + return { + ...duty, + date: duty.date.toISOString() + }; + }); + + this.saveAllDuties(allDuties); + } catch (e) { + console.error('Fehler beim Speichern der Dienste für Monat:', e); + throw e; } - - // Convert Date objects to strings for storage - allDuties[employeeName][monthKey] = duties.map(duty => ({ - ...duty, - date: duty.date.toISOString() - })); - - this.saveAllDuties(allDuties); } /** @@ -196,10 +273,15 @@ class DataStorage { * @returns {string} JSON string */ exportData() { - return JSON.stringify({ - employees: this.getEmployees(), - duties: this.getAllDuties() - }, null, 2); + try { + return JSON.stringify({ + employees: this.getEmployees(), + duties: this.getAllDuties() + }, null, 2); + } catch (e) { + console.error('Fehler beim Exportieren der Daten:', e); + throw new Error('Fehler beim Exportieren der Daten: ' + e.message); + } } /** From 37796da8f47d1d24dce7c40052541c66f8bd5765 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Dec 2025 09:27:06 +0000 Subject: [PATCH 25/25] Update CLAUDE.MD with recent improvements and clarifications - Add Quick Start guide for all three implementations - Update NRW holidays list to include Heiligabend and Silvester - Document recent export functionality improvements (v3.1) - Add "Recent Changes" section for December 2025 updates - Document known differences between Web-App and Python/Android versions - Add detailed Export function documentation - Update version history to reflect v3.1 release --- claude.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/claude.md b/claude.md index 0d18bd2..cd890f1 100644 --- a/claude.md +++ b/claude.md @@ -4,6 +4,25 @@ Dieses Projekt berechnet Bonuszahlungen für Mitarbeiter basierend auf Wochenend- und Feiertagsdiensten nach spezifischen NRW-Regeln. Es existieren drei verschiedene Implementierungen für unterschiedliche Anwendungsfälle. +## Quick Start + +**Für schnellen Einstieg - Web-App (empfohlen):** +1. Öffne `webapp/index.html` im Browser +2. Füge Mitarbeiter hinzu (Tab "Mitarbeiter") +3. Trage Dienste ein (Tab "Dienste") +4. Berechne Bonus (Tab "Berechnungen") + +**Für Python/Excel:** +```bash +python -m venv .venv +.venv\Scripts\activate # Windows +pip install -r requirements.txt +python src/fill_plan_dates.py 2025 12 +``` + +**Für Android:** +Siehe `android-app/README.md` für Build-Anleitung. + ## Verfügbare Implementierungen ### 1. Web-App (empfohlen) @@ -123,11 +142,29 @@ Alle Implementierungen nutzen die gleichen NRW-Feiertage: - Fronleichnam (variabel) - Tag der Deutschen Einheit (3. Oktober) - Allerheiligen (1. November) +- Heiligabend (24. Dezember) - *Python/Android 2025-2026* - 1. Weihnachtstag (25. Dezember) - 2. Weihnachtstag (26. Dezember) +- Silvester (31. Dezember) - *Python/Android 2025-2026* **Abdeckung**: 2025-2030 (Web-App), 2025-2026 (Python/Android) +**Hinweis**: Heiligabend und Silvester wurden kürzlich zur Python/Android-Version hinzugefügt, sind aber noch nicht in der Web-App implementiert. + +## Letzte Änderungen & Verbesserungen + +### Dezember 2025 +- ✅ **Export-Verbesserung**: Abgezogene Tage werden in der Export-Ansicht speziell markiert +- ✅ **UI-Verbesserung**: Euro-Werte werden für abgezogene Tage ausgeblendet (klarere Darstellung) +- ✅ **Neue Feiertage**: Heiligabend (24.12.) und Silvester (31.12.) für Python/Android-Version +- ✅ **Bugfix**: Entfernung ungenutzter `isPartiallyDeducted`-Variable +- ✅ **Korrektur**: Alle Abzugsreferenzen auf 2.0 (statt 1.0) aktualisiert + +### Bekannte Unterschiede zwischen Versionen +- **Web-App**: Hat Heiligabend/Silvester noch nicht als Feiertage +- **Python/Android**: Vollständige Feiertage-Liste inklusive Heiligabend/Silvester +- **Berechnungslogik**: Web-App nutzt vereinfachte Logik (siehe "Berechnungsregeln - Unterschiede") + ## Entwicklungshinweise ### Web-App erweitern @@ -313,8 +350,28 @@ Da die App rein client-seitig läuft (keine Server-Logik), ist jeder Static-Host MIT License - Siehe Hauptprojekt +## Export-Funktion (Web-App) + +Die Web-App bietet eine Export-Funktion für Mitarbeiterdaten: + +### Features +- **Export-Format**: JSON-Datei mit allen Mitarbeiter- und Dienstdaten +- **Import-Funktion**: Wiederherstellen gespeicherter Daten +- **Verbesserte Darstellung** (v3.1): + - Abgezogene Tage werden speziell markiert + - Euro-Werte werden für abgezogene Tage ausgeblendet + - Klarere Unterscheidung zwischen bezahlten und abgezogenen Diensten + +### Verwendung +1. Im Tab "Mitarbeiter" auf "Export" klicken +2. JSON-Datei wird heruntergeladen +3. Zum Importieren: "Import" klicken und Datei auswählen + +**Tipp**: Regelmäßige Exports als Backup nutzen, da LocalStorage browser-abhängig ist. + ## Versionshistorie +- **v3.1** (Dezember 2025): Verbesserte Export-Darstellung, Heiligabend/Silvester für Python/Android - **v3.0** (2025): Web-App hinzugefügt mit vereinfachter Berechnungslogik - **v2.0** (2024): Android-App implementiert - **v1.0**: Python/Excel Version (Variante 2 "streng")