1453 lines
50 KiB
HTML
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">←</button>
|
|
<div class="current-date-display" id="displayDate">01.11.2025</div>
|
|
<button class="nav-btn" onclick="app.nextDay()" title="Nächster Tag">→</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 ≥ 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 => ({
|
|
'<': '<', '>': '>', '&': '&', '"': '"', "'": '''
|
|
}[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>
|