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/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 = ''; + + 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} + + `; + 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'}
+ + `; + 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 + + + +
+
+

Dienstplan Bonusrechner

+

Bonuszahlungen für Wochenend- und Feiertagsdienste (NRW)

+
+ + +
+ + + + +
+ + +
+
+

Dienste eintragen

+ + +
+ +
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+ + + + +
+

Eingetragene Dienste

+
+

Wählen Sie einen Mitarbeiter aus, um Dienste anzuzeigen.

+
+
+
+
+ + +
+
+

Bonusberechnung

+ + +
+ +
+ + +
+
+ + + + +
+ +
+
+
+ + +
+
+

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

+ +
+ + + +
+
+ +
+

Alle Daten löschen

+

Achtung: Diese Aktion kann nicht rückgängig gemacht werden!

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