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:
commit
e9253df736
22 changed files with 2464 additions and 1482 deletions
|
|
@ -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
|
|
@ -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)
|
||||
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
84
claude.md
84
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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 (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)"),
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
252
src/calculate.py
252
src/calculate.py
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
111
src/main.py
111
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__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
472
webapp/app.js
472
webapp/app.js
|
|
@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Reference in a new issue