Merge branch 'main' into claude/non-admin-app-setup-01P7njby7mZNPBHKwxAXF76Q

Signed-off-by: Kenearos <86194771+Kenearos@users.noreply.github.com>
This commit is contained in:
Kenearos 2025-12-25 10:41:59 +01:00 committed by GitHub
commit e9253df736
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2464 additions and 1482 deletions

View file

@ -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:**

File diff suppressed because it is too large Load diff

View file

@ -26,7 +26,7 @@ Native Android-App für mobiles Dienstplan-Management. Siehe [android-app/README
- ✅ Automatische Erkennung von Wochenenden (FrSo), 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)
@ -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`.

View file

@ -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 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")
@ -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 —

View file

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

View file

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

View file

@ -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 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)
* - 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 = 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
}

View file

@ -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 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,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(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)
assertEquals(0.0, result.payoutWE, 0.001)
}
/**
@ -144,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 {

View file

@ -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)
@ -39,7 +58,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€
@ -53,11 +72,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 1.0 WE-Einheit (Freitag-Priorität)
- Unter Schwellenwert: WE-Dienste = 0€ (nicht als WT vergütet)
- Dann Abzug von 2.0 WE-Einheiten (Freitag-Priorität)
- Unter Schwellenwert: Keine Bonuszahlung (weder WE noch WT)
### Wichtiger Unterschied - Beispiel
@ -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
@ -168,7 +205,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 +216,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,17 +240,12 @@ 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:
Der Abzug ist als Konstante in `webapp/calculator.js` definiert:
```javascript
qualifyingDaysDeducted = 1.0;
this.DEDUCTION_AMOUNT = 2.0; // Im Constructor
```
Um dies flexibel zu machen, könnte man hinzufügen:
```javascript
this.DEDUCTION_AMOUNT = 1.0; // Im Constructor
// Dann verwenden:
qualifyingDaysDeducted = this.DEDUCTION_AMOUNT;
```
Um den Abzugswert zu ändern, einfach diesen Wert anpassen.
## Code-Architektur
@ -318,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")

View file

@ -23,8 +23,10 @@ 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"),
("2025-12-31", "Silvester", "NRW"),
]
NRW_HOLIDAYS_2026 = [
@ -37,8 +39,10 @@ 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"),
("2026-12-31", "Silvester", "NRW"),
]
@ -61,7 +65,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 bei Erreichen der WE-Schwelle mit 250 € vergütet.",
"Splits anteilig. Monat und Bundesland in 'Regeln' wählen.",
"",
"Schritte:",
@ -83,7 +87,7 @@ def _populate_rules(ws):
("Satz_WT", 250, "Euro für jeden Werktagsdienst (MoDo, sofern kein WE-Tag)"),
("Satz_WE", 450, "Euro für jeden WE-Tag (FrSo, 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)"),
@ -280,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)

View file

@ -14,31 +14,45 @@ 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):
"""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__":

View file

@ -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__":

View file

@ -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__":

View file

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

View file

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

View file

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

View file

@ -50,6 +50,8 @@ class DienstplanApp {
document.getElementById('calculate-btn').addEventListener('click', () => this.calculateBonuses());
// 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());
@ -434,6 +436,476 @@ 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 werden 2,0 WE-Einheiten 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');
}
/**
* 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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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 = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Bonuszahlungen ${monthNames[month - 1]} ${year}</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 40px;
color: #333;
line-height: 1.6;
}
h3 {
color: #4472C4;
border-bottom: 2px solid #4472C4;
padding-bottom: 10px;
}
h5 {
color: #666;
margin-bottom: 20px;
}
table {
border-collapse: collapse;
width: 100%;
margin: 20px 0;
}
th, td {
border: 1px solid #ddd;
padding: 10px 8px;
text-align: center;
}
th {
background-color: #4472C4;
color: white;
font-weight: bold;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
.employee-name {
text-align: left;
font-weight: bold;
}
.bonus-amount {
font-weight: bold;
color: #28a745;
}
.no-bonus {
color: #dc3545;
}
.duty-cell {
font-size: 0.85em;
}
.duty-cell .we-tag {
background: #d4edda;
color: #155724;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
.duty-cell .wt-tag {
background: #e7e7e7;
color: #666;
padding: 2px 6px;
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;
background: #f8f9fa;
border-left: 3px solid #4472C4;
}
.employee-note b {
color: #4472C4;
}
.summary {
margin-top: 30px;
padding: 20px;
background: #e7f3ff;
border-radius: 8px;
}
.total {
font-size: 1.2em;
font-weight: bold;
color: #4472C4;
}
@media print {
body { margin: 20px; }
.no-print { display: none; }
}
</style>
</head>
<body>
<div class="no-print" style="margin-bottom: 20px; padding: 10px; background: #fff3cd; border-radius: 5px;">
<button onclick="window.print()" style="padding: 8px 16px; background: #4472C4; color: white; border: none; border-radius: 4px; cursor: pointer; margin-right: 10px;">🖨 Drucken / Als PDF speichern</button>
<span style="color: #666;">Tipp: Beim Drucken "Als PDF speichern" wählen für eine PDF-Datei.</span>
</div>
<h3>Bonuszahlungen</h3>
<h5>Monat ${monthNames[month - 1]} ${year} mit Auszahlung Ende ${monthNames[payoutMonth]} ${payoutYear}</h5>
<p>Für die im ${monthNames[month - 1]} ${year} geleisteten Bereitschaftsdienste ergeben sich folgende Bonuszahlungen:</p>
<table>
<thead>
<tr>
<th>Mitarbeiter</th>
<th>Mo</th>
<th>Di</th>
<th>Mi</th>
<th>Do</th>
<th>Fr</th>
<th>Sa</th>
<th>So</th>
<th>Bonus ()</th>
</tr>
</thead>
<tbody>`;
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 = '';
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;
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;
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 - cleaner, more professional format
const safeName = escapeHtml(name);
let note = '';
if (!thresholdReached) {
note = `<b>${safeName}</b> 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 - this.calculator.DEDUCTION_AMOUNT;
let breakdown = [];
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 = `<b>${safeName}</b> erhält eine Bonuszahlung von <span style="color: #28a745; font-weight: bold;">${this.calculator.formatCurrency(bonus)}</span>`;
if (breakdown.length > 0) {
note += ` (${breakdown.join(' + ')})`;
}
note += '.';
}
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 += `
<tr>
<td class="employee-name">${safeName}</td>`;
// 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 += `<td></td>`;
} else {
let cellContent = '';
dayDuties.forEach(duty => {
const dateStr = duty.date.getDate() + '.';
const shareStr = duty.share === 0.5 ? '½' : '';
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)' : '';
// 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;
// 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 += `<span class="${tag}">${shareStr}X${extraInfo}</span><br>`;
// Only show euro amount for non-deducted or partially-paid days
if (!isFullyDeducted && (paidAmount > 0 || !duty.isQual)) {
cellContent += `<small>${amountStr}</small><br>`;
}
});
html += `<td class="duty-cell">${cellContent}</td>`;
}
}
html += `
<td class="${bonus > 0 ? 'bonus-amount' : 'no-bonus'}">${bonus > 0 ? this.calculator.formatCurrency(bonus) : '-'}</td>
</tr>`;
}
html += `
</tbody>
</table>
<div class="summary">
<p class="total">Gesamtsumme: ${this.calculator.formatCurrency(totalBonus)}</p>
</div>
<h4>Erläuterungen zu den einzelnen Mitarbeitern:</h4>
`;
employeeNotes.forEach(note => {
html += `<div class="employee-note">${note}</div>\n`;
});
html += `
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #ddd;">
<p><strong>Berechnungsregeln (Variante 2 - Streng):</strong></p>
<ul>
<li><strong>WE-Tage:</strong> Freitag, Samstag, Sonntag, Feiertage und Tage vor Feiertagen</li>
<li><strong>Schwelle:</strong> Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich</li>
<li><strong>Vergütung bei Erreichen der Schwelle:</strong>
<ul>
<li>Werktage (WT): 250 pro Einheit</li>
<li>WE-Tage: 450 pro Einheit (abzüglich 2,0 Einheiten Abzug, Freitag zuerst)</li>
</ul>
</li>
<li><strong>Unter Schwelle:</strong> Keine Bonuszahlung (weder WT noch WE)</li>
</ul>
</div>
<p style="margin-top: 30px; color: #666; font-size: 0.9em;">
Erstellt am: ${new Date().toLocaleDateString('de-DE')} | Dienstplan NRW (Variante 2 - Streng)
</p>
</body>
</html>`;
// 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
*/

View file

@ -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 = 2.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);

View file

@ -177,7 +177,10 @@
<div class="settings-section">
<h3>Datenexport / Import</h3>
<button id="export-csv-btn" class="btn btn-success">📊 Excel/CSV Export</button>
<button id="export-report-btn" class="btn btn-primary">📝 Bonus-Bericht</button>
<button id="export-btn" class="btn btn-secondary">Daten exportieren (JSON)</button>
<p class="text-muted" style="margin-top: 10px;">💡 <strong>Tipp:</strong> 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.</p>
<div class="form-group">
<label for="import-file">Daten importieren:</label>
<input type="file" id="import-file" accept=".json">

View file

@ -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);
}
}
/**

View file

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

View file

@ -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 = 0€ (genau Schwelle, aber nach Abzug nichts)', (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 = 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 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');
t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein (2.0 - 2.0 = 0.0 × 450€)');
});
runner.test('Berechnung: Keine Dienste = 0€', (t) => {