1345 lines
46 KiB
HTML
1345 lines
46 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 - Bonus-Berechnung</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
}
|
|
|
|
.container {
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
background: white;
|
|
border-radius: 20px;
|
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
overflow: hidden;
|
|
}
|
|
|
|
header {
|
|
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
|
|
color: white;
|
|
padding: 25px 30px;
|
|
text-align: center;
|
|
}
|
|
|
|
header h1 {
|
|
font-size: 1.8em;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
header p {
|
|
opacity: 0.8;
|
|
font-size: 0.95em;
|
|
}
|
|
|
|
.steps-indicator {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 25px 20px;
|
|
background: #f7fafc;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.step-item {
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 0 10px;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.step-number {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: #cbd5e0;
|
|
color: white;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: bold;
|
|
margin-right: 10px;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.step-item.active .step-number {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
transform: scale(1.1);
|
|
}
|
|
|
|
.step-item.completed .step-number {
|
|
background: #48bb78;
|
|
}
|
|
|
|
.step-label {
|
|
font-size: 0.9em;
|
|
color: #718096;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.step-item.active .step-label {
|
|
color: #2d3748;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.step-connector {
|
|
width: 40px;
|
|
height: 2px;
|
|
background: #cbd5e0;
|
|
margin: 0 5px;
|
|
}
|
|
|
|
.content {
|
|
padding: 30px;
|
|
min-height: 400px;
|
|
}
|
|
|
|
.step-content {
|
|
display: none;
|
|
}
|
|
|
|
.step-content.active {
|
|
display: block;
|
|
animation: fadeIn 0.3s ease;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
font-weight: 600;
|
|
color: #2d3748;
|
|
}
|
|
|
|
input, select {
|
|
width: 100%;
|
|
padding: 12px 15px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
font-size: 1em;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
input:focus, select:focus {
|
|
outline: none;
|
|
border-color: #667eea;
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
|
}
|
|
|
|
.btn {
|
|
padding: 12px 30px;
|
|
border: none;
|
|
border-radius: 10px;
|
|
font-size: 1em;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
margin: 5px;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #e2e8f0;
|
|
color: #4a5568;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: #cbd5e0;
|
|
}
|
|
|
|
.btn-success {
|
|
background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
|
|
color: white;
|
|
}
|
|
|
|
.btn-danger {
|
|
background: #fc8181;
|
|
color: white;
|
|
padding: 8px 15px;
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
.btn-danger:hover {
|
|
background: #f56565;
|
|
}
|
|
|
|
.employee-list {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.employee-tag {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
padding: 10px 15px;
|
|
border-radius: 25px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.employee-tag .remove {
|
|
background: rgba(255,255,255,0.3);
|
|
border: none;
|
|
color: white;
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1.2em;
|
|
line-height: 1;
|
|
}
|
|
|
|
.employee-tag .remove:hover {
|
|
background: rgba(255,255,255,0.5);
|
|
}
|
|
|
|
.month-selector {
|
|
display: flex;
|
|
gap: 15px;
|
|
margin-bottom: 25px;
|
|
}
|
|
|
|
.month-selector select {
|
|
flex: 1;
|
|
}
|
|
|
|
.duty-entry {
|
|
background: #f7fafc;
|
|
border-radius: 15px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.duty-entry h3 {
|
|
color: #2d3748;
|
|
margin-bottom: 15px;
|
|
font-size: 1.1em;
|
|
}
|
|
|
|
.duty-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 120px 50px;
|
|
gap: 10px;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.duty-list {
|
|
margin-top: 25px;
|
|
}
|
|
|
|
.duty-item {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 100px 80px;
|
|
gap: 10px;
|
|
padding: 12px 15px;
|
|
background: white;
|
|
border-radius: 10px;
|
|
margin-bottom: 8px;
|
|
border-left: 4px solid #cbd5e0;
|
|
align-items: center;
|
|
}
|
|
|
|
.duty-item.qualifying {
|
|
border-left-color: #48bb78;
|
|
background: linear-gradient(90deg, rgba(72, 187, 120, 0.1) 0%, white 50%);
|
|
}
|
|
|
|
.duty-item .day-type {
|
|
font-size: 0.85em;
|
|
color: #718096;
|
|
}
|
|
|
|
.duty-item.qualifying .day-type {
|
|
color: #2f855a;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.results-container {
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.result-card {
|
|
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
|
|
border-radius: 15px;
|
|
padding: 25px;
|
|
margin-bottom: 20px;
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.result-card h3 {
|
|
color: #2d3748;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 10px;
|
|
border-bottom: 2px solid #e2e8f0;
|
|
}
|
|
|
|
.result-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 15px;
|
|
}
|
|
|
|
.result-item {
|
|
background: white;
|
|
padding: 15px;
|
|
border-radius: 10px;
|
|
text-align: center;
|
|
}
|
|
|
|
.result-item .label {
|
|
font-size: 0.85em;
|
|
color: #718096;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.result-item .value {
|
|
font-size: 1.4em;
|
|
font-weight: 700;
|
|
color: #2d3748;
|
|
}
|
|
|
|
.result-item.highlight {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
}
|
|
|
|
.result-item.highlight .label,
|
|
.result-item.highlight .value {
|
|
color: white;
|
|
}
|
|
|
|
.result-item.success {
|
|
background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
|
|
}
|
|
|
|
.result-item.success .label,
|
|
.result-item.success .value {
|
|
color: white;
|
|
}
|
|
|
|
.result-item.warning {
|
|
background: linear-gradient(135deg, #ed8936 0%, #dd6b20 100%);
|
|
}
|
|
|
|
.result-item.warning .label,
|
|
.result-item.warning .value {
|
|
color: white;
|
|
}
|
|
|
|
.threshold-badge {
|
|
display: inline-block;
|
|
padding: 5px 15px;
|
|
border-radius: 20px;
|
|
font-size: 0.9em;
|
|
font-weight: 600;
|
|
margin-left: 10px;
|
|
}
|
|
|
|
.threshold-badge.reached {
|
|
background: #c6f6d5;
|
|
color: #2f855a;
|
|
}
|
|
|
|
.threshold-badge.not-reached {
|
|
background: #fed7d7;
|
|
color: #c53030;
|
|
}
|
|
|
|
.total-summary {
|
|
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
|
|
color: white;
|
|
padding: 25px;
|
|
border-radius: 15px;
|
|
margin-top: 25px;
|
|
text-align: center;
|
|
}
|
|
|
|
.total-summary h2 {
|
|
font-size: 1.2em;
|
|
opacity: 0.9;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.total-summary .amount {
|
|
font-size: 3em;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.info-box {
|
|
background: #ebf8ff;
|
|
border-left: 4px solid #4299e1;
|
|
padding: 15px 20px;
|
|
border-radius: 0 10px 10px 0;
|
|
margin: 20px 0;
|
|
color: #2b6cb0;
|
|
}
|
|
|
|
.info-box h4 {
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.navigation {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-top: 30px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #718096;
|
|
}
|
|
|
|
.empty-state svg {
|
|
width: 80px;
|
|
height: 80px;
|
|
margin-bottom: 15px;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.calendar-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr);
|
|
gap: 5px;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.calendar-header {
|
|
text-align: center;
|
|
font-weight: 600;
|
|
color: #4a5568;
|
|
padding: 8px;
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
.calendar-day {
|
|
text-align: center;
|
|
padding: 10px 5px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-size: 0.9em;
|
|
border: 2px solid transparent;
|
|
}
|
|
|
|
.calendar-day:hover {
|
|
background: #edf2f7;
|
|
}
|
|
|
|
.calendar-day.empty {
|
|
cursor: default;
|
|
}
|
|
|
|
.calendar-day.empty:hover {
|
|
background: transparent;
|
|
}
|
|
|
|
.calendar-day.qualifying {
|
|
background: #c6f6d5;
|
|
color: #2f855a;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.calendar-day.selected {
|
|
border-color: #667eea;
|
|
background: #ebf4ff;
|
|
}
|
|
|
|
.calendar-day.has-duty {
|
|
position: relative;
|
|
}
|
|
|
|
.calendar-day.has-duty::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: 3px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: #667eea;
|
|
}
|
|
|
|
.legend {
|
|
display: flex;
|
|
gap: 20px;
|
|
margin-top: 15px;
|
|
font-size: 0.85em;
|
|
color: #718096;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
|
|
.legend-color {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.legend-color.qualifying {
|
|
background: #c6f6d5;
|
|
}
|
|
|
|
.legend-color.selected {
|
|
border: 2px solid #667eea;
|
|
background: #ebf4ff;
|
|
}
|
|
|
|
.print-btn {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
width: 60px;
|
|
height: 60px;
|
|
border-radius: 50%;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
border: none;
|
|
cursor: pointer;
|
|
box-shadow: 0 5px 20px rgba(0,0,0,0.3);
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1.5em;
|
|
}
|
|
|
|
.print-btn:hover {
|
|
transform: scale(1.1);
|
|
}
|
|
|
|
@media print {
|
|
body {
|
|
background: white;
|
|
padding: 0;
|
|
}
|
|
.container {
|
|
box-shadow: none;
|
|
}
|
|
.steps-indicator, .navigation, .print-btn {
|
|
display: none !important;
|
|
}
|
|
.step-content {
|
|
display: block !important;
|
|
}
|
|
.step-content:not(.results) {
|
|
display: none !important;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.duty-row, .duty-item {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
.month-selector {
|
|
flex-direction: column;
|
|
}
|
|
.step-label {
|
|
display: none;
|
|
}
|
|
.result-grid {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1>Dienstplan NRW</h1>
|
|
<p>Bonus-Berechnung nach Variante 2 (streng)</p>
|
|
</header>
|
|
|
|
<div class="steps-indicator">
|
|
<div class="step-item active" data-step="1">
|
|
<div class="step-number">1</div>
|
|
<span class="step-label">Mitarbeiter</span>
|
|
</div>
|
|
<div class="step-connector"></div>
|
|
<div class="step-item" data-step="2">
|
|
<div class="step-number">2</div>
|
|
<span class="step-label">Dienste</span>
|
|
</div>
|
|
<div class="step-connector"></div>
|
|
<div class="step-item" data-step="3">
|
|
<div class="step-number">3</div>
|
|
<span class="step-label">Ergebnis</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<!-- Step 1: Mitarbeiter -->
|
|
<div class="step-content active" id="step1">
|
|
<h2>Schritt 1: Mitarbeiter eingeben</h2>
|
|
<p style="color: #718096; margin: 10px 0 25px;">Geben Sie alle Mitarbeiter ein, die in diesem Monat Dienste haben.</p>
|
|
|
|
<div class="month-selector">
|
|
<div class="form-group" style="margin: 0; flex: 1;">
|
|
<label for="month">Monat</label>
|
|
<select id="month"></select>
|
|
</div>
|
|
<div class="form-group" style="margin: 0; flex: 1;">
|
|
<label for="year">Jahr</label>
|
|
<select id="year"></select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="employeeName">Mitarbeiter hinzufügen</label>
|
|
<div style="display: flex; gap: 10px;">
|
|
<input type="text" id="employeeName" placeholder="Name eingeben..." style="flex: 1;">
|
|
<button class="btn btn-primary" onclick="addEmployee()">Hinzufügen</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="employee-list" id="employeeList"></div>
|
|
|
|
<div class="info-box" style="margin-top: 25px;">
|
|
<h4>Hinweis</h4>
|
|
<p>Sie können Mitarbeiter jederzeit hinzufügen oder entfernen. Die Daten werden lokal im Browser gespeichert.</p>
|
|
</div>
|
|
|
|
<div class="navigation">
|
|
<div></div>
|
|
<button class="btn btn-primary" onclick="goToStep(2)">Weiter zu Dienste</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Dienste -->
|
|
<div class="step-content" id="step2">
|
|
<h2>Schritt 2: Dienste eintragen</h2>
|
|
<p style="color: #718096; margin: 10px 0 25px;">Tragen Sie die Dienste für <span id="monthYearDisplay"></span> ein.</p>
|
|
|
|
<div class="duty-entry">
|
|
<h3>Neuen Dienst hinzufügen</h3>
|
|
<div class="duty-row">
|
|
<div class="form-group" style="margin: 0;">
|
|
<label for="dutyEmployee">Mitarbeiter</label>
|
|
<select id="dutyEmployee"></select>
|
|
</div>
|
|
<div class="form-group" style="margin: 0;">
|
|
<label for="dutyDate">Datum</label>
|
|
<input type="date" id="dutyDate">
|
|
</div>
|
|
<div class="form-group" style="margin: 0;">
|
|
<label for="dutyShare">Anteil</label>
|
|
<select id="dutyShare">
|
|
<option value="1.0">1,0</option>
|
|
<option value="0.5">0,5</option>
|
|
</select>
|
|
</div>
|
|
<div style="padding-top: 25px;">
|
|
<button class="btn btn-success" onclick="addDuty()">+</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h3>Kalenderübersicht</h3>
|
|
<div id="calendarContainer"></div>
|
|
<div class="legend">
|
|
<div class="legend-item">
|
|
<div class="legend-color qualifying"></div>
|
|
<span>WE-Tag (Bonus-Tag)</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color selected"></div>
|
|
<span>Dienst eingetragen</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="duty-list">
|
|
<h3 style="margin-bottom: 15px;">Eingetragene Dienste (<span id="dutyCount">0</span>)</h3>
|
|
<div id="dutyListContainer"></div>
|
|
</div>
|
|
|
|
<div class="navigation">
|
|
<button class="btn btn-secondary" onclick="goToStep(1)">Zurück</button>
|
|
<button class="btn btn-primary" onclick="calculateAndShowResults()">Berechnen</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: Ergebnis -->
|
|
<div class="step-content results" id="step3">
|
|
<h2>Schritt 3: Ergebnis</h2>
|
|
<p style="color: #718096; margin: 10px 0 25px;">Bonus-Berechnung für <span id="resultMonthYear"></span></p>
|
|
|
|
<div id="resultsContainer"></div>
|
|
|
|
<div class="navigation">
|
|
<button class="btn btn-secondary" onclick="goToStep(2)">Zurück</button>
|
|
<button class="btn btn-primary" onclick="window.print()">Drucken</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="print-btn" onclick="window.print()" title="Drucken">
|
|
<span>🖨</span>
|
|
</button>
|
|
|
|
<script>
|
|
// ==================== HOLIDAYS ====================
|
|
const NRW_HOLIDAYS = {
|
|
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' }
|
|
]
|
|
};
|
|
|
|
// ==================== CONSTANTS ====================
|
|
const RATE_NORMAL = 250;
|
|
const RATE_WEEKEND = 450;
|
|
const MIN_QUALIFYING_DAYS = 2.0;
|
|
const DEDUCTION = 1.0;
|
|
|
|
const MONTHS = [
|
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
|
|
];
|
|
|
|
// ==================== STATE ====================
|
|
let employees = [];
|
|
let duties = [];
|
|
let currentStep = 1;
|
|
let selectedMonth = new Date().getMonth();
|
|
let selectedYear = new Date().getFullYear();
|
|
|
|
// ==================== HOLIDAY FUNCTIONS ====================
|
|
function formatDate(date) {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function isHoliday(date) {
|
|
const year = date.getFullYear();
|
|
const dateStr = formatDate(date);
|
|
if (!NRW_HOLIDAYS[year]) return false;
|
|
return NRW_HOLIDAYS[year].some(h => h.date === dateStr);
|
|
}
|
|
|
|
function isDayBeforeHoliday(date) {
|
|
const nextDay = new Date(date);
|
|
nextDay.setDate(nextDay.getDate() + 1);
|
|
return isHoliday(nextDay);
|
|
}
|
|
|
|
function getHolidayName(date) {
|
|
const year = date.getFullYear();
|
|
const dateStr = formatDate(date);
|
|
if (!NRW_HOLIDAYS[year]) return null;
|
|
const holiday = NRW_HOLIDAYS[year].find(h => h.date === dateStr);
|
|
return holiday ? holiday.name : null;
|
|
}
|
|
|
|
function isQualifyingDay(date) {
|
|
const dayOfWeek = date.getDay();
|
|
const isWeekend = dayOfWeek === 5 || dayOfWeek === 6 || dayOfWeek === 0;
|
|
return isWeekend || isHoliday(date) || isDayBeforeHoliday(date);
|
|
}
|
|
|
|
function getDayTypeLabel(date) {
|
|
const dayOfWeek = date.getDay();
|
|
const holidayName = getHolidayName(date);
|
|
|
|
if (holidayName) return `Feiertag (${holidayName})`;
|
|
if (isDayBeforeHoliday(date)) return 'Tag vor Feiertag';
|
|
if (dayOfWeek === 5) return 'Freitag';
|
|
if (dayOfWeek === 6) return 'Samstag';
|
|
if (dayOfWeek === 0) return 'Sonntag';
|
|
|
|
const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
|
|
return days[dayOfWeek];
|
|
}
|
|
|
|
// ==================== CALCULATION ====================
|
|
function calculateBonus(employeeDuties) {
|
|
if (!employeeDuties || employeeDuties.length === 0) {
|
|
return {
|
|
normalDays: 0,
|
|
qualifyingDaysFriday: 0,
|
|
qualifyingDaysOther: 0,
|
|
qualifyingDays: 0,
|
|
thresholdReached: false,
|
|
deduction: 0,
|
|
normalDaysPaid: 0,
|
|
qualifyingDaysPaid: 0,
|
|
bonusNormalDays: 0,
|
|
bonusQualifyingDays: 0,
|
|
totalBonus: 0,
|
|
details: []
|
|
};
|
|
}
|
|
|
|
let qualifyingDaysFriday = 0;
|
|
let qualifyingDaysOther = 0;
|
|
let normalDays = 0;
|
|
const details = [];
|
|
|
|
employeeDuties.forEach(duty => {
|
|
const date = new Date(duty.date);
|
|
const qualifying = isQualifyingDay(date);
|
|
const isFriday = date.getDay() === 5;
|
|
const dayType = getDayTypeLabel(date);
|
|
|
|
if (qualifying) {
|
|
if (isFriday) {
|
|
qualifyingDaysFriday += duty.share;
|
|
} else {
|
|
qualifyingDaysOther += duty.share;
|
|
}
|
|
} else {
|
|
normalDays += duty.share;
|
|
}
|
|
|
|
details.push({
|
|
date: date,
|
|
share: duty.share,
|
|
isQualifying: qualifying,
|
|
dayType: dayType
|
|
});
|
|
});
|
|
|
|
const qualifyingDays = qualifyingDaysFriday + qualifyingDaysOther;
|
|
const thresholdReached = qualifyingDays >= MIN_QUALIFYING_DAYS - 0.0001;
|
|
|
|
let bonus = 0;
|
|
let normalDaysPaid = 0;
|
|
let qualifyingDaysPaid = 0;
|
|
let deduction = 0;
|
|
|
|
if (thresholdReached) {
|
|
deduction = DEDUCTION;
|
|
const deductionFromFriday = Math.min(deduction, qualifyingDaysFriday);
|
|
const deductionFromOther = Math.max(0, deduction - deductionFromFriday);
|
|
|
|
const qualifyingDaysFridayPaid = Math.max(0, qualifyingDaysFriday - deductionFromFriday);
|
|
const qualifyingDaysOtherPaid = Math.max(0, qualifyingDaysOther - deductionFromOther);
|
|
|
|
qualifyingDaysPaid = qualifyingDaysFridayPaid + qualifyingDaysOtherPaid;
|
|
normalDaysPaid = normalDays;
|
|
|
|
bonus = (normalDaysPaid * RATE_NORMAL) + (qualifyingDaysPaid * RATE_WEEKEND);
|
|
}
|
|
|
|
return {
|
|
normalDays: normalDays,
|
|
qualifyingDaysFriday: qualifyingDaysFriday,
|
|
qualifyingDaysOther: qualifyingDaysOther,
|
|
qualifyingDays: qualifyingDays,
|
|
thresholdReached: thresholdReached,
|
|
deduction: deduction,
|
|
normalDaysPaid: normalDaysPaid,
|
|
qualifyingDaysPaid: qualifyingDaysPaid,
|
|
bonusNormalDays: normalDaysPaid * RATE_NORMAL,
|
|
bonusQualifyingDays: qualifyingDaysPaid * RATE_WEEKEND,
|
|
totalBonus: bonus,
|
|
details: details
|
|
};
|
|
}
|
|
|
|
function formatCurrency(amount) {
|
|
return new Intl.NumberFormat('de-DE', {
|
|
style: 'currency',
|
|
currency: 'EUR'
|
|
}).format(amount);
|
|
}
|
|
|
|
function formatNumber(num) {
|
|
return num.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
|
}
|
|
|
|
// ==================== UI FUNCTIONS ====================
|
|
function init() {
|
|
loadFromStorage();
|
|
initSelectors();
|
|
renderEmployees();
|
|
updateStep(1);
|
|
}
|
|
|
|
function initSelectors() {
|
|
const monthSelect = document.getElementById('month');
|
|
const yearSelect = document.getElementById('year');
|
|
|
|
MONTHS.forEach((m, i) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = i;
|
|
opt.textContent = m;
|
|
if (i === selectedMonth) opt.selected = true;
|
|
monthSelect.appendChild(opt);
|
|
});
|
|
|
|
const currentYear = new Date().getFullYear();
|
|
for (let y = currentYear - 1; y <= currentYear + 5; y++) {
|
|
const opt = document.createElement('option');
|
|
opt.value = y;
|
|
opt.textContent = y;
|
|
if (y === selectedYear) opt.selected = true;
|
|
yearSelect.appendChild(opt);
|
|
}
|
|
|
|
monthSelect.addEventListener('change', (e) => {
|
|
selectedMonth = parseInt(e.target.value);
|
|
saveToStorage();
|
|
if (currentStep === 2) renderDutyStep();
|
|
});
|
|
|
|
yearSelect.addEventListener('change', (e) => {
|
|
selectedYear = parseInt(e.target.value);
|
|
saveToStorage();
|
|
if (currentStep === 2) renderDutyStep();
|
|
});
|
|
}
|
|
|
|
function goToStep(step) {
|
|
if (step === 2 && employees.length === 0) {
|
|
alert('Bitte geben Sie mindestens einen Mitarbeiter ein.');
|
|
return;
|
|
}
|
|
updateStep(step);
|
|
}
|
|
|
|
function updateStep(step) {
|
|
currentStep = step;
|
|
|
|
document.querySelectorAll('.step-content').forEach(el => el.classList.remove('active'));
|
|
document.getElementById(`step${step}`).classList.add('active');
|
|
|
|
document.querySelectorAll('.step-item').forEach((el, i) => {
|
|
el.classList.remove('active', 'completed');
|
|
if (i + 1 === step) {
|
|
el.classList.add('active');
|
|
} else if (i + 1 < step) {
|
|
el.classList.add('completed');
|
|
}
|
|
});
|
|
|
|
if (step === 2) {
|
|
renderDutyStep();
|
|
}
|
|
|
|
document.querySelector('.print-btn').style.display = step === 3 ? 'flex' : 'none';
|
|
}
|
|
|
|
function addEmployee() {
|
|
const input = document.getElementById('employeeName');
|
|
const name = input.value.trim();
|
|
|
|
if (!name) {
|
|
alert('Bitte geben Sie einen Namen ein.');
|
|
return;
|
|
}
|
|
|
|
if (employees.includes(name)) {
|
|
alert('Dieser Mitarbeiter existiert bereits.');
|
|
return;
|
|
}
|
|
|
|
employees.push(name);
|
|
input.value = '';
|
|
renderEmployees();
|
|
saveToStorage();
|
|
}
|
|
|
|
function removeEmployee(name) {
|
|
employees = employees.filter(e => e !== name);
|
|
duties = duties.filter(d => d.employee !== name);
|
|
renderEmployees();
|
|
saveToStorage();
|
|
}
|
|
|
|
function renderEmployees() {
|
|
const container = document.getElementById('employeeList');
|
|
|
|
if (employees.length === 0) {
|
|
container.innerHTML = '<div class="empty-state"><p>Noch keine Mitarbeiter hinzugefügt</p></div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = employees.map(name => `
|
|
<div class="employee-tag">
|
|
<span>${name}</span>
|
|
<button class="remove" onclick="removeEmployee('${name}')">×</button>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderDutyStep() {
|
|
document.getElementById('monthYearDisplay').textContent = `${MONTHS[selectedMonth]} ${selectedYear}`;
|
|
|
|
const empSelect = document.getElementById('dutyEmployee');
|
|
empSelect.innerHTML = employees.map(e => `<option value="${e}">${e}</option>`).join('');
|
|
|
|
const dateInput = document.getElementById('dutyDate');
|
|
const firstDay = new Date(selectedYear, selectedMonth, 1);
|
|
const lastDay = new Date(selectedYear, selectedMonth + 1, 0);
|
|
dateInput.min = formatDate(firstDay);
|
|
dateInput.max = formatDate(lastDay);
|
|
dateInput.value = formatDate(firstDay);
|
|
|
|
renderCalendar();
|
|
renderDutyList();
|
|
}
|
|
|
|
function renderCalendar() {
|
|
const container = document.getElementById('calendarContainer');
|
|
const firstDay = new Date(selectedYear, selectedMonth, 1);
|
|
const lastDay = new Date(selectedYear, selectedMonth + 1, 0);
|
|
const startDay = firstDay.getDay() || 7;
|
|
|
|
let html = '<div class="calendar-grid">';
|
|
const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
|
dayNames.forEach(d => html += `<div class="calendar-header">${d}</div>`);
|
|
|
|
for (let i = 1; i < startDay; i++) {
|
|
html += '<div class="calendar-day empty"></div>';
|
|
}
|
|
|
|
for (let day = 1; day <= lastDay.getDate(); day++) {
|
|
const date = new Date(selectedYear, selectedMonth, day);
|
|
const dateStr = formatDate(date);
|
|
const qualifying = isQualifyingDay(date);
|
|
const hasDuty = duties.some(d => d.date === dateStr);
|
|
|
|
let classes = 'calendar-day';
|
|
if (qualifying) classes += ' qualifying';
|
|
if (hasDuty) classes += ' has-duty selected';
|
|
|
|
html += `<div class="${classes}" onclick="selectDate('${dateStr}')" title="${getDayTypeLabel(date)}">${day}</div>`;
|
|
}
|
|
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function selectDate(dateStr) {
|
|
document.getElementById('dutyDate').value = dateStr;
|
|
}
|
|
|
|
function addDuty() {
|
|
const employee = document.getElementById('dutyEmployee').value;
|
|
const dateStr = document.getElementById('dutyDate').value;
|
|
const share = parseFloat(document.getElementById('dutyShare').value);
|
|
|
|
if (!employee || !dateStr) {
|
|
alert('Bitte füllen Sie alle Felder aus.');
|
|
return;
|
|
}
|
|
|
|
const date = new Date(dateStr);
|
|
if (date.getMonth() !== selectedMonth || date.getFullYear() !== selectedYear) {
|
|
alert('Das Datum muss im ausgewählten Monat liegen.');
|
|
return;
|
|
}
|
|
|
|
duties.push({ employee, date: dateStr, share });
|
|
renderCalendar();
|
|
renderDutyList();
|
|
saveToStorage();
|
|
}
|
|
|
|
function removeDuty(index) {
|
|
duties.splice(index, 1);
|
|
renderCalendar();
|
|
renderDutyList();
|
|
saveToStorage();
|
|
}
|
|
|
|
function renderDutyList() {
|
|
const container = document.getElementById('dutyListContainer');
|
|
const monthDuties = duties.filter(d => {
|
|
const date = new Date(d.date);
|
|
return date.getMonth() === selectedMonth && date.getFullYear() === selectedYear;
|
|
});
|
|
|
|
document.getElementById('dutyCount').textContent = monthDuties.length;
|
|
|
|
if (monthDuties.length === 0) {
|
|
container.innerHTML = '<div class="empty-state"><p>Noch keine Dienste eingetragen</p></div>';
|
|
return;
|
|
}
|
|
|
|
monthDuties.sort((a, b) => new Date(a.date) - new Date(b.date));
|
|
|
|
container.innerHTML = monthDuties.map((duty, idx) => {
|
|
const date = new Date(duty.date);
|
|
const qualifying = isQualifyingDay(date);
|
|
const dayType = getDayTypeLabel(date);
|
|
const realIndex = duties.indexOf(duty);
|
|
|
|
return `
|
|
<div class="duty-item ${qualifying ? 'qualifying' : ''}">
|
|
<div>
|
|
<strong>${duty.employee}</strong>
|
|
</div>
|
|
<div>
|
|
${date.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}
|
|
<div class="day-type">${dayType}</div>
|
|
</div>
|
|
<div>Anteil: ${formatNumber(duty.share)}</div>
|
|
<div>
|
|
<button class="btn btn-danger" onclick="removeDuty(${realIndex})">Löschen</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function calculateAndShowResults() {
|
|
const monthDuties = duties.filter(d => {
|
|
const date = new Date(d.date);
|
|
return date.getMonth() === selectedMonth && date.getFullYear() === selectedYear;
|
|
});
|
|
|
|
if (monthDuties.length === 0) {
|
|
alert('Keine Dienste für diesen Monat eingetragen.');
|
|
return;
|
|
}
|
|
|
|
goToStep(3);
|
|
renderResults();
|
|
}
|
|
|
|
function renderResults() {
|
|
document.getElementById('resultMonthYear').textContent = `${MONTHS[selectedMonth]} ${selectedYear}`;
|
|
|
|
const monthDuties = duties.filter(d => {
|
|
const date = new Date(d.date);
|
|
return date.getMonth() === selectedMonth && date.getFullYear() === selectedYear;
|
|
});
|
|
|
|
const byEmployee = {};
|
|
monthDuties.forEach(d => {
|
|
if (!byEmployee[d.employee]) byEmployee[d.employee] = [];
|
|
byEmployee[d.employee].push(d);
|
|
});
|
|
|
|
let totalBonus = 0;
|
|
let html = '';
|
|
|
|
employees.forEach(emp => {
|
|
const empDuties = byEmployee[emp] || [];
|
|
const result = calculateBonus(empDuties);
|
|
totalBonus += result.totalBonus;
|
|
|
|
html += `
|
|
<div class="result-card">
|
|
<h3>
|
|
${emp}
|
|
<span class="threshold-badge ${result.thresholdReached ? 'reached' : 'not-reached'}">
|
|
${result.thresholdReached ? 'Schwelle erreicht' : 'Schwelle nicht erreicht'}
|
|
</span>
|
|
</h3>
|
|
<div class="result-grid">
|
|
<div class="result-item">
|
|
<div class="label">WT-Einheiten</div>
|
|
<div class="value">${formatNumber(result.normalDays)}</div>
|
|
</div>
|
|
<div class="result-item">
|
|
<div class="label">WE-Einheiten (Freitag)</div>
|
|
<div class="value">${formatNumber(result.qualifyingDaysFriday)}</div>
|
|
</div>
|
|
<div class="result-item">
|
|
<div class="label">WE-Einheiten (Andere)</div>
|
|
<div class="value">${formatNumber(result.qualifyingDaysOther)}</div>
|
|
</div>
|
|
<div class="result-item ${result.thresholdReached ? 'success' : 'warning'}">
|
|
<div class="label">WE-Gesamt</div>
|
|
<div class="value">${formatNumber(result.qualifyingDays)}</div>
|
|
</div>
|
|
<div class="result-item">
|
|
<div class="label">Abzug</div>
|
|
<div class="value">-${formatNumber(result.deduction)}</div>
|
|
</div>
|
|
<div class="result-item">
|
|
<div class="label">WT bezahlt</div>
|
|
<div class="value">${formatNumber(result.normalDaysPaid)}</div>
|
|
</div>
|
|
<div class="result-item">
|
|
<div class="label">WE bezahlt</div>
|
|
<div class="value">${formatNumber(result.qualifyingDaysPaid)}</div>
|
|
</div>
|
|
<div class="result-item highlight">
|
|
<div class="label">Bonus Gesamt</div>
|
|
<div class="value">${formatCurrency(result.totalBonus)}</div>
|
|
</div>
|
|
</div>
|
|
${result.thresholdReached ? `
|
|
<div style="margin-top: 15px; font-size: 0.9em; color: #718096;">
|
|
WT: ${formatNumber(result.normalDaysPaid)} x ${formatCurrency(RATE_NORMAL)} = ${formatCurrency(result.bonusNormalDays)}<br>
|
|
WE: ${formatNumber(result.qualifyingDaysPaid)} x ${formatCurrency(RATE_WEEKEND)} = ${formatCurrency(result.bonusQualifyingDays)}
|
|
</div>
|
|
` : `
|
|
<div style="margin-top: 15px; font-size: 0.9em; color: #c53030;">
|
|
WE-Schwelle (2,0) nicht erreicht - kein Bonus
|
|
</div>
|
|
`}
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
<div class="total-summary">
|
|
<h2>Gesamtauszahlung</h2>
|
|
<div class="amount">${formatCurrency(totalBonus)}</div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('resultsContainer').innerHTML = html;
|
|
}
|
|
|
|
// ==================== STORAGE ====================
|
|
function saveToStorage() {
|
|
try {
|
|
localStorage.setItem('dienstplan_portable_employees', JSON.stringify(employees));
|
|
localStorage.setItem('dienstplan_portable_duties', JSON.stringify(duties));
|
|
localStorage.setItem('dienstplan_portable_month', selectedMonth);
|
|
localStorage.setItem('dienstplan_portable_year', selectedYear);
|
|
} catch (e) {
|
|
console.log('Speichern nicht möglich:', e);
|
|
}
|
|
}
|
|
|
|
function loadFromStorage() {
|
|
try {
|
|
const savedEmployees = localStorage.getItem('dienstplan_portable_employees');
|
|
const savedDuties = localStorage.getItem('dienstplan_portable_duties');
|
|
const savedMonth = localStorage.getItem('dienstplan_portable_month');
|
|
const savedYear = localStorage.getItem('dienstplan_portable_year');
|
|
|
|
if (savedEmployees) employees = JSON.parse(savedEmployees);
|
|
if (savedDuties) duties = JSON.parse(savedDuties);
|
|
if (savedMonth !== null) selectedMonth = parseInt(savedMonth);
|
|
if (savedYear !== null) selectedYear = parseInt(savedYear);
|
|
} catch (e) {
|
|
console.log('Laden nicht möglich:', e);
|
|
}
|
|
}
|
|
|
|
// Enter key for employee input
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
init();
|
|
document.getElementById('employeeName').addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') addEmployee();
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|