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] feb9c9f712 Address code review feedback: use constants for deduction values
Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com>
2025-12-12 12:11:07 +00:00

1453 lines
50 KiB
HTML

<!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 - cleaner, more professional format
const safeName = this.sanitizeName(name);
let note = '';
if (!thresholdReached) {
note = `<b>${safeName}</b> erreicht die Mindestschwelle nicht (${we_total.toFixed(1)} von ${CONFIG.THRESHOLD.toFixed(1)} WE-Einheiten) und erhält daher keine Bonuszahlung.`;
} else {
const paid_we = we_total - CONFIG.DEDUCTION;
let breakdown = [];
if (data.wt > 0) breakdown.push(`${data.wt.toFixed(1)} WT-Einheiten à ${CONFIG.RATE_WT}`);
if (paid_we > 0) breakdown.push(`${paid_we.toFixed(1)} WE-Einheiten à ${CONFIG.RATE_WE}`);
note = `<b>${safeName}</b> erhält eine Bonuszahlung von <span style="color: #28a745; font-weight: bold;">${this.formatCurrency(bonus)}</span>`;
if (breakdown.length > 0) {
note += ` (${breakdown.join(' + ')})`;
}
note += '.';
}
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>