Merge pull request #9 from Kenearos/claude/duty-schedule-calculator-01HRgA7y2Auxt5K2BY6sWtrM

Build duty schedule calculation application
This commit is contained in:
Kenearos 2025-11-18 21:07:19 +01:00 committed by GitHub
commit 80f6d0e562
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 2313 additions and 1 deletions

View file

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

329
claude.md Normal file
View file

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

164
webapp/README.md Normal file
View file

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

504
webapp/app.js Normal file
View file

@ -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 = '<option value="">-- Mitarbeiter auswählen --</option>';
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 = '<p class="text-muted">Keine Mitarbeiter vorhanden.</p>';
return;
}
container.innerHTML = '';
employees.forEach(employee => {
const item = document.createElement('div');
item.className = 'employee-item';
item.innerHTML = `
<span class="employee-name">${employee}</span>
<button class="btn btn-danger btn-small" onclick="app.removeEmployee('${employee}')">Löschen</button>
`;
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 = '<p class="text-muted">Wählen Sie einen Mitarbeiter aus, um Dienste anzuzeigen.</p>';
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 = `<p class="text-muted">Keine Dienste für ${monthNames[month - 1]} ${year}.</p>`;
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 = `
<div class="duty-info">
<div class="duty-date">${dateStr}</div>
<div class="duty-meta">
${dayType}
<span class="badge ${isQualifying ? 'badge-qualifying' : 'badge-normal'}">
${isQualifying ? 'WE/Feiertag' : 'Normal'}
</span>
</div>
</div>
<div class="duty-share">${duty.share === 1 ? 'Ganzer Dienst' : 'Halber Dienst'}</div>
<button class="btn btn-danger btn-small"
onclick="app.removeDuty('${employeeName}', ${year}, ${month}, new Date('${duty.date.toISOString()}'))">
Löschen
</button>
`;
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 = `<h3>Ergebnisse für ${monthNames[month - 1]} ${year}</h3>`;
const employees = Object.keys(results);
if (employees.length === 0) {
resultsContainer.innerHTML += '<p class="text-muted">Keine Daten verfügbar.</p>';
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 = `<h3>${employeeName}</h3>`;
if (!result.thresholdReached) {
content += `
<div class="threshold-warning">
<h4>Schwellenwert nicht erreicht</h4>
<p>Es wurden nur ${result.qualifyingDays.toFixed(1)} qualifizierende Tage gearbeitet.
Mindestens ${this.calculator.MIN_QUALIFYING_DAYS} Tage erforderlich.</p>
<p><strong>Keine Bonuszahlung</strong></p>
</div>
`;
} else {
content += `
<div class="result-summary">
<div class="result-item">
<div class="result-label">Normale Tage</div>
<div class="result-value">${result.normalDays.toFixed(1)}</div>
</div>
<div class="result-item">
<div class="result-label">WE/Feiertag Tage</div>
<div class="result-value">${result.qualifyingDays.toFixed(1)}</div>
</div>
<div class="result-item">
<div class="result-label">Abzug</div>
<div class="result-value danger">-${result.qualifyingDaysDeducted.toFixed(1)}</div>
</div>
<div class="result-item">
<div class="result-label">Normale Tage (bezahlt)</div>
<div class="result-value success">${result.normalDaysPaid.toFixed(1)}</div>
</div>
<div class="result-item">
<div class="result-label">WE/Feiertag (bezahlt)</div>
<div class="result-value success">${result.qualifyingDaysPaid.toFixed(1)}</div>
</div>
</div>
<div class="result-summary">
<div class="result-item">
<div class="result-label">Normale Tage (250)</div>
<div class="result-value">${this.calculator.formatCurrency(result.bonusNormalDays)}</div>
</div>
<div class="result-item">
<div class="result-label">WE/Feiertag (450)</div>
<div class="result-value">${this.calculator.formatCurrency(result.bonusQualifyingDays)}</div>
</div>
</div>
<div class="bonus-total">
<h4>Gesamtbonus</h4>
<div class="amount">${this.calculator.formatCurrency(result.totalBonus)}</div>
</div>
`;
}
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();
});

176
webapp/calculator.js Normal file
View file

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

156
webapp/holidays.js Normal file
View file

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

206
webapp/index.html Normal file
View file

@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dienstplan Bonusrechner - NRW</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>Dienstplan Bonusrechner</h1>
<p class="subtitle">Bonuszahlungen für Wochenend- und Feiertagsdienste (NRW)</p>
</header>
<!-- Navigation Tabs -->
<div class="tabs">
<button class="tab-btn active" data-tab="duties">Dienste eintragen</button>
<button class="tab-btn" data-tab="calculation">Berechnung</button>
<button class="tab-btn" data-tab="employees">Mitarbeiter verwalten</button>
<button class="tab-btn" data-tab="settings">Einstellungen</button>
</div>
<!-- Tab: Dienste eintragen -->
<div id="tab-duties" class="tab-content active">
<div class="card">
<h2>Dienste eintragen</h2>
<!-- Month Selection -->
<div class="form-group">
<label for="month-select">Monat auswählen:</label>
<div class="month-selector">
<select id="month-select">
<option value="1">Januar</option>
<option value="2">Februar</option>
<option value="3">März</option>
<option value="4">April</option>
<option value="5">Mai</option>
<option value="6">Juni</option>
<option value="7">Juli</option>
<option value="8">August</option>
<option value="9">September</option>
<option value="10">Oktober</option>
<option value="11">November</option>
<option value="12">Dezember</option>
</select>
<select id="year-select">
<!-- Will be populated by JavaScript -->
</select>
</div>
</div>
<!-- Employee Selection -->
<div class="form-group">
<label for="employee-select-duty">Mitarbeiter:</label>
<select id="employee-select-duty">
<option value="">-- Mitarbeiter auswählen --</option>
</select>
</div>
<!-- Add Duty Form -->
<div class="form-group">
<label for="duty-date">Datum:</label>
<input type="date" id="duty-date">
</div>
<div class="form-group">
<label for="duty-share">Dienstanteil:</label>
<select id="duty-share">
<option value="1">Ganzer Dienst (1.0)</option>
<option value="0.5">Halber Dienst (0.5)</option>
</select>
</div>
<button id="add-duty-btn" class="btn btn-primary">Dienst hinzufügen</button>
<!-- Duties List -->
<div class="duties-list">
<h3>Eingetragene Dienste</h3>
<div id="duties-display">
<p class="text-muted">Wählen Sie einen Mitarbeiter aus, um Dienste anzuzeigen.</p>
</div>
</div>
</div>
</div>
<!-- Tab: Berechnung -->
<div id="tab-calculation" class="tab-content">
<div class="card">
<h2>Bonusberechnung</h2>
<!-- Month Selection for Calculation -->
<div class="form-group">
<label for="calc-month-select">Monat auswählen:</label>
<div class="month-selector">
<select id="calc-month-select">
<option value="1">Januar</option>
<option value="2">Februar</option>
<option value="3">März</option>
<option value="4">April</option>
<option value="5">Mai</option>
<option value="6">Juni</option>
<option value="7">Juli</option>
<option value="8">August</option>
<option value="9">September</option>
<option value="10">Oktober</option>
<option value="11">November</option>
<option value="12">Dezember</option>
</select>
<select id="calc-year-select">
<!-- Will be populated by JavaScript -->
</select>
</div>
</div>
<button id="calculate-btn" class="btn btn-primary">Berechnung durchführen</button>
<!-- Results Display -->
<div id="calculation-results">
<!-- Will be populated by JavaScript -->
</div>
</div>
</div>
<!-- Tab: Mitarbeiter verwalten -->
<div id="tab-employees" class="tab-content">
<div class="card">
<h2>Mitarbeiter verwalten</h2>
<!-- Add Employee -->
<div class="form-group">
<label for="new-employee-name">Neuer Mitarbeiter:</label>
<div class="input-group">
<input type="text" id="new-employee-name" placeholder="Name eingeben...">
<button id="add-employee-btn" class="btn btn-primary">Hinzufügen</button>
</div>
</div>
<!-- Employee List -->
<div class="employee-list">
<h3>Mitarbeiter</h3>
<div id="employee-list-display">
<p class="text-muted">Keine Mitarbeiter vorhanden.</p>
</div>
</div>
</div>
</div>
<!-- Tab: Einstellungen -->
<div id="tab-settings" class="tab-content">
<div class="card">
<h2>Einstellungen & Daten</h2>
<div class="settings-section">
<h3>Berechnungsregeln</h3>
<div class="info-box">
<h4>Qualifizierende Tage (WE/Feiertag):</h4>
<ul>
<li>Freitag, Samstag, Sonntag</li>
<li>Feiertage in NRW</li>
<li>Tag vor einem Feiertag</li>
</ul>
<h4>Bonusberechnung:</h4>
<ul>
<li>Mindestens <strong>2.0 qualifizierende Tage</strong> erforderlich</li>
<li>Bei Erreichen der Schwelle: <strong>1.0 qualifizierender Tag</strong> wird abgezogen</li>
<li>Normale Tage: <strong>250€</strong> pro Tag</li>
<li>Qualifizierende Tage: <strong>450€</strong> pro Tag</li>
<li>Halbe Dienste werden mit der Hälfte berechnet</li>
</ul>
<h4>Wichtig:</h4>
<p>Wenn weniger als 2.0 qualifizierende Tage erreicht werden, erfolgt <strong>keine Bonuszahlung</strong>.</p>
</div>
</div>
<div class="settings-section">
<h3>Datenexport / Import</h3>
<button id="export-btn" class="btn btn-secondary">Daten exportieren (JSON)</button>
<div class="form-group">
<label for="import-file">Daten importieren:</label>
<input type="file" id="import-file" accept=".json">
<button id="import-btn" class="btn btn-secondary">Importieren</button>
</div>
</div>
<div class="settings-section">
<h3>Alle Daten löschen</h3>
<p class="text-warning">Achtung: Diese Aktion kann nicht rückgängig gemacht werden!</p>
<button id="clear-all-btn" class="btn btn-danger">Alle Daten löschen</button>
</div>
</div>
</div>
</div>
<!-- Toast Notification -->
<div id="toast" class="toast"></div>
<!-- Scripts -->
<script src="holidays.js"></script>
<script src="calculator.js"></script>
<script src="storage.js"></script>
<script src="app.js"></script>
</body>
</html>

231
webapp/storage.js Normal file
View file

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

526
webapp/styles.css Normal file
View file

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