/** * 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()); // Date stepper buttons (Feature C) document.getElementById('duty-date-prev').addEventListener('click', () => this.stepDutyDate(-1)); document.getElementById('duty-date-next').addEventListener('click', () => this.stepDutyDate(+1)); document.getElementById('duty-date').addEventListener('change', () => this.updateDateStepperState()); document.getElementById('month-select').addEventListener('change', () => this.onDutyMonthChange()); document.getElementById('year-select').addEventListener('change', () => this.onDutyMonthChange()); // Bild-Import (Feature A) const imageImportBtn = document.getElementById('open-image-import-btn'); if (imageImportBtn) { imageImportBtn.addEventListener('click', () => { if (window.imageImporter) { window.imageImporter.openImportDialog(); } else { this.showToast('Bild-Import nicht verfuegbar.', 'error'); } }); } // Calculation document.getElementById('calculate-btn').addEventListener('click', () => this.calculateBonuses()); // Settings document.getElementById('export-csv-btn').addEventListener('click', () => this.exportCSV()); document.getElementById('export-report-btn').addEventListener('click', () => this.exportBonusReport()); // NEW: Email Report Generator const emailBtn = document.getElementById('email-report-btn'); if (emailBtn) { emailBtn.addEventListener('click', () => this.generateEmailReport()); } 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()); // Bild-Import (KI) settings const setKeyBtn = document.getElementById('set-api-key-btn'); if (setKeyBtn) setKeyBtn.addEventListener('click', () => this.setApiKeyFromPrompt()); const clearKeyBtn = document.getElementById('clear-api-key-btn'); if (clearKeyBtn) clearKeyBtn.addEventListener('click', () => this.clearApiKey()); const modelSelect = document.getElementById('api-model-select'); if (modelSelect) { modelSelect.value = this.storage.getApiModel(); modelSelect.addEventListener('change', () => { this.storage.setApiModel(modelSelect.value); this.showToast(`Modell geaendert: ${modelSelect.options[modelSelect.selectedIndex].text}`, 'success'); }); } this.refreshApiKeyStatus(); } /** * 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; this.updateDateStepperState(); } /** * 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(); } else if (tabName === 'settings') { this.refreshApiKeyStatus(); } } /** * 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(); } /** * Step the duty-date input by +/-1 day, clamped to the currently selected month. */ stepDutyDate(delta) { const dateInput = document.getElementById('duty-date'); const monthSelect = document.getElementById('month-select'); const yearSelect = document.getElementById('year-select'); const month = parseInt(monthSelect.value); const year = parseInt(yearSelect.value); const lastDay = new Date(year, month, 0).getDate(); if (!dateInput.value) { // Initialize to 1st of the selected month dateInput.value = `${year}-${String(month).padStart(2, '0')}-01`; this.updateDateStepperState(); return; } const cur = new Date(dateInput.value + 'T12:00:00'); // If outside selected month, snap to 1st const inMonth = (cur.getFullYear() === year) && ((cur.getMonth() + 1) === month); if (!inMonth) { dateInput.value = `${year}-${String(month).padStart(2, '0')}-01`; this.updateDateStepperState(); return; } const curDay = cur.getDate(); const newDay = curDay + delta; if (newDay < 1 || newDay > lastDay) return; // clamp const newDate = new Date(year, month - 1, newDay, 12, 0, 0); const yyyy = newDate.getFullYear(); const mm = String(newDate.getMonth() + 1).padStart(2, '0'); const dd = String(newDate.getDate()).padStart(2, '0'); dateInput.value = `${yyyy}-${mm}-${dd}`; this.updateDateStepperState(); } /** * Update the disabled state of the stepper buttons based on current date / month. */ updateDateStepperState() { const dateInput = document.getElementById('duty-date'); const monthSelect = document.getElementById('month-select'); const yearSelect = document.getElementById('year-select'); const prevBtn = document.getElementById('duty-date-prev'); const nextBtn = document.getElementById('duty-date-next'); if (!dateInput || !prevBtn || !nextBtn) return; const month = parseInt(monthSelect.value); const year = parseInt(yearSelect.value); const lastDay = new Date(year, month, 0).getDate(); if (!dateInput.value) { prevBtn.disabled = false; nextBtn.disabled = false; return; } const cur = new Date(dateInput.value + 'T12:00:00'); const inSelectedMonth = (cur.getFullYear() === year) && ((cur.getMonth() + 1) === month); if (!inSelectedMonth) { prevBtn.disabled = false; nextBtn.disabled = false; return; } prevBtn.disabled = cur.getDate() <= 1; nextBtn.disabled = cur.getDate() >= lastDay; } /** * Handle month/year change in the duty tab: set date to 1st of new month, refresh list, refresh stepper. */ onDutyMonthChange() { const monthSelect = document.getElementById('month-select'); const yearSelect = document.getElementById('year-select'); const month = parseInt(monthSelect.value); const year = parseInt(yearSelect.value); document.getElementById('duty-date').value = `${year}-${String(month).padStart(2, '0')}-01`; this.updateDateStepperState(); 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 yearMonth = `${year}-${String(month).padStart(2, '0')}`; const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month); // Build vacation map for this month: { name: boolean } const vacationMap = {}; Object.keys(employeeDuties).forEach(name => { vacationMap[name] = this.storage.getVacationMode(name, yearMonth); }); const results = this.calculator.calculateAllEmployees(employeeDuties, vacationMap); 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; } // Stash current calc context for vacation-toggle handler this._currentCalcContext = { year, month, yearMonth }; 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 (new variants shape). */ createResultCard(employeeName, result) { const card = document.createElement('div'); card.className = 'result-card'; const ctx = this._currentCalcContext || {}; const yearMonth = ctx.yearMonth || ''; const vacChecked = result.isVacation ? 'checked' : ''; const safeName = String(employeeName).replace(/"/g, '"'); const safeYm = String(yearMonth).replace(/"/g, '"'); // Header + vacation toggle let content = `

${employeeName}

`; if (result.isVacation) { content += `
Urlaubsmodus aktiv - Schwellen halbiert
`; } // Winner banner if (!result.winner.eligible || result.totalBonus === 0) { content += `

Keine Variante triggert

Mit den eingetragenen Diensten erreicht keine der drei Varianten einen positiven Bonus.

Keine Bonuszahlung

`; } else { content += `

Variante ${result.winner.variantId} ★ Sieger

${this.calculator.formatCurrency(result.totalBonus)}
`; } // Classified summary line const c = result.classified; content += `
Fr: ${c.fr.toFixed(1)} Sa: ${c.sa.toFixed(1)} So: ${c.so.toFixed(1)} Werktage: ${c.weekday.toFixed(1)}
`; // Collapsible variant breakdown content += `
Alle Varianten anzeigen`; for (const v of result.allResults) { content += this.renderVariantBlock(v, result.winner.variantId); } content += `
`; card.innerHTML = content; // Attach vacation-toggle handler const cb = card.querySelector('input[data-vacation-employee]'); if (cb) { cb.addEventListener('change', (e) => this.onVacationToggle(e)); } return card; } /** * Render a single variant sub-panel. */ renderVariantBlock(v, winnerId) { const isWinner = v.variantId === winnerId; const star = isWinner ? '' : ''; const labels = { 1: 'V1: 1 (Fr/So) + 3 Werktage', 2: 'V2: 1 Sa + 2 Werktage', 3: 'V3 (loose): 2 qualifizierende Tage (Pool Fr+Sa+So)' }; let thresholdStr = '-'; if (v.threshold) { if (v.variantId === 1) thresholdStr = `Fr+So ≥ ${v.threshold.frSo}, Werktage ≥ ${v.threshold.weekday}`; if (v.variantId === 2) thresholdStr = `Sa ≥ ${v.threshold.sa}, Werktage ≥ ${v.threshold.weekday}`; if (v.variantId === 3) thresholdStr = `Pool ≥ ${v.threshold.pool}`; } const elig = v.eligible ? 'erfüllt' : 'nicht erfüllt'; return `
${star}${labels[v.variantId]}
Schwelle:${thresholdStr}
Eligibility:${elig}
Abzug: Fr ${v.deduction.fr.toFixed(2)} - Sa ${v.deduction.sa.toFixed(2)} - So ${v.deduction.so.toFixed(2)} - WT ${v.deduction.weekday.toFixed(2)}
Bezahlt: Fr ${v.paidShares.fr.toFixed(2)} - Sa ${v.paidShares.sa.toFixed(2)} - So ${v.paidShares.so.toFixed(2)} - WT ${v.paidShares.weekday.toFixed(2)}
Bonus:${this.calculator.formatCurrency(v.bonus)}
`; } /** * Handle vacation checkbox toggle. */ onVacationToggle(e) { const cb = e.target; const name = cb.getAttribute('data-vacation-employee'); const ym = cb.getAttribute('data-vacation-yearmonth'); try { this.storage.setVacationMode(name, ym, cb.checked); // Re-run calc to reflect the new state this.calculateBonuses(); } catch (err) { this.showToast('Urlaubsmodus konnte nicht gespeichert werden', 'error'); cb.checked = !cb.checked; // revert visual state } } // --- NEW: EMAIL REPORT GENERATOR --- generateEmailReport() { // Need to grab current selected calc month/year const monthSelect = document.getElementById('calc-month-select'); const yearSelect = document.getElementById('calc-year-select'); const month = parseInt(monthSelect.value); const year = parseInt(yearSelect.value); const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month); const yearMonth = `${year}-${String(month).padStart(2, '0')}`; const vacationMap = {}; Object.keys(employeeDuties).forEach(n => { vacationMap[n] = this.storage.getVacationMode(n, yearMonth); }); const results = this.calculator.calculateAllEmployees(employeeDuties, vacationMap); const monthName = this.getMonthName(month); let reportHtml = `

Dienstplan Abrechnung ${monthName} ${year}

`; // 1. Copy-Paste Table reportHtml += `
`; reportHtml += `

Übersicht:

`; reportHtml += ``; reportHtml += ``; let textBlocks = []; if (results && Object.keys(results).length > 0) { Object.keys(results).forEach(name => { const res = results[name]; const w = res.winner; const c = res.classified; const totalWe = c.fr + c.sa + c.so; const deducted = w.deduction.fr + w.deduction.sa + w.deduction.so; const triggered = w.eligible && res.totalBonus > 0; let statusText = ''; let rowStyle = ''; let blockText = ''; if (triggered) { statusText = `Variante ${w.variantId} (${this.calculator.formatCurrency(res.totalBonus)})${res.isVacation ? ' - Urlaub' : ''}`; blockText = `Herr/Frau ${name} erreicht ${this.formatNumber(totalWe)} qualifizierende Dienste (Fr/Sa/So), ${this.formatNumber(deducted)} davon werden abgezogen - Bonus nach Variante ${w.variantId}: ${this.calculator.formatCurrency(res.totalBonus)}${res.isVacation ? ' (Urlaubsmodus aktiv)' : ''}.`; } else if (totalWe > 0 || c.weekday > 0) { statusText = 'Bonus nicht erreicht'; rowStyle = 'background-color: #fff0f0;'; blockText = `Mitarbeiter ${name} erreicht in keiner der drei Varianten die Schwelle (Fr ${c.fr.toFixed(1)}, Sa ${c.sa.toFixed(1)}, So ${c.so.toFixed(1)}, Werktage ${c.weekday.toFixed(1)})${res.isVacation ? ' - Urlaubsmodus aktiv' : ''}.`; } else { statusText = '-'; rowStyle = 'color: #999;'; } reportHtml += ``; if (blockText) textBlocks.push(blockText); }); } else { reportHtml += ``; } reportHtml += `
Mitarbeiter WE-Dienste Abzug Bemerkung
${name} ${this.formatNumber(totalWe)} ${this.formatNumber(deducted)} ${statusText}
Keine Daten für diesen Monat
`; // 2. Text Blocks reportHtml += `

Text-Bausteine für E-Mail (Copy & Paste):

`; reportHtml += `
`; if (textBlocks.length > 0) { textBlocks.forEach(text => { reportHtml += `

${text}

`; }); } else { reportHtml += `

Keine relevanten Dienste.

`; } reportHtml += `
`; // Modal Logic const modal = document.createElement('div'); modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;justify-content:center;align-items:center;z-index:1000;'; modal.innerHTML = `

📧 E-Mail Text-Generator

Kopieren Sie diesen Inhalt direkt in Ihre E-Mail an die Verwaltung.

${reportHtml}
`; document.body.appendChild(modal); modal.querySelector('#close-modal-btn').onclick = () => modal.remove(); modal.querySelector('#close-btn-bottom').onclick = () => modal.remove(); modal.querySelector('#copy-btn').onclick = () => { const range = document.createRange(); range.selectNode(modal.querySelector('#report-content')); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); try { document.execCommand('copy'); this.showToast('✅ Bericht kopiert! (Einfügen mit Strg+V)', 'success'); } catch (err) { this.showToast('❌ Fehler beim Kopieren.', 'error'); } window.getSelection().removeAllRanges(); }; } /** * 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'); } /** * Export data as CSV (Excel-compatible) - Beginner-friendly format * Exports all duties and monthly summary for the selected month */ exportCSV() { const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; const month = parseInt(document.getElementById('calc-month-select').value); const year = parseInt(document.getElementById('calc-year-select').value); // Helper function to escape CSV values (handles semicolons, quotes, newlines) const escapeCSV = (value) => { const str = String(value); if (str.includes(';') || str.includes('"') || str.includes('\n')) { return '"' + str.replace(/"/g, '""') + '"'; } return str; }; // Build CSV content with BOM for Excel UTF-8 support let csv = '\uFEFF'; // UTF-8 BOM for Excel // === Sheet 1: Dienste (All Duties for the month) === csv += `DIENSTE ${monthNames[month - 1]} ${year}\n`; csv += 'Datum;Wochentag;Mitarbeiter;Anteil;Tagestyp\n'; const employees = this.storage.getEmployees(); const allDuties = []; // Collect all duties for the selected month from all employees employees.forEach(employee => { const duties = this.storage.getDutiesForMonth(employee, year, month); duties.forEach(duty => { allDuties.push({ ...duty, employee: employee }); }); }); // Sort by date allDuties.sort((a, b) => a.date - b.date); allDuties.forEach(duty => { const isQual = this.calculator.isQualifyingDay(duty.date); const dateStr = duty.date.toLocaleDateString('de-DE'); const weekday = weekdays[duty.date.getDay()]; const dayType = isQual ? 'WE-Tag' : 'Werktag (WT)'; csv += `${dateStr};${weekday};${escapeCSV(duty.employee)};${duty.share.toFixed(1).replace('.', ',')};${dayType}\n`; }); csv += '\n\n'; // === Sheet 2: Monatliche Auswertung === csv += `AUSWERTUNG ${monthNames[month - 1]} ${year}\n`; csv += 'Mitarbeiter;Urlaub;Sieger-Variante;Fr;Sa;So;Werktage;Eligible;Abzug Fr;Abzug Sa;Abzug So;Abzug WT;Bonus (EUR)\n'; const yearMonth = `${year}-${String(month).padStart(2, '0')}`; const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month); const vacationMap = {}; Object.keys(employeeDuties).forEach(name => { vacationMap[name] = this.storage.getVacationMode(name, yearMonth); }); const results = this.calculator.calculateAllEmployees(employeeDuties, vacationMap); let totalBonus = 0; for (const [employeeName, result] of Object.entries(results)) { const w = result.winner; const c = result.classified; totalBonus += result.totalBonus; csv += `${escapeCSV(employeeName)};`; csv += `${result.isVacation ? 'JA' : 'NEIN'};`; csv += `V${w.variantId};`; csv += `${c.fr.toFixed(1).replace('.', ',')};`; csv += `${c.sa.toFixed(1).replace('.', ',')};`; csv += `${c.so.toFixed(1).replace('.', ',')};`; csv += `${c.weekday.toFixed(1).replace('.', ',')};`; csv += `${w.eligible ? 'JA' : 'NEIN'};`; csv += `${w.deduction.fr.toFixed(2).replace('.', ',')};`; csv += `${w.deduction.sa.toFixed(2).replace('.', ',')};`; csv += `${w.deduction.so.toFixed(2).replace('.', ',')};`; csv += `${w.deduction.weekday.toFixed(2).replace('.', ',')};`; csv += `${result.totalBonus.toFixed(2).replace('.', ',')}\n`; } csv += `\nGESAMT;;;;;;;;;;;;${totalBonus.toFixed(2).replace('.', ',')}\n`; csv += '\n\n'; csv += 'LEGENDE\n'; csv += 'Fr/Sa/So/Werktage;Klassifizierte Shares pro Slot (Halbdienste 0,5)\n'; csv += 'Sieger-Variante;V1, V2 oder V3 - automatisch die Variante mit dem höchsten Bonus\n'; csv += 'V1;"fr+so >= 1 UND weekday >= 3 (Halbiert bei Urlaub: 0,5 / 1,5)"\n'; csv += 'V2;"sa >= 1 UND weekday >= 2 (Halbiert bei Urlaub: 0,5 / 1)"\n'; csv += 'V3 (loose);"fr+sa+so >= 2 - wie bisher (Halbiert bei Urlaub: 1)"\n'; csv += 'Urlaub;"Wenn JA: Schwellen und Abzüge halbiert"\n'; csv += 'Sätze;"Werktag = 250 EUR/Einheit, Fr/Sa/So/Feiertag = 450 EUR/Einheit"\n'; // Download CSV file const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `Dienstplan_${year}_${String(month).padStart(2, '0')}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.showToast('CSV wurde exportiert. Öffnen Sie die Datei mit Excel oder LibreOffice.', 'success'); } /** * Export a formal bonus report in HTML format * Opens in a new window for printing or saving as PDF */ exportBonusReport() { const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; const month = parseInt(document.getElementById('calc-month-select').value); const year = parseInt(document.getElementById('calc-year-select').value); // Calculate next month for payout date const payoutMonth = month % 12; const payoutYear = month === 12 ? year + 1 : year; const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month); const employees = Object.keys(employeeDuties); if (employees.length === 0) { this.showToast('Keine Dienste für diesen Monat vorhanden.', 'error'); return; } // Escape HTML function const escapeHtml = (str) => { return String(str).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }; // Group duties by employee and weekday const employeeData = {}; for (const [name, duties] of Object.entries(employeeDuties)) { employeeData[name] = { duties: duties, byWeekday: { 0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: [] } }; duties.forEach(duty => { const dayOfWeek = duty.date.getDay(); const isQualifying = this.calculator.isQualifyingDay(duty.date); employeeData[name].byWeekday[dayOfWeek].push({ ...duty, isQual: isQualifying, dayType: this.calculator.getDayTypeLabel(duty.date) }); }); } // Build HTML report let html = ` Bonuszahlungen ${monthNames[month - 1]} ${year}
Tipp: Beim Drucken "Als PDF speichern" wählen für eine PDF-Datei.

Bonuszahlungen

Monat ${monthNames[month - 1]} ${year} mit Auszahlung Ende ${monthNames[payoutMonth]} ${payoutYear}

Für die im ${monthNames[month - 1]} ${year} geleisteten Bereitschaftsdienste ergeben sich folgende Bonuszahlungen:

`; let totalBonus = 0; const employeeNotes = []; // Compute via BonusCalculator (uses winning variant) const yearMonth = `${year}-${String(month).padStart(2, '0')}`; const vacationMap = {}; Object.keys(employeeDuties).forEach(n => { vacationMap[n] = this.storage.getVacationMode(n, yearMonth); }); const calcResults = this.calculator.calculateAllEmployees(employeeDuties, vacationMap); for (const [name, data] of Object.entries(employeeData)) { const calcRes = calcResults[name] || this.calculator.getEmptyResult(); const bonus = calcRes.totalBonus; const w = calcRes.winner; totalBonus += bonus; const safeName = escapeHtml(name); let note = ''; if (bonus === 0 || !w.eligible) { note = `${safeName} erreicht in keiner der drei Varianten einen positiven Bonus${calcRes.isVacation ? ' (Urlaubsmodus aktiv)' : ''} und erhält daher keine Bonuszahlung.`; } else { const c = calcRes.classified; note = `${safeName} erhält eine Bonuszahlung von ${this.calculator.formatCurrency(bonus)} nach Variante ${w.variantId}${calcRes.isVacation ? ' (Urlaubsmodus aktiv)' : ''}. Klassifiziert: Fr ${c.fr.toFixed(1)} / Sa ${c.sa.toFixed(1)} / So ${c.so.toFixed(1)} / Werktage ${c.weekday.toFixed(1)}.`; } employeeNotes.push(note); // Build table row html += ` `; const dayOrder = [1, 2, 3, 4, 5, 6, 0]; for (const dayIdx of dayOrder) { const dayDuties = data.byWeekday[dayIdx]; if (dayDuties.length === 0) { html += ``; } else { let cellContent = ''; dayDuties.forEach(duty => { const shareStr = duty.share === 0.5 ? '½' : ''; const tag = duty.isQual ? 'we-tag' : 'wt-tag'; cellContent += `${shareStr}X
`; }); html += ``; } } html += ` `; } html += `
Mitarbeiter Mo Di Mi Do Fr Sa So Bonus (€)
${safeName}${cellContent}${bonus > 0 ? this.calculator.formatCurrency(bonus) : '-'}

Gesamtsumme: ${this.calculator.formatCurrency(totalBonus)}

Erläuterungen zu den einzelnen Mitarbeitern:

`; employeeNotes.forEach(note => { html += `
${note}
\n`; }); html += `

Berechnungsregeln (NRW Psychiatrie 2011):

Erstellt am: ${new Date().toLocaleDateString('de-DE')} | Dienstplan-Pro - NRW Psychiatrie 2011

`; // Open in new window const reportWindow = window.open('', '_blank'); if (reportWindow) { reportWindow.document.write(html); reportWindow.document.close(); this.showToast('Bonus-Bericht wurde in einem neuen Fenster geöffnet.', 'success'); } else { this.showToast('Popup wurde blockiert. Bitte erlauben Sie Popups für diese Seite.', 'error'); } } /** * Import data from JSON file */ 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(); } /** * Update the API-key status line in Settings. */ refreshApiKeyStatus() { const el = document.getElementById('api-key-status'); if (!el) return; if (this.storage.getApiKey()) { el.textContent = 'API-Key gespeichert'; el.className = 'api-key-status-ok'; } else { el.textContent = 'Kein Key hinterlegt'; el.className = 'api-key-status-none'; } } setApiKeyFromPrompt() { const input = window.prompt('OpenRouter API-Key eingeben:', ''); if (input === null) return; const trimmed = input.trim(); if (!trimmed) return; this.storage.setApiKey(trimmed); this.refreshApiKeyStatus(); this.showToast('API-Key gespeichert.', 'success'); } clearApiKey() { if (!window.confirm('API-Key wirklich loeschen?')) return; this.storage.clearApiKey(); this.refreshApiKeyStatus(); this.showToast('API-Key geloescht.', 'info'); } /** * 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); } formatNumber(num) { return num.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 1 }); } getMonthName(monthIndex) { const names = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]; return names[monthIndex - 1]; } } // Initialize app when DOM is ready let app; document.addEventListener('DOMContentLoaded', () => { app = new DienstplanApp(); window.app = app; });