This repository has been archived on 2026-06-28. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
Dienstplan/Dienstplan_Portable.html
copilot-swe-agent[bot] 635b986e2c Add Bonus-Bericht export function for formatted bonus reports
- Added new "Bonus-Bericht" button to both Dienstplan_Portable.html and webapp
- Creates a printable HTML report with:
  - Header with "Bonuszahlungen" title
  - Month and payout date information
  - Table showing duties per weekday with amounts per employee
  - Individual explanatory notes for each employee
  - Print/PDF save button
- Format matches user's requested output style from issue #20

Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com>
2025-12-11 08:52:09 +00:00

1450 lines
50 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dienstplan NRW - Kalender Edition</title>
<style>
:root {
--primary: #4472C4;
--primary-dark: #2952A3;
--bg-color: #f4f7f6;
--card-bg: #ffffff;
--text-color: #333;
--we-color: #d4edda;
--we-text: #155724;
--holiday-color: #fff3cd;
--holiday-text: #856404;
}
* {
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
.container {
max-width: 900px;
width: 100%;
background: var(--card-bg);
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
overflow: hidden;
margin-bottom: 20px;
}
header {
background-color: var(--primary);
color: white;
padding: 20px;
text-align: center;
}
h1, h2, h3 { margin: 0; }
h1 { font-size: 1.5rem; margin-bottom: 5px; }
.controls {
padding: 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.month-selector select {
padding: 8px;
font-size: 1rem;
border-radius: 6px;
border: 1px solid #ccc;
}
.tabs {
display: flex;
background: #eee;
border-radius: 8px;
padding: 4px;
}
.tab-btn {
border: none;
background: transparent;
padding: 8px 16px;
cursor: pointer;
border-radius: 6px;
font-weight: 600;
color: #666;
transition: all 0.2s;
}
.tab-btn.active {
background: white;
color: var(--primary);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.tab-btn:hover:not(.active) {
background: rgba(255,255,255,0.5);
}
.day-view {
padding: 30px;
text-align: center;
}
.date-navigator {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-bottom: 20px;
}
.nav-btn {
background: var(--primary);
color: white;
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
font-size: 1.2rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.nav-btn:hover { background: var(--primary-dark); }
.current-date-display {
font-size: 1.6rem;
font-weight: bold;
min-width: 280px;
}
.day-badge {
display: inline-block;
padding: 8px 18px;
border-radius: 20px;
background: #eee;
color: #666;
margin-top: 10px;
font-weight: 600;
font-size: 0.95rem;
}
.day-badge.qualifying {
background: var(--we-color);
color: var(--we-text);
}
.day-badge.holiday {
background: var(--holiday-color);
color: var(--holiday-text);
}
.holiday-name {
font-size: 0.9rem;
color: #666;
margin-top: 8px;
font-style: italic;
}
.entry-form {
background: #f9f9f9;
padding: 20px;
border-radius: 8px;
max-width: 500px;
margin: 0 auto;
border: 1px solid #eee;
}
.form-row {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
input[type="text"], select {
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
flex: 1;
}
input[type="text"]:focus, select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(68, 114, 196, 0.2);
}
.btn-add {
background: #28a745;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
width: 100%;
transition: background 0.2s;
}
.btn-add:hover { background: #218838; }
.duty-list {
margin-top: 20px;
max-width: 500px;
margin-left: auto;
margin-right: auto;
}
.duty-item {
background: white;
border: 1px solid #ddd;
padding: 12px 15px;
border-radius: 6px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-delete {
background: #dc3545;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
margin-left: 10px;
font-size: 0.9rem;
transition: background 0.2s;
}
.btn-delete:hover { background: #c82333; }
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th { background-color: #f8f9fa; font-weight: 600; }
.amount { font-family: monospace; font-weight: bold; }
.positive { color: #28a745; }
.neutral { color: #6c757d; }
.threshold-tag {
font-size: 0.85em;
padding: 3px 8px;
border-radius: 4px;
font-weight: 600;
}
.threshold-yes { background: #d4edda; color: #155724; }
.threshold-no { background: #f8d7da; color: #721c24; }
.view-section { display: none; }
.view-section.active { display: block; }
.info-box {
background: #e7f3ff;
border-left: 4px solid var(--primary);
padding: 15px;
margin: 20px;
border-radius: 0 8px 8px 0;
}
.info-box h4 {
margin: 0 0 8px 0;
color: var(--primary-dark);
}
.info-box p {
margin: 0;
font-size: 0.9rem;
color: #555;
}
.data-section {
padding: 20px;
}
.data-section button {
padding: 10px 20px;
margin: 5px;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
border: 1px solid #ddd;
background: white;
transition: all 0.2s;
}
.data-section button:hover {
background: #f8f9fa;
}
.data-section .btn-danger {
background: #dc3545;
color: white;
border: none;
}
.data-section .btn-danger:hover {
background: #c82333;
}
.summary-card {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color: white;
padding: 20px;
border-radius: 8px;
margin: 20px;
text-align: center;
}
.summary-card .total-label {
font-size: 0.9rem;
opacity: 0.9;
}
.summary-card .total-amount {
font-size: 2rem;
font-weight: bold;
margin-top: 5px;
}
@media (max-width: 600px) {
.controls {
flex-direction: column;
}
.current-date-display {
font-size: 1.2rem;
min-width: auto;
}
.form-row {
flex-direction: column;
}
th, td {
padding: 8px 4px;
font-size: 0.85rem;
}
}
@media print {
body {
background: white;
padding: 0;
}
.container {
box-shadow: none;
}
.controls, .entry-form, .btn-delete, .data-section {
display: none !important;
}
.view-section.active {
display: block !important;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Dienstplan NRW</h1>
<div style="opacity: 0.8; font-size: 0.9rem;">Variante 2 (Streng) - Kalender Edition</div>
</header>
<div class="controls">
<div class="month-selector">
<select id="selectMonth" onchange="app.changeMonth()"></select>
<select id="selectYear" onchange="app.changeMonth()"></select>
</div>
<div class="tabs">
<button class="tab-btn active" data-view="calendar" onclick="app.switchView('calendar', this)">Kalender</button>
<button class="tab-btn" data-view="results" onclick="app.switchView('results', this)">Auswertung</button>
<button class="tab-btn" data-view="data" onclick="app.switchView('data', this)">Daten</button>
</div>
</div>
<div id="view-calendar" class="view-section active">
<div class="day-view">
<div class="date-navigator">
<button class="nav-btn" onclick="app.prevDay()" title="Vorheriger Tag">&larr;</button>
<div class="current-date-display" id="displayDate">01.11.2025</div>
<button class="nav-btn" onclick="app.nextDay()" title="Nächster Tag">&rarr;</button>
</div>
<div id="dayStatusBadge" class="day-badge">Werktag</div>
<div id="holidayName" class="holiday-name"></div>
<div style="height: 25px;"></div>
<div class="entry-form">
<div class="form-row">
<input type="text" id="inputEmployee" placeholder="Mitarbeiter Name" list="employeeSuggestions" autocomplete="off">
<datalist id="employeeSuggestions"></datalist>
<select id="inputShare" style="max-width: 120px;">
<option value="1.0">1.0 (Voll)</option>
<option value="0.5">0.5 (Halb)</option>
</select>
</div>
<button class="btn-add" onclick="app.addDuty()">Dienst eintragen</button>
</div>
<div class="duty-list" id="dayDutyList"></div>
</div>
</div>
<div id="view-results" class="view-section" style="padding: 20px;">
<h2>Monatsauswertung</h2>
<div id="resultsMonthDisplay" style="color: #666; margin-bottom: 15px;"></div>
<div style="overflow-x: auto;">
<table id="resultsTable">
<thead>
<tr>
<th>Mitarbeiter</th>
<th>WT</th>
<th>WE (Fr)</th>
<th>WE (And.)</th>
<th>WE Ges.</th>
<th>Schwelle</th>
<th>Auszahlung</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div id="totalSummary" class="summary-card" style="display: none;">
<div class="total-label">Gesamtauszahlung</div>
<div class="total-amount" id="totalAmount">0,00 EUR</div>
</div>
<div class="info-box">
<h4>Berechnungsregeln (Variante 2 - Streng)</h4>
<p>
<strong>Schwelle:</strong> Gesamter Bonus wird nur gezahlt, wenn WE-Einheiten &ge; 2,0.<br>
<strong>Bei Erreichen:</strong> WT = 250 EUR/Einheit, WE = 450 EUR/Einheit (abzgl. 1,0 Einheit Abzug).<br>
<strong>Unter Schwelle:</strong> Keine Auszahlung (weder WT noch WE).<br>
<strong>WE-Tage:</strong> Fr, Sa, So, Feiertage und Vortage von Feiertagen.
</p>
</div>
</div>
<div id="view-data" class="view-section data-section">
<h3>Datenverwaltung</h3>
<p style="color: #666;">Daten werden automatisch im Browser gespeichert (localStorage).</p>
<div style="margin: 20px 0;">
<button onclick="app.exportCSV()" style="background: #28a745; color: white; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; font-weight: bold;">📊 Excel/CSV Export</button>
<button onclick="app.exportBonusReport()" style="background: #4472C4; color: white; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; font-weight: bold;">📝 Bonus-Bericht</button>
<button onclick="app.exportData()">Daten exportieren (JSON)</button>
<input type="file" id="importFile" accept=".json" onchange="app.importData(this)" style="display:none">
<button onclick="document.getElementById('importFile').click()">Daten importieren</button>
</div>
<p style="color: #666; font-size: 0.9rem; margin-top: 10px;">💡 <strong>Tipp:</strong> CSV-Dateien können direkt mit Excel, LibreOffice oder Google Sheets geöffnet werden. Der Bonus-Bericht öffnet sich in einem neuen Fenster zum Drucken.</p>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
<h4 style="color: #dc3545;">Gefahrenzone</h4>
<button class="btn-danger" onclick="app.clearData()">Alle Daten löschen</button>
</div>
</div>
</div>
<script>
// ==================== NRW HOLIDAYS 2024-2030 ====================
const NRW_HOLIDAYS = {
2024: [
{ date: "2024-01-01", name: "Neujahr" },
{ date: "2024-03-29", name: "Karfreitag" },
{ date: "2024-04-01", name: "Ostermontag" },
{ date: "2024-05-01", name: "Tag der Arbeit" },
{ date: "2024-05-09", name: "Christi Himmelfahrt" },
{ date: "2024-05-20", name: "Pfingstmontag" },
{ date: "2024-05-30", name: "Fronleichnam" },
{ date: "2024-10-03", name: "Tag der Deutschen Einheit" },
{ date: "2024-11-01", name: "Allerheiligen" },
{ date: "2024-12-25", name: "1. Weihnachtstag" },
{ date: "2024-12-26", name: "2. Weihnachtstag" }
],
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" }
]
};
// ==================== CONFIGURATION ====================
const CONFIG = {
RATE_WT: 250,
RATE_WE: 450,
THRESHOLD: 2.0,
DEDUCTION: 1.0,
TOLERANCE: 0.0001
};
const MONTHS = ["Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember"];
const WEEKDAYS = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
// ==================== DIENSTPLAN APP ====================
class DienstplanApp {
constructor() {
this.duties = [];
this.employees = [];
this.loadFromStorage();
const today = new Date();
this.currentDate = new Date(today.getFullYear(), today.getMonth(), today.getDate());
this.initUI();
this.updateView();
}
// --- Storage ---
loadFromStorage() {
try {
const savedDuties = localStorage.getItem('nrw_duties_v2');
const savedEmployees = localStorage.getItem('nrw_employees_v2');
if (savedDuties) this.duties = JSON.parse(savedDuties);
if (savedEmployees) this.employees = JSON.parse(savedEmployees);
} catch (e) {
console.warn('Laden fehlgeschlagen:', e);
}
}
save() {
try {
localStorage.setItem('nrw_duties_v2', JSON.stringify(this.duties));
localStorage.setItem('nrw_employees_v2', JSON.stringify([...new Set(this.employees)]));
} catch (e) {
console.warn('Speichern fehlgeschlagen:', e);
}
this.updateSuggestions();
}
// --- UI Initialization ---
initUI() {
const monthSelect = document.getElementById('selectMonth');
MONTHS.forEach((m, i) => {
monthSelect.add(new Option(m, i));
});
monthSelect.value = this.currentDate.getMonth();
const yearSelect = document.getElementById('selectYear');
for (let y = 2024; y <= 2030; y++) {
yearSelect.add(new Option(y, y));
}
yearSelect.value = this.currentDate.getFullYear();
document.getElementById('inputEmployee').addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.addDuty();
});
}
updateSuggestions() {
const list = document.getElementById('employeeSuggestions');
list.innerHTML = '';
[...new Set(this.employees)].forEach(emp => {
const opt = document.createElement('option');
opt.value = emp;
list.appendChild(opt);
});
}
// --- Navigation ---
changeMonth() {
const m = parseInt(document.getElementById('selectMonth').value);
const y = parseInt(document.getElementById('selectYear').value);
this.currentDate = new Date(y, m, 1);
this.updateView();
}
prevDay() {
this.currentDate.setDate(this.currentDate.getDate() - 1);
this.syncSelectors();
this.updateView();
}
nextDay() {
this.currentDate.setDate(this.currentDate.getDate() + 1);
this.syncSelectors();
this.updateView();
}
syncSelectors() {
document.getElementById('selectMonth').value = this.currentDate.getMonth();
document.getElementById('selectYear').value = this.currentDate.getFullYear();
}
switchView(viewName, btnElement) {
document.querySelectorAll('.view-section').forEach(el => el.classList.remove('active'));
document.getElementById('view-' + viewName).classList.add('active');
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
if (btnElement) btnElement.classList.add('active');
if (viewName === 'results') this.calcResults();
}
// --- Date Helpers ---
formatDateISO(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
isHoliday(date) {
const year = date.getFullYear();
const iso = this.formatDateISO(date);
if (!NRW_HOLIDAYS[year]) return false;
return NRW_HOLIDAYS[year].some(h => h.date === iso);
}
getHolidayName(date) {
const year = date.getFullYear();
const iso = this.formatDateISO(date);
if (!NRW_HOLIDAYS[year]) return null;
const holiday = NRW_HOLIDAYS[year].find(h => h.date === iso);
return holiday ? holiday.name : null;
}
isDayBeforeHoliday(date) {
const nextDay = new Date(date);
nextDay.setDate(date.getDate() + 1);
return this.isHoliday(nextDay);
}
getNextDayHolidayName(date) {
const nextDay = new Date(date);
nextDay.setDate(date.getDate() + 1);
return this.getHolidayName(nextDay);
}
isQualifyingDay(date) {
const day = date.getDay();
// Fr(5), Sa(6), So(0)
if (day === 5 || day === 6 || day === 0) return true;
if (this.isHoliday(date)) return true;
if (this.isDayBeforeHoliday(date)) return true;
return false;
}
isFriday(date) {
return date.getDay() === 5;
}
getDayTypeInfo(date) {
const day = date.getDay();
const holidayName = this.getHolidayName(date);
const nextDayHolidayName = this.getNextDayHolidayName(date);
if (holidayName) {
return { type: 'holiday', label: 'Feiertag', detail: holidayName };
}
if (this.isDayBeforeHoliday(date)) {
return { type: 'preHoliday', label: 'Vortag Feiertag', detail: `Tag vor ${nextDayHolidayName}` };
}
if (day === 5) return { type: 'friday', label: 'Freitag', detail: 'WE-Tag (Freitag)' };
if (day === 6) return { type: 'saturday', label: 'Samstag', detail: 'WE-Tag (Samstag)' };
if (day === 0) return { type: 'sunday', label: 'Sonntag', detail: 'WE-Tag (Sonntag)' };
return { type: 'weekday', label: WEEKDAYS[day], detail: 'Normaler Werktag (WT)' };
}
// --- Duties ---
getDailyDuties(isoDate) {
return this.duties.filter(d => d.date === isoDate);
}
sanitizeName(name) {
return name.replace(/[<>&"']/g, c => ({
'<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;'
}[c]));
}
addDuty() {
const nameInput = document.getElementById('inputEmployee');
const shareInput = document.getElementById('inputShare');
const name = nameInput.value.trim();
if (!name) {
alert('Bitte einen Mitarbeiternamen eingeben.');
return;
}
if (name.length > 100) {
alert('Name zu lang (max. 100 Zeichen).');
return;
}
const entry = {
id: Date.now() + Math.random(),
date: this.formatDateISO(this.currentDate),
name: name,
share: parseFloat(shareInput.value)
};
this.duties.push(entry);
if (!this.employees.includes(name)) {
this.employees.push(name);
}
this.save();
this.updateView();
nameInput.value = '';
nameInput.focus();
}
deleteDuty(id) {
this.duties = this.duties.filter(d => d.id !== id);
this.save();
this.updateView();
}
// --- View Update ---
updateView() {
const options = { weekday: 'long', year: 'numeric', month: '2-digit', day: '2-digit' };
document.getElementById('displayDate').textContent =
this.currentDate.toLocaleDateString('de-DE', options);
const dayInfo = this.getDayTypeInfo(this.currentDate);
const badge = document.getElementById('dayStatusBadge');
const holidayNameEl = document.getElementById('holidayName');
const isQual = this.isQualifyingDay(this.currentDate);
if (dayInfo.type === 'holiday') {
badge.textContent = 'Feiertag (WE-Tag)';
badge.className = 'day-badge holiday';
holidayNameEl.textContent = dayInfo.detail;
} else if (dayInfo.type === 'preHoliday') {
badge.textContent = 'Vortag Feiertag (WE-Tag)';
badge.className = 'day-badge qualifying';
holidayNameEl.textContent = dayInfo.detail;
} else if (isQual) {
badge.textContent = `${dayInfo.label} (WE-Tag)`;
badge.className = 'day-badge qualifying';
holidayNameEl.textContent = '';
} else {
badge.textContent = `${dayInfo.label} (WT)`;
badge.className = 'day-badge';
holidayNameEl.textContent = '';
}
const iso = this.formatDateISO(this.currentDate);
const listContainer = document.getElementById('dayDutyList');
listContainer.innerHTML = '';
const todaysDuties = this.getDailyDuties(iso);
if (todaysDuties.length === 0) {
listContainer.innerHTML = '<p style="color: #888; margin-top: 20px;">Keine Dienste an diesem Tag eingetragen.</p>';
} else {
todaysDuties.forEach(d => {
const div = document.createElement('div');
div.className = 'duty-item';
const safeName = this.sanitizeName(d.name);
div.innerHTML = `
<div><strong>${safeName}</strong> <span style="color:#666">(${d.share.toFixed(1)})</span></div>
<button class="btn-delete" onclick="app.deleteDuty(${d.id})">Löschen</button>
`;
listContainer.appendChild(div);
});
}
this.updateSuggestions();
}
// --- Calculation (Variante 2 Streng) ---
calcResults() {
const m = parseInt(document.getElementById('selectMonth').value);
const y = parseInt(document.getElementById('selectYear').value);
document.getElementById('resultsMonthDisplay').textContent = `${MONTHS[m]} ${y}`;
const tbody = document.querySelector('#resultsTable tbody');
tbody.innerHTML = '';
const monthDuties = this.duties.filter(d => {
const date = new Date(d.date);
return date.getMonth() === m && date.getFullYear() === y;
});
if (monthDuties.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color:#888; padding: 30px;">Keine Dienste in diesem Monat eingetragen.</td></tr>';
document.getElementById('totalSummary').style.display = 'none';
return;
}
// Group by employee
const stats = {};
monthDuties.forEach(d => {
if (!stats[d.name]) {
stats[d.name] = { wt: 0, we_fr: 0, we_other: 0 };
}
const date = new Date(d.date);
const isQual = this.isQualifyingDay(date);
const isFri = this.isFriday(date);
if (!isQual) {
stats[d.name].wt += d.share;
} else {
if (isFri) {
stats[d.name].we_fr += d.share;
} else {
stats[d.name].we_other += d.share;
}
}
});
let totalPayout = 0;
for (const [name, data] of Object.entries(stats)) {
const we_total = data.we_fr + data.we_other;
const thresholdReached = we_total >= (CONFIG.THRESHOLD - CONFIG.TOLERANCE);
let payout = 0;
if (thresholdReached) {
// WT payment
const wt_pay = data.wt * CONFIG.RATE_WT;
// WE payment with deduction (Friday first)
let deduct = CONFIG.DEDUCTION;
const deduct_fr = Math.min(deduct, data.we_fr);
const deduct_other = Math.max(0, deduct - deduct_fr);
const paid_fr = Math.max(0, data.we_fr - deduct_fr);
const paid_other = Math.max(0, data.we_other - deduct_other);
const we_pay = (paid_fr + paid_other) * CONFIG.RATE_WE;
payout = wt_pay + we_pay;
}
// If threshold not reached: payout stays 0 (no WT, no WE)
totalPayout += payout;
const safeName = this.sanitizeName(name);
const tr = document.createElement('tr');
tr.innerHTML = `
<td><strong>${safeName}</strong></td>
<td>${data.wt.toFixed(1)}</td>
<td>${data.we_fr.toFixed(1)}</td>
<td>${data.we_other.toFixed(1)}</td>
<td>${we_total.toFixed(1)}</td>
<td><span class="threshold-tag ${thresholdReached ? 'threshold-yes' : 'threshold-no'}">${thresholdReached ? 'JA' : 'NEIN'}</span></td>
<td class="amount ${payout > 0 ? 'positive' : 'neutral'}">${this.formatCurrency(payout)}</td>
`;
tbody.appendChild(tr);
}
document.getElementById('totalSummary').style.display = 'block';
document.getElementById('totalAmount').textContent = this.formatCurrency(totalPayout);
}
formatCurrency(amount) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
}
// --- Data Management ---
exportData() {
const exportObj = {
version: 2,
exported: new Date().toISOString(),
duties: this.duties,
employees: this.employees
};
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportObj, null, 2));
const downloadAnchor = document.createElement('a');
downloadAnchor.setAttribute("href", dataStr);
downloadAnchor.setAttribute("download", `dienstplan_backup_${new Date().toISOString().split('T')[0]}.json`);
document.body.appendChild(downloadAnchor);
downloadAnchor.click();
downloadAnchor.remove();
}
/**
* Export data as CSV (Excel-compatible) - Beginner-friendly format
* Exports two sheets: 1) All duties (Dienste) 2) Monthly summary (Auswertung)
*/
exportCSV() {
const m = parseInt(document.getElementById('selectMonth').value);
const y = parseInt(document.getElementById('selectYear').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) ===
csv += 'ALLE DIENSTE\n';
csv += 'Datum;Wochentag;Mitarbeiter;Anteil;Tagestyp\n';
// Sort duties by date
const sortedDuties = [...this.duties].sort((a, b) => a.date.localeCompare(b.date));
sortedDuties.forEach(duty => {
const date = new Date(duty.date);
const dayInfo = this.getDayTypeInfo(date);
const isQual = this.isQualifyingDay(date);
const dateStr = date.toLocaleDateString('de-DE');
const weekday = WEEKDAYS[date.getDay()];
const dayType = isQual ? 'WE-Tag' : 'Werktag (WT)';
csv += `${dateStr};${weekday};${escapeCSV(duty.name)};${duty.share.toFixed(1).replace('.', ',')};${dayType}\n`;
});
csv += '\n\n';
// === Sheet 2: Monatliche Auswertung ===
csv += `AUSWERTUNG ${MONTHS[m]} ${y}\n`;
csv += 'Mitarbeiter;WT (Einheiten);WE Freitag;WE Andere;WE Gesamt;Schwelle erreicht;Auszahlung (EUR)\n';
// Filter duties for selected month
const monthDuties = this.duties.filter(d => {
const date = new Date(d.date);
return date.getMonth() === m && date.getFullYear() === y;
});
// Group by employee
const stats = {};
monthDuties.forEach(d => {
if (!stats[d.name]) {
stats[d.name] = { wt: 0, we_fr: 0, we_other: 0 };
}
const date = new Date(d.date);
const isQual = this.isQualifyingDay(date);
const isFri = this.isFriday(date);
if (!isQual) {
stats[d.name].wt += d.share;
} else {
if (isFri) {
stats[d.name].we_fr += d.share;
} else {
stats[d.name].we_other += d.share;
}
}
});
let totalPayout = 0;
for (const [name, data] of Object.entries(stats)) {
const we_total = data.we_fr + data.we_other;
const thresholdReached = we_total >= (CONFIG.THRESHOLD - CONFIG.TOLERANCE);
let payout = 0;
if (thresholdReached) {
const wt_pay = data.wt * CONFIG.RATE_WT;
let deduct = CONFIG.DEDUCTION;
const deduct_fr = Math.min(deduct, data.we_fr);
const deduct_other = Math.max(0, deduct - deduct_fr);
const paid_fr = Math.max(0, data.we_fr - deduct_fr);
const paid_other = Math.max(0, data.we_other - deduct_other);
const we_pay = (paid_fr + paid_other) * CONFIG.RATE_WE;
payout = wt_pay + we_pay;
}
totalPayout += payout;
const threshold = thresholdReached ? 'JA' : 'NEIN';
csv += `${escapeCSV(name)};${data.wt.toFixed(1).replace('.', ',')};${data.we_fr.toFixed(1).replace('.', ',')};${data.we_other.toFixed(1).replace('.', ',')};${we_total.toFixed(1).replace('.', ',')};${threshold};${payout.toFixed(2).replace('.', ',')}\n`;
}
csv += `\nGESAMT;;;;;;${totalPayout.toFixed(2).replace('.', ',')}\n`;
csv += '\n\n';
csv += 'LEGENDE\n';
csv += 'WT;Werktag (Montag-Donnerstag ohne Feiertag/Vortag)\n';
csv += 'WE-Tag;"Freitag, Samstag, Sonntag, Feiertag oder Tag vor Feiertag"\n';
csv += 'Schwelle;"Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich"\n';
csv += 'Sätze;"WT = 250 EUR/Einheit, WE = 450 EUR/Einheit (abzgl. 1,0 Abzug)"\n';
// Download CSV file
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const downloadAnchor = document.createElement('a');
downloadAnchor.href = url;
downloadAnchor.download = `Dienstplan_${y}_${String(m + 1).padStart(2, '0')}.csv`;
document.body.appendChild(downloadAnchor);
downloadAnchor.click();
downloadAnchor.remove();
URL.revokeObjectURL(url);
}
/**
* Export a formal bonus report in HTML format
* Opens in a new window for printing or saving as PDF
*/
exportBonusReport() {
const m = parseInt(document.getElementById('selectMonth').value);
const y = parseInt(document.getElementById('selectYear').value);
// Calculate next month for payout date
const payoutMonth = (m + 1) % 12;
const payoutYear = m === 11 ? y + 1 : y;
// Filter duties for selected month
const monthDuties = this.duties.filter(d => {
const date = new Date(d.date);
return date.getMonth() === m && date.getFullYear() === y;
});
if (monthDuties.length === 0) {
alert('Keine Dienste für diesen Monat vorhanden.');
return;
}
// Group duties by employee and by weekday
const employeeData = {};
monthDuties.forEach(d => {
if (!employeeData[d.name]) {
employeeData[d.name] = {
duties: [],
byWeekday: { 0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: [] }, // Sun=0 to Sat=6
wt: 0,
we_fr: 0,
we_other: 0
};
}
const date = new Date(d.date);
const weekday = date.getDay();
const isQual = this.isQualifyingDay(date);
const isFri = this.isFriday(date);
employeeData[d.name].duties.push(d);
employeeData[d.name].byWeekday[weekday].push({
...d,
date: date,
isQual: isQual,
dayInfo: this.getDayTypeInfo(date)
});
if (!isQual) {
employeeData[d.name].wt += d.share;
} else if (isFri) {
employeeData[d.name].we_fr += d.share;
} else {
employeeData[d.name].we_other += d.share;
}
});
// Build HTML report
let html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Bonuszahlungen ${MONTHS[m]} ${y}</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 40px;
color: #333;
line-height: 1.6;
}
h3 {
color: #4472C4;
border-bottom: 2px solid #4472C4;
padding-bottom: 10px;
}
h5 {
color: #666;
margin-bottom: 20px;
}
table {
border-collapse: collapse;
width: 100%;
margin: 20px 0;
}
th, td {
border: 1px solid #ddd;
padding: 10px 8px;
text-align: center;
}
th {
background-color: #4472C4;
color: white;
font-weight: bold;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
.employee-name {
text-align: left;
font-weight: bold;
}
.bonus-amount {
font-weight: bold;
color: #28a745;
}
.no-bonus {
color: #dc3545;
}
.duty-cell {
font-size: 0.85em;
}
.duty-cell .we-tag {
background: #d4edda;
color: #155724;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
.duty-cell .wt-tag {
background: #e7e7e7;
color: #666;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
.employee-note {
margin: 10px 0;
padding: 10px;
background: #f8f9fa;
border-left: 3px solid #4472C4;
}
.employee-note b {
color: #4472C4;
}
.summary {
margin-top: 30px;
padding: 20px;
background: #e7f3ff;
border-radius: 8px;
}
.total {
font-size: 1.2em;
font-weight: bold;
color: #4472C4;
}
@media print {
body { margin: 20px; }
.no-print { display: none; }
}
</style>
</head>
<body>
<div class="no-print" style="margin-bottom: 20px; padding: 10px; background: #fff3cd; border-radius: 5px;">
<button onclick="window.print()" style="padding: 8px 16px; background: #4472C4; color: white; border: none; border-radius: 4px; cursor: pointer; margin-right: 10px;">🖨️ Drucken / Als PDF speichern</button>
<span style="color: #666;">Tipp: Beim Drucken "Als PDF speichern" wählen für eine PDF-Datei.</span>
</div>
<h3>Bonuszahlungen</h3>
<h5>Monat ${MONTHS[m]} ${y} mit Auszahlung Ende ${MONTHS[payoutMonth]} ${payoutYear}</h5>
<p>Für die im ${MONTHS[m]} ${y} geleisteten Bereitschaftsdienste ergeben sich folgende Bonuszahlungen:</p>
<table>
<thead>
<tr>
<th>Mitarbeiter</th>
<th>Mo</th>
<th>Di</th>
<th>Mi</th>
<th>Do</th>
<th>Fr</th>
<th>Sa</th>
<th>So</th>
<th>Bonus (€)</th>
</tr>
</thead>
<tbody>`;
let totalBonus = 0;
const employeeNotes = [];
for (const [name, data] of Object.entries(employeeData)) {
const we_total = data.we_fr + data.we_other;
const thresholdReached = we_total >= (CONFIG.THRESHOLD - CONFIG.TOLERANCE);
let bonus = 0;
let deductedFrom = '';
if (thresholdReached) {
const wt_pay = data.wt * CONFIG.RATE_WT;
let deduct = CONFIG.DEDUCTION;
const deduct_fr = Math.min(deduct, data.we_fr);
const deduct_other = Math.max(0, deduct - deduct_fr);
const paid_fr = Math.max(0, data.we_fr - deduct_fr);
const paid_other = Math.max(0, data.we_other - deduct_other);
const we_pay = (paid_fr + paid_other) * CONFIG.RATE_WE;
bonus = wt_pay + we_pay;
// Determine what was deducted for the note
if (deduct_fr > 0 && deduct_other > 0) {
deductedFrom = 'Freitag und weiterer WE-Tag';
} else if (deduct_fr > 0) {
deductedFrom = 'Freitag';
} else {
deductedFrom = 'WE-Tag (Sa/So/Feiertag)';
}
}
totalBonus += bonus;
// Generate note for this employee
const safeName = this.sanitizeName(name);
let note = `<b>${safeName}</b>: `;
if (!thresholdReached) {
note += `Erreicht das Bonussystem nicht (nur ${we_total.toFixed(1)} WE-Einheiten, mind. 2,0 erforderlich).`;
} else {
const details = [];
if (data.wt > 0) details.push(`${data.wt.toFixed(1)} WT × 250€`);
if (data.we_fr > 0 || data.we_other > 0) {
const paid_we = we_total - 1.0;
details.push(`${paid_we.toFixed(1)} WE × 450€ (abzgl. 1,0 Abzug von ${deductedFrom})`);
}
note += `Erhält ${this.formatCurrency(bonus)}. ${details.join(', ')}.`;
}
employeeNotes.push(note);
// Build table row
html += `
<tr>
<td class="employee-name">${safeName}</td>`;
// Days: Mo(1), Di(2), Mi(3), Do(4), Fr(5), Sa(6), So(0)
const dayOrder = [1, 2, 3, 4, 5, 6, 0];
for (const dayIdx of dayOrder) {
const dayDuties = data.byWeekday[dayIdx];
if (dayDuties.length === 0) {
html += `<td></td>`;
} else {
let cellContent = '';
dayDuties.forEach(duty => {
const dateStr = duty.date.getDate() + '.';
const shareStr = duty.share === 0.5 ? '½' : '';
const amountStr = duty.isQual ? `${Math.round(duty.share * CONFIG.RATE_WE)}` : `${Math.round(duty.share * CONFIG.RATE_WT)}`;
const tag = duty.isQual ? 'we-tag' : 'wt-tag';
const extraInfo = duty.dayInfo.type === 'holiday' ? ' (Feiertag)' :
duty.dayInfo.type === 'preHoliday' ? ' (Vor Feiertag)' : '';
cellContent += `<span class="${tag}">${shareStr}X${extraInfo}</span><br><small>${amountStr}</small><br>`;
});
html += `<td class="duty-cell">${cellContent}</td>`;
}
}
html += `
<td class="${bonus > 0 ? 'bonus-amount' : 'no-bonus'}">${bonus > 0 ? this.formatCurrency(bonus) : '-'}</td>
</tr>`;
}
html += `
</tbody>
</table>
<div class="summary">
<p class="total">Gesamtsumme: ${this.formatCurrency(totalBonus)}</p>
</div>
<h4>Erläuterungen zu den einzelnen Mitarbeitern:</h4>
`;
employeeNotes.forEach(note => {
html += `<div class="employee-note">${note}</div>\n`;
});
html += `
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #ddd;">
<p><strong>Berechnungsregeln (Variante 2 - Streng):</strong></p>
<ul>
<li><strong>WE-Tage:</strong> Freitag, Samstag, Sonntag, Feiertage und Tage vor Feiertagen</li>
<li><strong>Schwelle:</strong> Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich</li>
<li><strong>Vergütung bei Erreichen der Schwelle:</strong>
<ul>
<li>Werktage (WT): 250 € pro Einheit</li>
<li>WE-Tage: 450 € pro Einheit (abzüglich 1,0 Einheit Abzug, Freitag zuerst)</li>
</ul>
</li>
<li><strong>Unter Schwelle:</strong> Keine Bonuszahlung (weder WT noch WE)</li>
</ul>
</div>
<p style="margin-top: 30px; color: #666; font-size: 0.9em;">
Erstellt am: ${new Date().toLocaleDateString('de-DE')} | Dienstplan NRW (Variante 2 - Streng)
</p>
</body>
</html>`;
// Open in new window
const reportWindow = window.open('', '_blank');
if (reportWindow) {
reportWindow.document.write(html);
reportWindow.document.close();
} else {
alert('Popup wurde blockiert. Bitte erlauben Sie Popups für diese Seite.');
}
}
importData(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
// Support both old format (array) and new format (object with version)
if (Array.isArray(data)) {
this.duties = data;
const empSet = new Set(this.employees);
data.forEach(d => empSet.add(d.name));
this.employees = [...empSet];
} else if (data.duties) {
this.duties = data.duties;
this.employees = data.employees || [];
const empSet = new Set(this.employees);
this.duties.forEach(d => empSet.add(d.name));
this.employees = [...empSet];
} else {
throw new Error('Unbekanntes Datenformat');
}
this.save();
this.updateView();
alert('Import erfolgreich! ' + this.duties.length + ' Dienste geladen.');
} catch (err) {
alert('Fehler beim Importieren: ' + err.message);
}
};
reader.readAsText(file);
input.value = '';
}
clearData() {
if (confirm('Wirklich ALLE Daten unwiderruflich löschen?')) {
this.duties = [];
this.employees = [];
localStorage.removeItem('nrw_duties_v2');
localStorage.removeItem('nrw_employees_v2');
this.updateView();
alert('Alle Daten wurden gelöscht.');
}
}
}
// Initialize app
const app = new DienstplanApp();
</script>
</body>
</html>