diff --git a/README.md b/README.md
index 920383b..01780b6 100644
--- a/README.md
+++ b/README.md
@@ -4,10 +4,22 @@ Projekt zum automatischen Erstellen von Dienstplänen mit Vergütungsberechnung
## Verfügbare Versionen
+### Web-App (Browser) 🆕
+**Empfohlen für die meisten Benutzer!**
+
+Moderne Web-Anwendung zur Bonusberechnung für Wochenend- und Feiertagsdienste.
+- ✅ Läuft direkt im Browser (keine Installation)
+- ✅ Mitarbeiterverwaltung
+- ✅ Monatliche Dienstplanung
+- ✅ Automatische Bonusberechnung
+- ✅ Export/Import-Funktion
+
+Siehe [webapp/README.md](webapp/README.md) für Details.
+
### Python/Excel Version (Desktop)
Python-basierter Generator für Excel-Dienstpläne.
-### Android App (Mobile) 🆕
+### Android App (Mobile)
Native Android-App für mobiles Dienstplan-Management. Siehe [android-app/README.md](android-app/README.md) für Details.
## Features (Python/Excel Version)
@@ -63,6 +75,14 @@ Die Datei landet in `output/Dienstplan_YYYY_MM_NRW.xlsx`.
```text
.
+├── webapp/ # Web-App (Browser) 🆕
+│ ├── index.html # Haupt-HTML-Datei
+│ ├── styles.css # Styling
+│ ├── app.js # App-Logik & UI
+│ ├── calculator.js # Bonusberechnung
+│ ├── holidays.js # NRW-Feiertagsdaten
+│ ├── storage.js # LocalStorage-Verwaltung
+│ └── README.md # Web-App Dokumentation
├── src/ # Python source code
│ ├── build_template.py # Erstellt die Basis-Vorlage
│ ├── fill_plan_dates.py # Füllt Monate mit Datumszeilen
diff --git a/claude.md b/claude.md
new file mode 100644
index 0000000..656cd19
--- /dev/null
+++ b/claude.md
@@ -0,0 +1,329 @@
+# Dienstplan Bonusrechner - Projekt Übersicht
+
+## Projektbeschreibung
+
+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.
+
+## Verfügbare Implementierungen
+
+### 1. Web-App (empfohlen)
+**Verzeichnis**: `webapp/`
+**Technologie**: Vanilla JavaScript, HTML5, CSS3
+**Verwendung**: Browser-basiert, keine Installation erforderlich
+
+Die Web-App ist die modernste und benutzerfreundlichste Version. Sie läuft komplett im Browser und speichert Daten lokal im LocalStorage.
+
+### 2. Python/Excel Version
+**Verzeichnis**: `src/`
+**Technologie**: Python mit openpyxl
+**Verwendung**: Generiert Excel-Dateien mit Formeln
+
+Die ursprüngliche Implementierung, die Excel-Arbeitsmappen mit eingebetteten Formeln erstellt.
+
+### 3. Android App
+**Verzeichnis**: `android-app/`
+**Technologie**: Kotlin, Android SDK
+**Verwendung**: Native Android-Anwendung
+
+Mobile Version für Android-Geräte.
+
+## Berechnungsregeln - Unterschiede
+
+### Web-App Logik (Benutzer-Anforderung)
+Die Web-App implementiert eine vereinfachte Logik:
+
+1. **Qualifizierende Tage (WE/Feiertag)**:
+ - Freitag, Samstag, Sonntag
+ - Feiertage in NRW
+ - Tag vor einem Feiertag
+
+2. **Bonusberechnung**:
+ - Mindestens **2.0 qualifizierende Tage** erforderlich
+ - Bei Erreichen: **1.0 qualifizierender Tag** wird abgezogen
+ - **Alle übrigen Tage** werden bezahlt:
+ - Normale Tage (Mo-Do, kein Feiertag): 250€
+ - Qualifizierende Tage: 450€
+ - Unter Schwellenwert: **Keine Bonuszahlung**
+
+### Python/Android Logik (Variante 2 "streng")
+Die ältere Implementierung nutzt eine andere Logik:
+
+1. **Tag-Kategorien**:
+ - **WT-Tag** (Werktag): Mo-Do (ohne Feiertag/Vortag)
+ - **WE-Tag** (Weekend): Fr-So + Feiertag + Vortag Feiertag
+
+2. **Bonusberechnung**:
+ - **WT-Tage** werden **immer** mit 250€ vergütet
+ - **WE-Tage** nur vergütet wenn ≥ 2.0 WE-Einheiten:
+ - Bei Erreichen: 450€ pro WE-Tag
+ - Dann Abzug von 1.0 WE-Einheit (Freitag-Priorität)
+ - Unter Schwellenwert: WE-Dienste = 0€ (nicht als WT vergütet)
+
+### Wichtiger Unterschied - Beispiel
+
+**Szenario**: Mitarbeiter arbeitet 1 × Mo, 1 × Di, 1 × Sa
+
+**Web-App**:
+- Qualifizierende Tage: 1.0 (nur Samstag)
+- Schwellenwert nicht erreicht → **0€ Bonus**
+
+**Python/Android**:
+- WT-Tage: 2.0 (Mo, Di) → 2 × 250€ = **500€**
+- WE-Tage: 1.0 (Sa) → Schwelle nicht erreicht → 0€
+- **Gesamt: 500€**
+
+Die Web-App ist **strenger** für Mitarbeiter ohne ausreichend WE-Dienste.
+
+## Dateistruktur
+
+```
+Dienstplan/
+├── webapp/ # Web-Anwendung (Browser)
+│ ├── index.html # Haupt-UI
+│ ├── styles.css # Styling (Gradient-Design)
+│ ├── app.js # UI-Logik, Event-Handling
+│ ├── calculator.js # Bonusberechnungs-Engine
+│ ├── holidays.js # NRW-Feiertage (2025-2030)
+│ ├── storage.js # LocalStorage-Verwaltung
+│ └── README.md # Web-App Dokumentation
+│
+├── src/ # Python/Excel Version
+│ ├── build_template.py # Excel-Vorlage erstellen
+│ ├── fill_plan_dates.py # Monatspläne generieren
+│ └── read_excel.py # Excel-Dateien lesen
+│
+├── android-app/ # Android App
+│ ├── app/src/main/java/com/dienstplan/nrw/
+│ │ ├── MainActivity.kt # Haupt-Activity
+│ │ ├── data/
+│ │ │ ├── PayrollCalculator.kt # Bonusberechnung
+│ │ │ ├── HolidayProvider.kt # Feiertagsdaten
+│ │ │ └── DutyDataStore.kt # Datenverwaltung
+│ │ └── model/ # Datenmodelle
+│ └── README.md # Android-Dokumentation
+│
+├── templates/ # Excel-Vorlagen
+├── output/ # Generierte Excel-Dateien
+├── README.md # Projekt-Hauptdokumentation
+├── SPECIFICATION.md # Detaillierte Regelspezifikation
+├── claude.md # Diese Datei
+└── requirements.txt # Python-Abhängigkeiten
+```
+
+## NRW Feiertage
+
+Alle Implementierungen nutzen die gleichen NRW-Feiertage:
+
+- Neujahr (1. Januar)
+- Karfreitag (variabel)
+- Ostermontag (variabel)
+- Tag der Arbeit (1. Mai)
+- Christi Himmelfahrt (variabel)
+- Pfingstmontag (variabel)
+- Fronleichnam (variabel)
+- Tag der Deutschen Einheit (3. Oktober)
+- Allerheiligen (1. November)
+- 1. Weihnachtstag (25. Dezember)
+- 2. Weihnachtstag (26. Dezember)
+
+**Abdeckung**: 2025-2030 (Web-App), 2025-2026 (Python/Android)
+
+## Entwicklungshinweise
+
+### Web-App erweitern
+
+**Neue Feiertage hinzufügen** (`webapp/holidays.js`):
+```javascript
+2031: [
+ { date: '2031-01-01', name: 'Neujahr' },
+ // ... weitere Feiertage
+]
+```
+
+**Berechnungsraten ändern** (`webapp/calculator.js`):
+```javascript
+this.RATE_NORMAL = 250; // Normale Tage
+this.RATE_WEEKEND = 450; // WE/Feiertag Tage
+this.MIN_QUALIFYING_DAYS = 2.0; // Schwellenwert
+```
+
+### Python Version erweitern
+
+**Neue Monate generieren**:
+```bash
+python src/fill_plan_dates.py 2025 11 # November 2025
+```
+
+### Android App
+
+**Build & Install**:
+```bash
+cd android-app
+./gradlew assembleDebug
+adb install app/build/outputs/apk/debug/app-debug.apk
+```
+
+## Testing-Szenarien
+
+### 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€**
+
+### Testfall 2: Schwellenwert nicht erreicht
+- 1 × Samstag (1.0)
+- 1 × Sonntag (0.5 - halber Dienst)
+- Erwartung: 1.5 qualifizierende Tage → **0€** (Schwelle nicht erreicht)
+
+### Testfall 3: Mit normalen Tagen
+- 2 × Montag (2.0)
+- 2 × Samstag (2.0)
+- Erwartung:
+ - 2.0 qualifizierende → -1.0 Abzug → 1.0 bezahlt
+ - Bonus: (2 × 250€) + (1 × 450€) = **950€**
+
+### Testfall 4: Feiertag + Vortag
+- 1 × Donnerstag vor Karfreitag (qualifizierend!)
+- 1 × Karfreitag (Feiertag, qualifizierend!)
+- Erwartung: 2.0 qualifizierende → -1.0 → 1.0 × 450€ = **450€**
+
+## Häufige Anpassungen
+
+### Schwellenwert ändern (Web-App)
+`webapp/calculator.js`, Zeile 10:
+```javascript
+this.MIN_QUALIFYING_DAYS = 3.0; // Statt 2.0
+```
+
+### Vergütungsraten ändern (Web-App)
+`webapp/calculator.js`, Zeilen 8-9:
+```javascript
+this.RATE_NORMAL = 300; // Statt 250
+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:
+```javascript
+qualifyingDaysDeducted = 1.0;
+```
+
+Um dies flexibel zu machen, könnte man hinzufügen:
+```javascript
+this.DEDUCTION_AMOUNT = 1.0; // Im Constructor
+// Dann verwenden:
+qualifyingDaysDeducted = this.DEDUCTION_AMOUNT;
+```
+
+## Code-Architektur
+
+### Web-App (MVC-ähnlich)
+
+**Model** (`storage.js`):
+- Datenverwaltung
+- LocalStorage-Persistenz
+- CRUD-Operationen für Mitarbeiter & Dienste
+
+**Controller** (`app.js`):
+- Event-Handling
+- Koordination zwischen Model, View, Calculator
+- UI-State-Management
+
+**Business Logic** (`calculator.js`):
+- Bonusberechnung
+- Tag-Klassifizierung (qualifizierend/normal)
+- Formatierung
+
+**Data Provider** (`holidays.js`):
+- Feiertagsdaten
+- Datum-Utilities
+
+**View** (`index.html` + `styles.css`):
+- UI-Layout (Tabs)
+- Styling
+- Responsives Design
+
+### Datenfluss (Web-App)
+
+```
+User Action (UI)
+ ↓
+Event Handler (app.js)
+ ↓
+Storage Operation (storage.js) ←→ LocalStorage
+ ↓
+Data Retrieved
+ ↓
+Calculator Processing (calculator.js) → Holiday Check (holidays.js)
+ ↓
+Results
+ ↓
+UI Update (app.js)
+ ↓
+View Rendering (HTML)
+```
+
+## Browser-Kompatibilität (Web-App)
+
+- **Chrome/Edge**: ✅ Vollständig unterstützt
+- **Firefox**: ✅ Vollständig unterstützt
+- **Safari**: ✅ Vollständig unterstützt
+- **Opera**: ✅ Vollständig unterstützt
+
+**Mindestanforderungen**:
+- LocalStorage API
+- ES6 JavaScript (Arrow Functions, Classes, Template Literals)
+- CSS Grid & Flexbox
+- Date API
+
+## Deployment-Optionen (Web-App)
+
+### Option 1: Lokale Datei
+Einfach `index.html` im Browser öffnen - funktioniert sofort!
+
+### Option 2: Statischer Webserver
+```bash
+# Python
+python -m http.server 8000
+
+# Node.js
+npx http-server -p 8000
+
+# PHP
+php -S localhost:8000
+```
+
+### Option 3: Cloud-Hosting
+Geeignet für Plattformen wie:
+- **GitHub Pages**: Kostenlos, einfach via Git
+- **Netlify**: Drag & Drop, kostenloser Plan
+- **Vercel**: Automatisches Deployment
+- **AWS S3**: Static Website Hosting
+
+Da die App rein client-seitig läuft (keine Server-Logik), ist jeder Static-Hosting-Service geeignet.
+
+## Sicherheitshinweise
+
+### LocalStorage-Daten
+- Daten sind **nicht verschlüsselt**
+- Für produktive Nutzung mit sensiblen Daten ggf. Verschlüsselung hinzufügen
+- Regelmäßige Backups via Export-Funktion empfohlen
+
+### CORS (bei Web-Hosting)
+- LocalStorage funktioniert nur auf gleicher Domain
+- Beim Testen via `file://` können CORS-Einschränkungen auftreten
+- Lösung: Lokaler Webserver (siehe Deployment-Optionen)
+
+## Lizenz
+
+MIT License - Siehe Hauptprojekt
+
+## Versionshistorie
+
+- **v3.0** (2025): Web-App hinzugefügt mit vereinfachter Berechnungslogik
+- **v2.0** (2024): Android-App implementiert
+- **v1.0**: Python/Excel Version (Variante 2 "streng")
+
+## Kontakt & Support
+
+Für Fragen zum Projekt siehe `README.md` der jeweiligen Implementierung.
diff --git a/webapp/README.md b/webapp/README.md
new file mode 100644
index 0000000..e3779fc
--- /dev/null
+++ b/webapp/README.md
@@ -0,0 +1,164 @@
+# Dienstplan Bonusrechner - Web App
+
+Eine Web-Anwendung zur Berechnung von Bonuszahlungen für Wochenend- und Feiertagsdienste nach NRW-Regeln.
+
+## Features
+
+- ✅ **Mitarbeiterverwaltung**: Mehrere Mitarbeiter gleichzeitig verwalten
+- ✅ **Dienstplanung**: Dienste für beliebige Monate eintragen (ganze und halbe Dienste)
+- ✅ **Automatische Feiertagserkennung**: NRW-Feiertage 2025-2030
+- ✅ **Bonusberechnung**: Automatische Berechnung nach festgelegten Regeln
+- ✅ **Datenexport/Import**: JSON-Export für Backup und Migration
+- ✅ **LocalStorage**: Alle Daten werden lokal im Browser gespeichert
+- ✅ **Responsive Design**: Funktioniert auf Desktop und Mobilgeräten
+
+## Berechnungsregeln
+
+### Qualifizierende Tage (WE/Feiertag)
+- **Wochenende**: Freitag, Samstag, Sonntag
+- **Feiertage**: Alle gesetzlichen Feiertage in NRW
+- **Tag vor Feiertag**: Der Tag vor einem gesetzlichen Feiertag
+
+### Bonusberechnung
+1. **Schwellenwert**: Mindestens **2.0 qualifizierende Tage** im Monat erforderlich
+2. **Abzug**: Bei Erreichen des Schwellenwerts wird **1.0 qualifizierender Tag** abgezogen
+3. **Vergütung**:
+ - Normale Tage: **250€** pro Tag
+ - Qualifizierende Tage (WE/Feiertag): **450€** pro Tag
+ - Halbe Dienste: Jeweils die Hälfte
+
+### Beispiel
+Mitarbeiter hat im Monat:
+- 3 normale Tage (Mo-Do, keine Feiertage)
+- 3 Wochenend-Tage (Fr, Sa, So)
+
+**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€**
+
+## Installation & Nutzung
+
+### Lokale Nutzung (einfachste Methode)
+
+1. **Dateien öffnen**:
+ - Navigieren Sie zum Ordner `webapp`
+ - Öffnen Sie die Datei `index.html` direkt in Ihrem Browser (Doppelklick)
+
+2. **Fertig!** Die App läuft komplett im Browser, keine Installation nötig.
+
+### Mit lokalem Webserver (optional)
+
+Wenn Sie lieber einen Webserver verwenden möchten:
+
+```bash
+# Im webapp-Ordner
+python -m http.server 8000
+# Oder mit Node.js
+npx http-server -p 8000
+```
+
+Dann im Browser öffnen: `http://localhost:8000`
+
+## Bedienung
+
+### 1. Mitarbeiter hinzufügen
+1. Gehen Sie zum Tab "Mitarbeiter verwalten"
+2. Geben Sie den Namen ein und klicken Sie auf "Hinzufügen"
+
+### 2. Dienste eintragen
+1. Gehen Sie zum Tab "Dienste eintragen"
+2. Wählen Sie Monat und Jahr
+3. Wählen Sie einen Mitarbeiter
+4. Wählen Sie das Datum
+5. Wählen Sie Dienstanteil (ganz oder halb)
+6. Klicken Sie auf "Dienst hinzufügen"
+
+**Hinweis**: Qualifizierende Tage (WE/Feiertag) werden grün hervorgehoben.
+
+### 3. Bonus berechnen
+1. Gehen Sie zum Tab "Berechnung"
+2. Wählen Sie Monat und Jahr
+3. Klicken Sie auf "Berechnung durchführen"
+4. Sehen Sie die Ergebnisse für alle Mitarbeiter
+
+### 4. Daten exportieren/importieren
+1. Gehen Sie zum Tab "Einstellungen"
+2. Klicken Sie auf "Daten exportieren" für ein Backup
+3. Verwenden Sie "Daten importieren" um gespeicherte Daten zu laden
+
+## Datenspeicherung
+
+- Alle Daten werden im **Browser LocalStorage** gespeichert
+- Die Daten bleiben erhalten, auch nach Schließen des Browsers
+- **Wichtig**: Beim Löschen der Browser-Daten gehen die Daten verloren
+- Regelmäßige Exports werden empfohlen!
+
+## NRW Feiertage (2025-2030)
+
+Die App enthält alle gesetzlichen Feiertage für NRW von 2025 bis 2030:
+- Neujahr
+- Karfreitag
+- Ostermontag
+- Tag der Arbeit
+- Christi Himmelfahrt
+- Pfingstmontag
+- Fronleichnam
+- Tag der Deutschen Einheit
+- Allerheiligen
+- 1. und 2. Weihnachtstag
+
+## Technische Details
+
+### Projektstruktur
+```
+webapp/
+├── index.html # Haupt-HTML-Datei
+├── styles.css # Styling
+├── app.js # Haupt-App-Logik & UI
+├── calculator.js # Bonusberechnungs-Logik
+├── holidays.js # NRW-Feiertagsdaten
+├── storage.js # LocalStorage-Verwaltung
+└── README.md # Diese Datei
+```
+
+### Technologien
+- **Vanilla JavaScript** (kein Framework erforderlich)
+- **HTML5 & CSS3**
+- **LocalStorage API**
+- Keine externen Abhängigkeiten
+- Funktioniert in allen modernen Browsern
+
+### Browser-Kompatibilität
+- Chrome/Edge (empfohlen)
+- Firefox
+- Safari
+- Opera
+
+## Tipps & Tricks
+
+1. **Regelmäßige Backups**: Exportieren Sie Ihre Daten regelmäßig als JSON-Datei
+2. **Drucken**: Die Berechnungsseite kann direkt gedruckt werden (Datei → Drucken)
+3. **Mehrere Browser**: Daten sind browser-spezifisch und werden nicht synchronisiert
+4. **Mobile Nutzung**: Die App ist mobilfreundlich und kann auch auf Tablets/Smartphones genutzt werden
+
+## Unterschiede zu anderen Versionen
+
+Diese Web-App verwendet leicht andere Regeln als die Python/Excel Version:
+
+### Web-App Logik (Ihre Anforderungen)
+- Wenn < 2 WE-Tage: **Keine Bonuszahlung**
+- Wenn ≥ 2 WE-Tage:
+ - 1 WE-Tag wird abgezogen
+ - Alle übrigen Tage werden bezahlt (normale: 250€, WE: 450€)
+
+### Python/Excel Version (Variante 2 "streng")
+- Normale Tage (WT) werden immer bezahlt (250€)
+- WE-Tage nur wenn ≥ 2.0 WE-Einheiten
+
+Die Web-App folgt genau Ihren beschriebenen Anforderungen.
+
+## Lizenz
+
+MIT
diff --git a/webapp/app.js b/webapp/app.js
new file mode 100644
index 0000000..41af5ee
--- /dev/null
+++ b/webapp/app.js
@@ -0,0 +1,504 @@
+/**
+ * Main Application
+ * Manages UI interactions and coordinates between components
+ */
+class DienstplanApp {
+ constructor() {
+ this.storage = new DataStorage();
+ this.holidayProvider = new HolidayProvider();
+ this.calculator = new BonusCalculator(this.holidayProvider);
+
+ this.currentMonth = new Date().getMonth() + 1;
+ this.currentYear = new Date().getFullYear();
+
+ this.init();
+ }
+
+ init() {
+ this.setupEventListeners();
+ this.populateYearSelects();
+ this.setCurrentMonthYear();
+ this.loadEmployeeSelects();
+ this.loadEmployeeList();
+ this.switchTab('duties');
+ }
+
+ /**
+ * Setup all event listeners
+ */
+ setupEventListeners() {
+ // Tab switching
+ document.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ this.switchTab(e.target.dataset.tab);
+ });
+ });
+
+ // Employee management
+ document.getElementById('add-employee-btn').addEventListener('click', () => this.addEmployee());
+ document.getElementById('new-employee-name').addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') this.addEmployee();
+ });
+
+ // Duty management
+ document.getElementById('add-duty-btn').addEventListener('click', () => this.addDuty());
+ document.getElementById('employee-select-duty').addEventListener('change', () => this.loadDutiesForSelectedEmployee());
+ document.getElementById('month-select').addEventListener('change', () => this.loadDutiesForSelectedEmployee());
+ document.getElementById('year-select').addEventListener('change', () => this.loadDutiesForSelectedEmployee());
+
+ // Calculation
+ document.getElementById('calculate-btn').addEventListener('click', () => this.calculateBonuses());
+
+ // Settings
+ 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());
+ }
+
+ /**
+ * Populate year select dropdowns
+ */
+ populateYearSelects() {
+ const currentYear = new Date().getFullYear();
+ const years = [];
+
+ for (let year = currentYear - 1; year <= currentYear + 5; year++) {
+ years.push(year);
+ }
+
+ const yearSelects = ['year-select', 'calc-year-select'];
+ yearSelects.forEach(selectId => {
+ const select = document.getElementById(selectId);
+ select.innerHTML = '';
+ years.forEach(year => {
+ const option = document.createElement('option');
+ option.value = year;
+ option.textContent = year;
+ if (year === currentYear) option.selected = true;
+ select.appendChild(option);
+ });
+ });
+ }
+
+ /**
+ * Set current month and year in selects
+ */
+ setCurrentMonthYear() {
+ const currentMonth = new Date().getMonth() + 1;
+ const currentYear = new Date().getFullYear();
+
+ document.getElementById('month-select').value = currentMonth;
+ document.getElementById('year-select').value = currentYear;
+ document.getElementById('calc-month-select').value = currentMonth;
+ document.getElementById('calc-year-select').value = currentYear;
+
+ // Set date input to today
+ const today = new Date().toISOString().split('T')[0];
+ document.getElementById('duty-date').value = today;
+ }
+
+ /**
+ * Switch between tabs
+ */
+ switchTab(tabName) {
+ // Update tab buttons
+ document.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.classList.remove('active');
+ if (btn.dataset.tab === tabName) {
+ btn.classList.add('active');
+ }
+ });
+
+ // Update tab content
+ document.querySelectorAll('.tab-content').forEach(content => {
+ content.classList.remove('active');
+ });
+ document.getElementById(`tab-${tabName}`).classList.add('active');
+
+ // Refresh data when switching to certain tabs
+ if (tabName === 'employees') {
+ this.loadEmployeeList();
+ } else if (tabName === 'duties') {
+ this.loadDutiesForSelectedEmployee();
+ }
+ }
+
+ /**
+ * Load employee select dropdowns
+ */
+ loadEmployeeSelects() {
+ const employees = this.storage.getEmployees();
+ const selects = ['employee-select-duty'];
+
+ selects.forEach(selectId => {
+ const select = document.getElementById(selectId);
+ const currentValue = select.value;
+ select.innerHTML = '-- Mitarbeiter auswählen -- ';
+
+ employees.forEach(employee => {
+ const option = document.createElement('option');
+ option.value = employee;
+ option.textContent = employee;
+ select.appendChild(option);
+ });
+
+ // Restore previous selection if still valid
+ if (employees.includes(currentValue)) {
+ select.value = currentValue;
+ }
+ });
+ }
+
+ /**
+ * Add a new employee
+ */
+ addEmployee() {
+ const input = document.getElementById('new-employee-name');
+ const name = input.value.trim();
+
+ if (!name) {
+ this.showToast('Bitte geben Sie einen Namen ein.', 'error');
+ return;
+ }
+
+ const success = this.storage.addEmployee(name);
+
+ if (success) {
+ this.showToast(`Mitarbeiter "${name}" wurde hinzugefügt.`, 'success');
+ input.value = '';
+ this.loadEmployeeList();
+ this.loadEmployeeSelects();
+ } else {
+ this.showToast(`Mitarbeiter "${name}" existiert bereits.`, 'error');
+ }
+ }
+
+ /**
+ * Remove an employee
+ */
+ removeEmployee(employeeName) {
+ if (!confirm(`Möchten Sie "${employeeName}" wirklich löschen? Alle Dienste werden ebenfalls gelöscht.`)) {
+ return;
+ }
+
+ this.storage.removeEmployee(employeeName);
+ this.showToast(`Mitarbeiter "${employeeName}" wurde gelöscht.`, 'success');
+ this.loadEmployeeList();
+ this.loadEmployeeSelects();
+ this.loadDutiesForSelectedEmployee();
+ }
+
+ /**
+ * Load and display employee list
+ */
+ loadEmployeeList() {
+ const employees = this.storage.getEmployees();
+ const container = document.getElementById('employee-list-display');
+
+ if (employees.length === 0) {
+ container.innerHTML = '
Keine Mitarbeiter vorhanden.
';
+ return;
+ }
+
+ container.innerHTML = '';
+ employees.forEach(employee => {
+ const item = document.createElement('div');
+ item.className = 'employee-item';
+ item.innerHTML = `
+ ${employee}
+ Löschen
+ `;
+ container.appendChild(item);
+ });
+ }
+
+ /**
+ * Add a duty
+ */
+ addDuty() {
+ const employeeSelect = document.getElementById('employee-select-duty');
+ const dateInput = document.getElementById('duty-date');
+ const shareSelect = document.getElementById('duty-share');
+
+ const employeeName = employeeSelect.value;
+ const dateStr = dateInput.value;
+ const share = parseFloat(shareSelect.value);
+
+ if (!employeeName) {
+ this.showToast('Bitte wählen Sie einen Mitarbeiter aus.', 'error');
+ return;
+ }
+
+ if (!dateStr) {
+ this.showToast('Bitte wählen Sie ein Datum aus.', 'error');
+ return;
+ }
+
+ const date = new Date(dateStr + 'T12:00:00'); // Add time to avoid timezone issues
+ const year = date.getFullYear();
+ const month = date.getMonth() + 1;
+
+ this.storage.addDuty(employeeName, year, month, date, share);
+ this.showToast('Dienst wurde hinzugefügt.', 'success');
+ this.loadDutiesForSelectedEmployee();
+
+ // Update month/year selects to match the added duty
+ document.getElementById('month-select').value = month;
+ document.getElementById('year-select').value = year;
+ }
+
+ /**
+ * Remove a duty
+ */
+ removeDuty(employeeName, year, month, date) {
+ this.storage.removeDuty(employeeName, year, month, date);
+ this.showToast('Dienst wurde gelöscht.', 'success');
+ this.loadDutiesForSelectedEmployee();
+ }
+
+ /**
+ * Load duties for the selected employee and month
+ */
+ loadDutiesForSelectedEmployee() {
+ const employeeSelect = document.getElementById('employee-select-duty');
+ const monthSelect = document.getElementById('month-select');
+ const yearSelect = document.getElementById('year-select');
+ const container = document.getElementById('duties-display');
+
+ const employeeName = employeeSelect.value;
+ const month = parseInt(monthSelect.value);
+ const year = parseInt(yearSelect.value);
+
+ if (!employeeName) {
+ container.innerHTML = 'Wählen Sie einen Mitarbeiter aus, um Dienste anzuzeigen.
';
+ return;
+ }
+
+ const duties = this.storage.getDutiesForMonth(employeeName, year, month);
+
+ if (duties.length === 0) {
+ const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
+ 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
+ container.innerHTML = `Keine Dienste für ${monthNames[month - 1]} ${year}.
`;
+ return;
+ }
+
+ container.innerHTML = '';
+ duties.forEach(duty => {
+ const isQualifying = this.calculator.isQualifyingDay(duty.date);
+ const dayType = this.calculator.getDayTypeLabel(duty.date);
+ const dateStr = duty.date.toLocaleDateString('de-DE', {
+ weekday: 'short',
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric'
+ });
+
+ const item = document.createElement('div');
+ item.className = `duty-item ${isQualifying ? 'qualifying' : ''}`;
+ item.innerHTML = `
+
+
${dateStr}
+
+ ${dayType}
+
+ ${isQualifying ? 'WE/Feiertag' : 'Normal'}
+
+
+
+ ${duty.share === 1 ? 'Ganzer Dienst' : 'Halber Dienst'}
+
+ Löschen
+
+ `;
+ container.appendChild(item);
+ });
+ }
+
+ /**
+ * Calculate bonuses for all employees
+ */
+ calculateBonuses() {
+ const monthSelect = document.getElementById('calc-month-select');
+ const yearSelect = document.getElementById('calc-year-select');
+ const resultsContainer = document.getElementById('calculation-results');
+
+ const month = parseInt(monthSelect.value);
+ const year = parseInt(yearSelect.value);
+
+ const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month);
+ const results = this.calculator.calculateAllEmployees(employeeDuties);
+
+ const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
+ 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
+
+ resultsContainer.innerHTML = `Ergebnisse für ${monthNames[month - 1]} ${year} `;
+
+ const employees = Object.keys(results);
+ if (employees.length === 0) {
+ resultsContainer.innerHTML += 'Keine Daten verfügbar.
';
+ return;
+ }
+
+ employees.forEach(employeeName => {
+ const result = results[employeeName];
+ const resultCard = this.createResultCard(employeeName, result);
+ resultsContainer.appendChild(resultCard);
+ });
+
+ this.showToast('Berechnung abgeschlossen.', 'success');
+ }
+
+ /**
+ * Create a result card for an employee
+ */
+ createResultCard(employeeName, result) {
+ const card = document.createElement('div');
+ card.className = 'result-card';
+
+ let content = `${employeeName} `;
+
+ if (!result.thresholdReached) {
+ content += `
+
+
Schwellenwert nicht erreicht
+
Es wurden nur ${result.qualifyingDays.toFixed(1)} qualifizierende Tage gearbeitet.
+ Mindestens ${this.calculator.MIN_QUALIFYING_DAYS} Tage erforderlich.
+
Keine Bonuszahlung
+
+ `;
+ } else {
+ content += `
+
+
+
Normale Tage
+
${result.normalDays.toFixed(1)}
+
+
+
WE/Feiertag Tage
+
${result.qualifyingDays.toFixed(1)}
+
+
+
Abzug
+
-${result.qualifyingDaysDeducted.toFixed(1)}
+
+
+
Normale Tage (bezahlt)
+
${result.normalDaysPaid.toFixed(1)}
+
+
+
WE/Feiertag (bezahlt)
+
${result.qualifyingDaysPaid.toFixed(1)}
+
+
+
+
+
+
Normale Tage (250€)
+
${this.calculator.formatCurrency(result.bonusNormalDays)}
+
+
+
WE/Feiertag (450€)
+
${this.calculator.formatCurrency(result.bonusQualifyingDays)}
+
+
+
+
+
Gesamtbonus
+
${this.calculator.formatCurrency(result.totalBonus)}
+
+ `;
+ }
+
+ card.innerHTML = content;
+ return card;
+ }
+
+ /**
+ * Export data as JSON
+ */
+ exportData() {
+ const data = this.storage.exportData();
+ const blob = new Blob([data], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `dienstplan-export-${new Date().toISOString().split('T')[0]}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ this.showToast('Daten wurden exportiert.', 'success');
+ }
+
+ /**
+ * Import data from JSON file
+ */
+ importData() {
+ const fileInput = document.getElementById('import-file');
+ const file = fileInput.files[0];
+
+ if (!file) {
+ this.showToast('Bitte wählen Sie eine Datei aus.', 'error');
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const success = this.storage.importData(e.target.result);
+
+ if (success) {
+ this.showToast('Daten wurden erfolgreich importiert.', 'success');
+ this.loadEmployeeList();
+ this.loadEmployeeSelects();
+ this.loadDutiesForSelectedEmployee();
+ } else {
+ this.showToast('Import fehlgeschlagen. Bitte überprüfen Sie die Datei.', 'error');
+ }
+ };
+
+ reader.readAsText(file);
+ fileInput.value = ''; // Reset file input
+ }
+
+ /**
+ * Clear all data
+ */
+ clearAllData() {
+ if (!confirm('Möchten Sie wirklich ALLE Daten löschen? Diese Aktion kann nicht rückgängig gemacht werden!')) {
+ return;
+ }
+
+ this.storage.clearAll();
+ this.showToast('Alle Daten wurden gelöscht.', 'info');
+ this.loadEmployeeList();
+ this.loadEmployeeSelects();
+ this.loadDutiesForSelectedEmployee();
+ }
+
+ /**
+ * Show toast notification
+ */
+ showToast(message, type = 'info') {
+ const toast = document.getElementById('toast');
+ toast.textContent = message;
+ toast.className = `toast ${type}`;
+
+ setTimeout(() => {
+ toast.classList.add('show');
+ }, 100);
+
+ setTimeout(() => {
+ toast.classList.remove('show');
+ }, 3000);
+ }
+}
+
+// Initialize app when DOM is ready
+let app;
+document.addEventListener('DOMContentLoaded', () => {
+ app = new DienstplanApp();
+});
diff --git a/webapp/calculator.js b/webapp/calculator.js
new file mode 100644
index 0000000..63e019e
--- /dev/null
+++ b/webapp/calculator.js
@@ -0,0 +1,176 @@
+/**
+ * Duty Schedule Bonus Calculator
+ * Calculates bonuses based on weekend and holiday duty shifts
+ */
+class BonusCalculator {
+ constructor(holidayProvider) {
+ this.holidayProvider = holidayProvider;
+ 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
+ }
+
+ /**
+ * Check if a date is a qualifying day (weekend or holiday related)
+ * Qualifying days: Friday, Saturday, Sunday, Public Holiday, Day before public holiday
+ * @param {Date} date
+ * @returns {boolean}
+ */
+ isQualifyingDay(date) {
+ const dayOfWeek = date.getDay(); // 0 = Sunday, 5 = Friday, 6 = Saturday
+
+ // Weekend: Friday (5), Saturday (6), Sunday (0)
+ const isWeekend = dayOfWeek === 5 || dayOfWeek === 6 || dayOfWeek === 0;
+
+ // Public holiday
+ const isHoliday = this.holidayProvider.isHoliday(date);
+
+ // Day before public holiday
+ const isDayBeforeHoliday = this.holidayProvider.isDayBeforeHoliday(date);
+
+ return isWeekend || isHoliday || isDayBeforeHoliday;
+ }
+
+ /**
+ * Get day type label for display
+ * @param {Date} date
+ * @returns {string}
+ */
+ getDayTypeLabel(date) {
+ const dayOfWeek = date.getDay();
+ const isHoliday = this.holidayProvider.isHoliday(date);
+ const holidayName = this.holidayProvider.getHolidayName(date);
+ const isDayBefore = this.holidayProvider.isDayBeforeHoliday(date);
+
+ if (isHoliday) {
+ return `Feiertag (${holidayName})`;
+ }
+ if (isDayBefore) {
+ return 'Tag vor Feiertag';
+ }
+ if (dayOfWeek === 5) return 'Freitag';
+ if (dayOfWeek === 6) return 'Samstag';
+ if (dayOfWeek === 0) return 'Sonntag';
+
+ const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
+ return days[dayOfWeek];
+ }
+
+ /**
+ * Calculate bonus for a single employee for a given month
+ * @param {Array} duties - Array of duty objects: {date: Date, share: number (1.0 or 0.5)}
+ * @returns {Object} Calculation result
+ */
+ calculateMonthlyBonus(duties) {
+ if (!duties || duties.length === 0) {
+ return this.getEmptyResult();
+ }
+
+ // Separate qualifying and non-qualifying days
+ let qualifyingDays = 0;
+ let normalDays = 0;
+ const dutyDetails = [];
+
+ duties.forEach(duty => {
+ const isQualifying = this.isQualifyingDay(duty.date);
+ const dayType = this.getDayTypeLabel(duty.date);
+
+ if (isQualifying) {
+ qualifyingDays += duty.share;
+ } else {
+ normalDays += duty.share;
+ }
+
+ dutyDetails.push({
+ date: duty.date,
+ share: duty.share,
+ isQualifying: isQualifying,
+ dayType: dayType
+ });
+ });
+
+ // Check if threshold is reached
+ const thresholdReached = qualifyingDays >= this.MIN_QUALIFYING_DAYS;
+
+ let bonus = 0;
+ let normalDaysPaid = 0;
+ let qualifyingDaysPaid = 0;
+ let qualifyingDaysDeducted = 0;
+
+ if (thresholdReached) {
+ // Deduct 1.0 qualifying day
+ qualifyingDaysDeducted = 1.0;
+ qualifyingDaysPaid = Math.max(0, qualifyingDays - qualifyingDaysDeducted);
+ normalDaysPaid = normalDays;
+
+ // Calculate bonus
+ bonus = (normalDaysPaid * this.RATE_NORMAL) + (qualifyingDaysPaid * this.RATE_WEEKEND);
+ }
+
+ return {
+ totalDuties: duties.length,
+ totalDaysWorked: qualifyingDays + normalDays,
+ normalDays: normalDays,
+ qualifyingDays: qualifyingDays,
+ thresholdReached: thresholdReached,
+ qualifyingDaysDeducted: qualifyingDaysDeducted,
+ normalDaysPaid: normalDaysPaid,
+ qualifyingDaysPaid: qualifyingDaysPaid,
+ bonusNormalDays: normalDaysPaid * this.RATE_NORMAL,
+ bonusQualifyingDays: qualifyingDaysPaid * this.RATE_WEEKEND,
+ totalBonus: bonus,
+ dutyDetails: dutyDetails
+ };
+ }
+
+ /**
+ * Calculate bonuses for all employees
+ * @param {Object} employeeDuties - Object with employee names as keys and duty arrays as values
+ * @returns {Object} Results for all employees
+ */
+ calculateAllEmployees(employeeDuties) {
+ const results = {};
+
+ for (const [employeeName, duties] of Object.entries(employeeDuties)) {
+ results[employeeName] = this.calculateMonthlyBonus(duties);
+ }
+
+ return results;
+ }
+
+ /**
+ * Get empty result structure
+ * @returns {Object}
+ */
+ getEmptyResult() {
+ return {
+ totalDuties: 0,
+ totalDaysWorked: 0,
+ normalDays: 0,
+ qualifyingDays: 0,
+ thresholdReached: false,
+ qualifyingDaysDeducted: 0,
+ normalDaysPaid: 0,
+ qualifyingDaysPaid: 0,
+ bonusNormalDays: 0,
+ bonusQualifyingDays: 0,
+ totalBonus: 0,
+ dutyDetails: []
+ };
+ }
+
+ /**
+ * Format currency for display
+ * @param {number} amount
+ * @returns {string}
+ */
+ formatCurrency(amount) {
+ return new Intl.NumberFormat('de-DE', {
+ style: 'currency',
+ currency: 'EUR'
+ }).format(amount);
+ }
+}
+
+// Make it available globally
+window.BonusCalculator = BonusCalculator;
diff --git a/webapp/holidays.js b/webapp/holidays.js
new file mode 100644
index 0000000..7500315
--- /dev/null
+++ b/webapp/holidays.js
@@ -0,0 +1,156 @@
+/**
+ * NRW Public Holidays Provider
+ * Provides holidays for NRW (Nordrhein-Westfalen) from 2025-2030
+ */
+class HolidayProvider {
+ constructor() {
+ this.holidays = this.initializeHolidays();
+ }
+
+ initializeHolidays() {
+ return {
+ 2025: [
+ { date: '2025-01-01', name: 'Neujahr' },
+ { date: '2025-04-18', name: 'Karfreitag' },
+ { date: '2025-04-21', name: 'Ostermontag' },
+ { date: '2025-05-01', name: 'Tag der Arbeit' },
+ { date: '2025-05-29', name: 'Christi Himmelfahrt' },
+ { date: '2025-06-09', name: 'Pfingstmontag' },
+ { date: '2025-06-19', name: 'Fronleichnam' },
+ { date: '2025-10-03', name: 'Tag der Deutschen Einheit' },
+ { date: '2025-11-01', name: 'Allerheiligen' },
+ { date: '2025-12-25', name: '1. Weihnachtstag' },
+ { date: '2025-12-26', name: '2. Weihnachtstag' }
+ ],
+ 2026: [
+ { date: '2026-01-01', name: 'Neujahr' },
+ { date: '2026-04-03', name: 'Karfreitag' },
+ { date: '2026-04-06', name: 'Ostermontag' },
+ { date: '2026-05-01', name: 'Tag der Arbeit' },
+ { date: '2026-05-14', name: 'Christi Himmelfahrt' },
+ { date: '2026-05-25', name: 'Pfingstmontag' },
+ { date: '2026-06-04', name: 'Fronleichnam' },
+ { date: '2026-10-03', name: 'Tag der Deutschen Einheit' },
+ { date: '2026-11-01', name: 'Allerheiligen' },
+ { date: '2026-12-25', name: '1. Weihnachtstag' },
+ { date: '2026-12-26', name: '2. Weihnachtstag' }
+ ],
+ 2027: [
+ { date: '2027-01-01', name: 'Neujahr' },
+ { date: '2027-03-26', name: 'Karfreitag' },
+ { date: '2027-03-29', name: 'Ostermontag' },
+ { date: '2027-05-01', name: 'Tag der Arbeit' },
+ { date: '2027-05-06', name: 'Christi Himmelfahrt' },
+ { date: '2027-05-17', name: 'Pfingstmontag' },
+ { date: '2027-05-27', name: 'Fronleichnam' },
+ { date: '2027-10-03', name: 'Tag der Deutschen Einheit' },
+ { date: '2027-11-01', name: 'Allerheiligen' },
+ { date: '2027-12-25', name: '1. Weihnachtstag' },
+ { date: '2027-12-26', name: '2. Weihnachtstag' }
+ ],
+ 2028: [
+ { date: '2028-01-01', name: 'Neujahr' },
+ { date: '2028-04-14', name: 'Karfreitag' },
+ { date: '2028-04-17', name: 'Ostermontag' },
+ { date: '2028-05-01', name: 'Tag der Arbeit' },
+ { date: '2028-05-25', name: 'Christi Himmelfahrt' },
+ { date: '2028-06-05', name: 'Pfingstmontag' },
+ { date: '2028-06-15', name: 'Fronleichnam' },
+ { date: '2028-10-03', name: 'Tag der Deutschen Einheit' },
+ { date: '2028-11-01', name: 'Allerheiligen' },
+ { date: '2028-12-25', name: '1. Weihnachtstag' },
+ { date: '2028-12-26', name: '2. Weihnachtstag' }
+ ],
+ 2029: [
+ { date: '2029-01-01', name: 'Neujahr' },
+ { date: '2029-03-30', name: 'Karfreitag' },
+ { date: '2029-04-02', name: 'Ostermontag' },
+ { date: '2029-05-01', name: 'Tag der Arbeit' },
+ { date: '2029-05-10', name: 'Christi Himmelfahrt' },
+ { date: '2029-05-21', name: 'Pfingstmontag' },
+ { date: '2029-05-31', name: 'Fronleichnam' },
+ { date: '2029-10-03', name: 'Tag der Deutschen Einheit' },
+ { date: '2029-11-01', name: 'Allerheiligen' },
+ { date: '2029-12-25', name: '1. Weihnachtstag' },
+ { date: '2029-12-26', name: '2. Weihnachtstag' }
+ ],
+ 2030: [
+ { date: '2030-01-01', name: 'Neujahr' },
+ { date: '2030-04-19', name: 'Karfreitag' },
+ { date: '2030-04-22', name: 'Ostermontag' },
+ { date: '2030-05-01', name: 'Tag der Arbeit' },
+ { date: '2030-05-30', name: 'Christi Himmelfahrt' },
+ { date: '2030-06-10', name: 'Pfingstmontag' },
+ { date: '2030-06-20', name: 'Fronleichnam' },
+ { date: '2030-10-03', name: 'Tag der Deutschen Einheit' },
+ { date: '2030-11-01', name: 'Allerheiligen' },
+ { date: '2030-12-25', name: '1. Weihnachtstag' },
+ { date: '2030-12-26', name: '2. Weihnachtstag' }
+ ]
+ };
+ }
+
+ /**
+ * Check if a given date is a public holiday
+ * @param {Date} date - Date to check
+ * @returns {boolean}
+ */
+ isHoliday(date) {
+ const year = date.getFullYear();
+ const dateStr = this.formatDate(date);
+
+ if (!this.holidays[year]) return false;
+
+ return this.holidays[year].some(h => h.date === dateStr);
+ }
+
+ /**
+ * Check if a given date is the day before a public holiday
+ * @param {Date} date - Date to check
+ * @returns {boolean}
+ */
+ isDayBeforeHoliday(date) {
+ const nextDay = new Date(date);
+ nextDay.setDate(nextDay.getDate() + 1);
+ return this.isHoliday(nextDay);
+ }
+
+ /**
+ * Get holiday name for a given date (if it is a holiday)
+ * @param {Date} date - Date to check
+ * @returns {string|null}
+ */
+ getHolidayName(date) {
+ const year = date.getFullYear();
+ const dateStr = this.formatDate(date);
+
+ if (!this.holidays[year]) return null;
+
+ const holiday = this.holidays[year].find(h => h.date === dateStr);
+ return holiday ? holiday.name : null;
+ }
+
+ /**
+ * Format date as YYYY-MM-DD
+ * @param {Date} date
+ * @returns {string}
+ */
+ formatDate(date) {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ }
+
+ /**
+ * Get all holidays for a specific year
+ * @param {number} year
+ * @returns {Array}
+ */
+ getHolidaysForYear(year) {
+ return this.holidays[year] || [];
+ }
+}
+
+// Make it available globally
+window.HolidayProvider = HolidayProvider;
diff --git a/webapp/index.html b/webapp/index.html
new file mode 100644
index 0000000..9cbafd6
--- /dev/null
+++ b/webapp/index.html
@@ -0,0 +1,206 @@
+
+
+
+
+
+ Dienstplan Bonusrechner - NRW
+
+
+
+
+
+
+
+
+ Dienste eintragen
+ Berechnung
+ Mitarbeiter verwalten
+ Einstellungen
+
+
+
+
+
+
Dienste eintragen
+
+
+
+
+
+
+ Mitarbeiter:
+
+ -- Mitarbeiter auswählen --
+
+
+
+
+
+ Datum:
+
+
+
+
+ Dienstanteil:
+
+ Ganzer Dienst (1.0)
+ Halber Dienst (0.5)
+
+
+
+
Dienst hinzufügen
+
+
+
+
Eingetragene Dienste
+
+
Wählen Sie einen Mitarbeiter aus, um Dienste anzuzeigen.
+
+
+
+
+
+
+
+
+
Bonusberechnung
+
+
+
+
+
Berechnung durchführen
+
+
+
+
+
+
+
+
+
+
+
+
Mitarbeiter verwalten
+
+
+
+
+
+
+
Mitarbeiter
+
+
Keine Mitarbeiter vorhanden.
+
+
+
+
+
+
+
+
+
Einstellungen & Daten
+
+
+
Berechnungsregeln
+
+
Qualifizierende Tage (WE/Feiertag):
+
+ Freitag, Samstag, Sonntag
+ Feiertage in NRW
+ Tag vor einem Feiertag
+
+
+
Bonusberechnung:
+
+ Mindestens 2.0 qualifizierende Tage erforderlich
+ Bei Erreichen der Schwelle: 1.0 qualifizierender Tag wird abgezogen
+ Normale Tage: 250€ pro Tag
+ Qualifizierende Tage: 450€ pro Tag
+ Halbe Dienste werden mit der Hälfte berechnet
+
+
+
Wichtig:
+
Wenn weniger als 2.0 qualifizierende Tage erreicht werden, erfolgt keine Bonuszahlung .
+
+
+
+
+
Datenexport / Import
+
Daten exportieren (JSON)
+
+ Daten importieren:
+
+ Importieren
+
+
+
+
+
Alle Daten löschen
+
Achtung: Diese Aktion kann nicht rückgängig gemacht werden!
+
Alle Daten löschen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/storage.js b/webapp/storage.js
new file mode 100644
index 0000000..631d0e3
--- /dev/null
+++ b/webapp/storage.js
@@ -0,0 +1,231 @@
+/**
+ * Data Storage Manager
+ * Manages employee and duty data using localStorage
+ */
+class DataStorage {
+ constructor() {
+ this.STORAGE_KEY_EMPLOYEES = 'dienstplan_employees';
+ this.STORAGE_KEY_DUTIES = 'dienstplan_duties';
+ }
+
+ /**
+ * Get all employees
+ * @returns {Array} Array of employee names
+ */
+ getEmployees() {
+ const data = localStorage.getItem(this.STORAGE_KEY_EMPLOYEES);
+ return data ? JSON.parse(data) : [];
+ }
+
+ /**
+ * Save employees list
+ * @param {Array} employees - Array of employee names
+ */
+ saveEmployees(employees) {
+ localStorage.setItem(this.STORAGE_KEY_EMPLOYEES, JSON.stringify(employees));
+ }
+
+ /**
+ * Add a new employee
+ * @param {string} employeeName
+ * @returns {boolean} Success status
+ */
+ addEmployee(employeeName) {
+ const employees = this.getEmployees();
+
+ if (employees.includes(employeeName)) {
+ return false; // Already exists
+ }
+
+ employees.push(employeeName);
+ this.saveEmployees(employees.sort());
+ return true;
+ }
+
+ /**
+ * Remove an employee and all their duties
+ * @param {string} employeeName
+ */
+ removeEmployee(employeeName) {
+ // Remove from employees list
+ const employees = this.getEmployees();
+ const filtered = employees.filter(e => e !== employeeName);
+ this.saveEmployees(filtered);
+
+ // Remove all duties for this employee
+ const allDuties = this.getAllDuties();
+ delete allDuties[employeeName];
+ this.saveAllDuties(allDuties);
+ }
+
+ /**
+ * Get all duties data (all employees, all months)
+ * @returns {Object} Object with structure: {employeeName: {year-month: [duties]}}
+ */
+ getAllDuties() {
+ const data = localStorage.getItem(this.STORAGE_KEY_DUTIES);
+ return data ? JSON.parse(data) : {};
+ }
+
+ /**
+ * Save all duties data
+ * @param {Object} duties
+ */
+ saveAllDuties(duties) {
+ localStorage.setItem(this.STORAGE_KEY_DUTIES, JSON.stringify(duties));
+ }
+
+ /**
+ * Get duties for a specific employee and month
+ * @param {string} employeeName
+ * @param {number} year
+ * @param {number} month (1-12)
+ * @returns {Array} Array of duty objects
+ */
+ getDutiesForMonth(employeeName, year, month) {
+ const allDuties = this.getAllDuties();
+ const monthKey = `${year}-${String(month).padStart(2, '0')}`;
+
+ if (!allDuties[employeeName] || !allDuties[employeeName][monthKey]) {
+ return [];
+ }
+
+ // Convert date strings back to Date objects
+ return allDuties[employeeName][monthKey].map(duty => ({
+ ...duty,
+ date: new Date(duty.date)
+ }));
+ }
+
+ /**
+ * Save duties for a specific employee and month
+ * @param {string} employeeName
+ * @param {number} year
+ * @param {number} month (1-12)
+ * @param {Array} duties - Array of duty objects
+ */
+ saveDutiesForMonth(employeeName, year, month, duties) {
+ 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 => ({
+ ...duty,
+ date: duty.date.toISOString()
+ }));
+
+ this.saveAllDuties(allDuties);
+ }
+
+ /**
+ * Add a duty for an employee
+ * @param {string} employeeName
+ * @param {number} year
+ * @param {number} month (1-12)
+ * @param {Date} date
+ * @param {number} share (1.0 or 0.5)
+ */
+ addDuty(employeeName, year, month, date, share) {
+ const duties = this.getDutiesForMonth(employeeName, year, month);
+
+ // Check if duty already exists for this date
+ const existingIndex = duties.findIndex(d =>
+ d.date.toDateString() === date.toDateString()
+ );
+
+ if (existingIndex >= 0) {
+ // Update existing duty
+ duties[existingIndex].share = share;
+ } else {
+ // Add new duty
+ duties.push({ date, share });
+ }
+
+ // Sort by date
+ duties.sort((a, b) => a.date - b.date);
+
+ this.saveDutiesForMonth(employeeName, year, month, duties);
+ }
+
+ /**
+ * Remove a duty
+ * @param {string} employeeName
+ * @param {number} year
+ * @param {number} month (1-12)
+ * @param {Date} date
+ */
+ removeDuty(employeeName, year, month, date) {
+ const duties = this.getDutiesForMonth(employeeName, year, month);
+ const filtered = duties.filter(d =>
+ d.date.toDateString() !== date.toDateString()
+ );
+ this.saveDutiesForMonth(employeeName, year, month, filtered);
+ }
+
+ /**
+ * Get all duties for all employees in a specific month
+ * @param {number} year
+ * @param {number} month (1-12)
+ * @returns {Object} Object with employee names as keys
+ */
+ getAllEmployeeDutiesForMonth(year, month) {
+ const employees = this.getEmployees();
+ const result = {};
+
+ employees.forEach(employee => {
+ result[employee] = this.getDutiesForMonth(employee, year, month);
+ });
+
+ return result;
+ }
+
+ /**
+ * Clear all data
+ */
+ clearAll() {
+ localStorage.removeItem(this.STORAGE_KEY_EMPLOYEES);
+ localStorage.removeItem(this.STORAGE_KEY_DUTIES);
+ }
+
+ /**
+ * Export data as JSON
+ * @returns {string} JSON string
+ */
+ exportData() {
+ return JSON.stringify({
+ employees: this.getEmployees(),
+ duties: this.getAllDuties()
+ }, null, 2);
+ }
+
+ /**
+ * Import data from JSON
+ * @param {string} jsonString
+ * @returns {boolean} Success status
+ */
+ importData(jsonString) {
+ try {
+ const data = JSON.parse(jsonString);
+
+ if (data.employees) {
+ this.saveEmployees(data.employees);
+ }
+
+ if (data.duties) {
+ this.saveAllDuties(data.duties);
+ }
+
+ return true;
+ } catch (e) {
+ console.error('Import failed:', e);
+ return false;
+ }
+ }
+}
+
+// Make it available globally
+window.DataStorage = DataStorage;
diff --git a/webapp/styles.css b/webapp/styles.css
new file mode 100644
index 0000000..f332984
--- /dev/null
+++ b/webapp/styles.css
@@ -0,0 +1,526 @@
+/* Reset and Base Styles */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ min-height: 100vh;
+ padding: 20px;
+}
+
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
+ overflow: hidden;
+}
+
+/* Header */
+header {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 30px;
+ text-align: center;
+}
+
+header h1 {
+ font-size: 2rem;
+ margin-bottom: 10px;
+}
+
+.subtitle {
+ font-size: 1rem;
+ opacity: 0.9;
+}
+
+/* Tabs */
+.tabs {
+ display: flex;
+ background: #f8f9fa;
+ border-bottom: 2px solid #e0e0e0;
+ overflow-x: auto;
+}
+
+.tab-btn {
+ flex: 1;
+ padding: 15px 20px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ font-size: 1rem;
+ font-weight: 500;
+ color: #666;
+ transition: all 0.3s ease;
+ white-space: nowrap;
+}
+
+.tab-btn:hover {
+ background: rgba(102, 126, 234, 0.1);
+ color: #667eea;
+}
+
+.tab-btn.active {
+ background: white;
+ color: #667eea;
+ border-bottom: 3px solid #667eea;
+}
+
+/* Tab Content */
+.tab-content {
+ display: none;
+ padding: 30px;
+ animation: fadeIn 0.3s ease;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Card */
+.card {
+ background: white;
+}
+
+.card h2 {
+ color: #667eea;
+ margin-bottom: 20px;
+ font-size: 1.5rem;
+}
+
+.card h3 {
+ color: #333;
+ margin: 20px 0 10px;
+ font-size: 1.2rem;
+}
+
+/* Form Elements */
+.form-group {
+ margin-bottom: 20px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 500;
+ color: #555;
+}
+
+.form-group input[type="text"],
+.form-group input[type="date"],
+.form-group input[type="file"],
+.form-group select {
+ width: 100%;
+ padding: 10px 15px;
+ border: 2px solid #e0e0e0;
+ border-radius: 6px;
+ font-size: 1rem;
+ transition: border-color 0.3s ease;
+}
+
+.form-group input:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: #667eea;
+}
+
+.month-selector {
+ display: grid;
+ grid-template-columns: 2fr 1fr;
+ gap: 10px;
+}
+
+.input-group {
+ display: flex;
+ gap: 10px;
+}
+
+.input-group input {
+ flex: 1;
+}
+
+/* Buttons */
+.btn {
+ padding: 12px 24px;
+ border: none;
+ border-radius: 6px;
+ font-size: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.3s ease;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+}
+
+.btn-primary:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
+}
+
+.btn-secondary {
+ background: #6c757d;
+ color: white;
+ margin-right: 10px;
+}
+
+.btn-secondary:hover {
+ background: #5a6268;
+}
+
+.btn-danger {
+ background: #dc3545;
+ color: white;
+}
+
+.btn-danger:hover {
+ background: #c82333;
+}
+
+.btn-small {
+ padding: 6px 12px;
+ font-size: 0.875rem;
+}
+
+/* Duties List */
+.duties-list {
+ margin-top: 30px;
+ padding-top: 20px;
+ border-top: 2px solid #e0e0e0;
+}
+
+.duty-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 15px;
+ margin-bottom: 10px;
+ background: #f8f9fa;
+ border-radius: 6px;
+ border-left: 4px solid #667eea;
+}
+
+.duty-item.qualifying {
+ border-left-color: #28a745;
+ background: #f0f9f4;
+}
+
+.duty-info {
+ flex: 1;
+}
+
+.duty-date {
+ font-weight: 600;
+ color: #333;
+ margin-bottom: 4px;
+}
+
+.duty-meta {
+ font-size: 0.875rem;
+ color: #666;
+}
+
+.duty-share {
+ font-weight: 500;
+ margin-right: 15px;
+}
+
+.badge {
+ display: inline-block;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ margin-left: 8px;
+}
+
+.badge-qualifying {
+ background: #28a745;
+ color: white;
+}
+
+.badge-normal {
+ background: #6c757d;
+ color: white;
+}
+
+/* Employee List */
+.employee-list {
+ margin-top: 20px;
+}
+
+.employee-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 15px;
+ margin-bottom: 10px;
+ background: #f8f9fa;
+ border-radius: 6px;
+}
+
+.employee-name {
+ font-weight: 500;
+ color: #333;
+}
+
+/* Calculation Results */
+#calculation-results {
+ margin-top: 30px;
+}
+
+.result-card {
+ margin-bottom: 30px;
+ padding: 20px;
+ background: #f8f9fa;
+ border-radius: 8px;
+ border-left: 5px solid #667eea;
+}
+
+.result-card h3 {
+ color: #667eea;
+ margin-bottom: 15px;
+}
+
+.result-summary {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 15px;
+ margin-bottom: 20px;
+}
+
+.result-item {
+ background: white;
+ padding: 15px;
+ border-radius: 6px;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
+}
+
+.result-label {
+ font-size: 0.875rem;
+ color: #666;
+ margin-bottom: 5px;
+}
+
+.result-value {
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: #333;
+}
+
+.result-value.success {
+ color: #28a745;
+}
+
+.result-value.warning {
+ color: #ffc107;
+}
+
+.result-value.danger {
+ color: #dc3545;
+}
+
+.bonus-total {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 20px;
+ border-radius: 8px;
+ text-align: center;
+ margin-top: 20px;
+}
+
+.bonus-total h4 {
+ font-size: 1rem;
+ margin-bottom: 10px;
+ opacity: 0.9;
+}
+
+.bonus-total .amount {
+ font-size: 2.5rem;
+ font-weight: 700;
+}
+
+.threshold-warning {
+ background: #fff3cd;
+ border: 2px solid #ffc107;
+ border-radius: 6px;
+ padding: 15px;
+ margin: 20px 0;
+}
+
+.threshold-warning h4 {
+ color: #856404;
+ margin-bottom: 5px;
+}
+
+.threshold-warning p {
+ color: #856404;
+ margin: 0;
+}
+
+/* Settings */
+.settings-section {
+ margin-bottom: 40px;
+ padding-bottom: 20px;
+ border-bottom: 2px solid #e0e0e0;
+}
+
+.settings-section:last-child {
+ border-bottom: none;
+}
+
+.info-box {
+ background: #e7f3ff;
+ border-left: 4px solid #2196f3;
+ padding: 20px;
+ border-radius: 6px;
+ margin-top: 15px;
+}
+
+.info-box h4 {
+ color: #1976d2;
+ margin: 15px 0 10px;
+}
+
+.info-box h4:first-child {
+ margin-top: 0;
+}
+
+.info-box ul {
+ margin-left: 20px;
+}
+
+.info-box li {
+ margin-bottom: 5px;
+}
+
+.info-box p {
+ margin-top: 10px;
+}
+
+/* Utility Classes */
+.text-muted {
+ color: #6c757d;
+ font-style: italic;
+}
+
+.text-warning {
+ color: #856404;
+ font-weight: 500;
+}
+
+/* Toast Notification */
+.toast {
+ position: fixed;
+ bottom: 30px;
+ right: 30px;
+ background: #333;
+ color: white;
+ padding: 15px 25px;
+ border-radius: 6px;
+ box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
+ transform: translateY(100px);
+ opacity: 0;
+ transition: all 0.3s ease;
+ z-index: 1000;
+}
+
+.toast.show {
+ transform: translateY(0);
+ opacity: 1;
+}
+
+.toast.success {
+ background: #28a745;
+}
+
+.toast.error {
+ background: #dc3545;
+}
+
+.toast.info {
+ background: #17a2b8;
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+ body {
+ padding: 10px;
+ }
+
+ header h1 {
+ font-size: 1.5rem;
+ }
+
+ .tab-content {
+ padding: 20px;
+ }
+
+ .month-selector {
+ grid-template-columns: 1fr;
+ }
+
+ .result-summary {
+ grid-template-columns: 1fr;
+ }
+
+ .input-group {
+ flex-direction: column;
+ }
+
+ .tabs {
+ overflow-x: auto;
+ }
+
+ .tab-btn {
+ font-size: 0.875rem;
+ padding: 12px 15px;
+ }
+
+ .bonus-total .amount {
+ font-size: 2rem;
+ }
+}
+
+/* Print Styles */
+@media print {
+ body {
+ background: white;
+ padding: 0;
+ }
+
+ .container {
+ box-shadow: none;
+ }
+
+ .tabs,
+ .btn,
+ .form-group {
+ display: none;
+ }
+
+ .tab-content {
+ display: block !important;
+ padding: 20px 0;
+ }
+}