Files
SDSStundenerfassung/views/verwaltung.ejs

1282 lines
56 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verwaltung - Stundenerfassung</title>
<link rel="icon" type="image/png" href="/images/favicon.png">
<link rel="stylesheet" href="/css/style.css">
<%- include('header') %>
</head>
<body>
<div class="navbar">
<div class="container">
<div class="navbar-brand">
<img src="/images/header.png" alt="Logo" class="navbar-logo">
<h1>Stundenerfassung - Verwaltung</h1>
</div>
<div class="nav-right">
<span>Verwaltung: <%= user.firstname %> <%= user.lastname %></span>
<select id="verwaltungNav" class="role-switcher" style="margin-right: 10px; padding: 5px 10px; border-radius: 4px; border: 1px solid #ddd;">
<option value="/verwaltung" selected>Postfach</option>
<option value="/verwaltung/projektauswertung">Projektauswertung</option>
</select>
<% if (user.roles && user.roles.length > 1) { %>
<select id="roleSwitcher" class="role-switcher" style="margin-right: 10px; padding: 5px 10px; border-radius: 4px; border: 1px solid #ddd;">
<% const roleLabels = { 'mitarbeiter': 'Mitarbeiter', 'verwaltung': 'Verwaltung', 'admin': 'Administrator' }; %>
<% user.roles.forEach(function(role) { %>
<option value="<%= role %>" <%= user.currentRole === role ? 'selected' : '' %>><%= roleLabels[role] || role %></option>
<% }); %>
</select>
<% } %>
<a href="/logout" class="btn btn-logout">Abmelden</a>
</div>
</div>
</div>
<div class="container verwaltung-container">
<div class="verwaltung-panel">
<h2>Postfach - Eingereichte Stundenzettel</h2>
<!-- Massendownload für Kalenderwoche -->
<div style="margin-bottom: 30px; padding: 20px; background-color: #f8f9fa; border-radius: 8px; border: 1px solid #dee2e6;">
<h3 style="margin-top: 0; margin-bottom: 15px; font-size: 16px; color: #333;">Massendownload für Kalenderwoche</h3>
<div style="display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap;">
<div style="display: flex; flex-direction: column; gap: 5px;">
<label for="bulkDownloadYear" style="font-size: 13px; color: #555; font-weight: 500;">Jahr:</label>
<input
type="number"
id="bulkDownloadYear"
min="2000"
max="2100"
value="<%= new Date().getFullYear() %>"
style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; width: 100px;"
placeholder="2024">
</div>
<div style="display: flex; flex-direction: column; gap: 5px;">
<label for="bulkDownloadWeek" style="font-size: 13px; color: #555; font-weight: 500;">Kalenderwoche:</label>
<input
type="number"
id="bulkDownloadWeek"
min="1"
max="53"
style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; width: 100px;"
placeholder="5">
</div>
<button
id="bulkDownloadBtn"
class="btn btn-primary"
style="padding: 8px 20px; font-size: 14px; white-space: nowrap;">
Alle PDFs für KW herunterladen
</button>
</div>
<div id="bulkDownloadStatus" style="margin-top: 12px; font-size: 13px; color: #666; display: none;"></div>
</div>
<!-- Sortierung der Mitarbeiter-Gruppen -->
<div style="margin-bottom: 25px; padding: 12px 16px; background-color: #f8f9fa; border-radius: 8px; border: 1px solid #dee2e6;">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px;">
<span style="font-size: 13px; color: #555; font-weight: 500; margin-right: 5px;">Sortieren nach:</span>
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="firstname" data-sort-direction="asc">
Vorname ▲
</button>
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="firstname" data-sort-direction="desc">
Vorname ▼
</button>
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="name" data-sort-direction="asc">
Name ▲
</button>
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="name" data-sort-direction="desc">
Name ▼
</button>
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="personalnummer" data-sort-direction="asc">
Personalnr. ▲
</button>
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="personalnummer" data-sort-direction="desc">
Personalnr. ▼
</button>
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="overtime" data-sort-direction="asc">
Aktuelle Überstunden ▲
</button>
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="overtime" data-sort-direction="desc">
Aktuelle Überstunden ▼
</button>
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="remainingVacation" data-sort-direction="asc">
Verbleibender Urlaub ▲
</button>
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="remainingVacation" data-sort-direction="desc">
Verbleibender Urlaub ▼
</button>
</div>
</div>
</div>
<% if (!groupedByEmployee || groupedByEmployee.length === 0) { %>
<div class="empty-state">
<p>Keine eingereichten Stundenzettel vorhanden.</p>
</div>
<% } else { %>
<div class="timesheet-groups">
<% groupedByEmployee.forEach(function(employee, employeeIndex) { %>
<!-- Level 1: Mitarbeiter -->
<div
class="employee-group"
data-employee-id="<%= employee.user.id %>"
data-employee-index="<%= employeeIndex %>"
data-lastname="<%= employee.user.lastname || '' %>"
data-firstname="<%= employee.user.firstname || '' %>"
data-personalnummer="<%= employee.user.personalnummer || '' %>"
>
<div class="employee-header">
<div class="employee-info">
<div class="employee-name">
<strong><%= employee.user.firstname %> <%= employee.user.lastname %></strong>
<% if (employee.user.personalnummer) { %>
<span style="margin-left: 10px; color: #666;">(Personalnummer: <%= employee.user.personalnummer %>)</span>
<% } %>
<% if (employee.has_new_version_after_download) { %>
<span class="employee-new-version-warning">ACHTUNG: Neue version eingereicht</span>
<% } %>
</div>
<div class="employee-details" style="margin-top: 10px;">
<div style="display: inline-block; margin-right: 20px;">
<strong>Aktuelle Überstunden:</strong> <span class="current-overtime-value" data-user-id="<%= employee.user.id %>">-</span>
</div>
<div style="display: inline-block; margin-right: 20px;">
<strong>Verbleibender Urlaub:</strong> <span class="remaining-vacation-value" data-user-id="<%= employee.user.id %>">-</span> Tage
</div>
<div style="display: inline-flex; gap: 8px; align-items: center; margin-right: 20px;">
<strong>Überstunden-Korrektur:</strong>
<input
type="text"
class="overtime-offset-input"
data-user-id="<%= employee.user.id %>"
value="0:00"
placeholder="z. B. 1:30, 10:30 oder -0:45"
style="width: 110px; padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px;"
title="Korrektur in h:mm (z. B. 1:30, 10:30 oder -0:45). Nach dem Speichern wird das Feld auf 0:00 gesetzt." />
<button
type="button"
class="btn btn-success btn-sm save-overtime-offset-btn"
data-user-id="<%= employee.user.id %>"
style="padding: 6px 10px; white-space: nowrap;"
title="Überstunden-Korrektur speichern (addiert/abzieht und protokolliert)">
Speichern
</button>
<button
type="button"
class="btn btn-secondary btn-sm toggle-overtime-corrections-btn"
data-user-id="<%= employee.user.id %>"
style="padding: 6px 10px; white-space: nowrap;"
title="Korrektur-Historie anzeigen/ausblenden">
Historie
</button>
</div>
<div class="overtime-corrections-container" data-user-id="<%= employee.user.id %>" style="display: none; margin: 10px; padding: 10px 12px; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px;">
<div class="overtime-corrections-loading" style="color: #666; font-size: 13px;">Lade Korrekturen...</div>
<div class="overtime-corrections-empty" style="display: none; color: #999; font-size: 13px;">Keine Korrekturen vorhanden.</div>
<ul class="overtime-corrections-list" style="margin: 8px 0 0 0; padding-left: 18px; font-size: 13px;"></ul>
</div>
<div style="display: inline-flex; gap: 8px; align-items: center; margin-right: 20px;">
<strong>Urlaubstage-Offset:</strong>
<input
type="number"
step="0.5"
class="vacation-offset-input"
data-user-id="<%= employee.user.id %>"
value="<%= (employee.user.vacation_offset_days !== undefined && employee.user.vacation_offset_days !== null) ? employee.user.vacation_offset_days : 0 %>"
style="width: 90px; padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px;"
title="Manuelle Korrektur (positiv oder negativ) in Tagen" />
<button
type="button"
class="btn btn-success btn-sm save-vacation-offset-btn"
data-user-id="<%= employee.user.id %>"
style="padding: 6px 10px; white-space: nowrap;"
title="Urlaubstage-Offset speichern">
Speichern
</button>
</div>
<div style="display: inline-block; margin-right: 20px;">
<strong>Kalenderwochen:</strong> <span><%= employee.weekCount %></span>
</div>
<div style="display: inline-block; margin-right: 20px;">
<strong>Krankheitstage (<span class="sick-days-year"><%= new Date().getFullYear() %></span>):</strong>
<span class="sick-days-count" data-user-id="<%= employee.user.id %>" style="color: #e74c3c;">-</span>
</div>
<% if (employee.latest_week_start && employee.latest_week_end) { %>
<div class="group-stats group-stats-header" data-user-id="<%= employee.user.id %>" data-week-start="<%= employee.latest_week_start %>" data-week-end="<%= employee.latest_week_end %>" style="display: none;">
<div class="stats-loading" style="display: inline-block; color: #666;">Lade Statistiken...</div>
</div>
<% } %>
</div>
</div>
<button class="btn btn-secondary btn-sm toggle-employee-btn" data-employee-index="<%= employeeIndex %>">
<span class="toggle-icon">▼</span> Kalenderwochen
</button>
</div>
<!-- Level 2: Kalenderwochen -->
<div class="weeks-container" data-employee-index="<%= employeeIndex %>" data-user-id="<%= employee.user.id %>" style="display: none;" data-loaded="false"></div>
</div>
<% }); %>
</div>
<% } %>
</div>
</div>
<script src="/js/format-hours.js"></script>
<script>
async function loadStatsForDiv(statsDiv) {
const userId = statsDiv.dataset.userId;
const weekStart = statsDiv.dataset.weekStart;
const weekEnd = statsDiv.dataset.weekEnd;
return fetch(`/api/verwaltung/user/${userId}/stats?week_start=${weekStart}&week_end=${weekEnd}`)
.then(response => response.json())
.then(data => {
const loadingDiv = statsDiv.querySelector('.stats-loading');
if (loadingDiv) {
loadingDiv.style.display = 'none';
}
// vorherige Stats entfernen (wenn reloaded)
statsDiv.querySelectorAll('.stats-inline').forEach(n => n.remove());
// Statistiken anzeigen
let statsHTML = '';
if (data.weekOvertimeHours !== undefined) {
const weekOvertimeColor = data.weekOvertimeHours < 0 ? '#dc3545' : (data.weekOvertimeHours > 0 ? '#28a745' : '#666');
const sign = data.weekOvertimeHours >= 0 ? '+' : '';
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
<strong>Überstunden:</strong> <span style="color: ${weekOvertimeColor};">${sign}${formatHoursMin(data.weekOvertimeHours)}</span>
</div>`;
}
if (data.overtimeOffsetHours !== undefined && data.overtimeOffsetHours !== 0) {
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
<strong>Offset:</strong> <span>${formatHoursMin(Number(data.overtimeOffsetHours))}</span>
${data.remainingOvertimeWithOffset !== undefined ? `<span style="color: #28a745;">(verbleibend inkl. Offset: ${formatHoursMin(Number(data.remainingOvertimeWithOffset))})</span>` : ''}
</div>`;
}
if (data.totalVacationDays !== undefined || data.vacationDays !== undefined) {
const totalTaken = data.totalVacationDays !== undefined ? data.totalVacationDays : 0;
const inWeek = data.vacationDays !== undefined ? data.vacationDays : 0;
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
<strong>Urlaub genommen (dieses Jahr):</strong> <span>${Number(totalTaken).toFixed(1)} Tag${totalTaken !== 1 ? 'e' : ''}</span>
${inWeek > 0 ? ` <span style="color: #666;">(diese Woche: ${inWeek.toFixed(1)})</span>` : ''}
${data.remainingVacation !== undefined ? ` <span style="color: #28a745;">(verbleibend: ${Number(data.remainingVacation).toFixed(1)} Tage)</span>` : ''}
</div>`;
}
if (data.vacationOffsetDays !== undefined && data.vacationOffsetDays !== 0) {
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
<strong>Urlaubstage-Offset:</strong> <span>${Number(data.vacationOffsetDays).toFixed(1)} Tag${Math.abs(data.vacationOffsetDays) !== 1 ? 'e' : ''}</span>
${data.remainingVacation !== undefined ? `<span style="color: #28a745;">(verbleibend inkl. Offset: ${Number(data.remainingVacation).toFixed(1)} Tage)</span>` : ''}
</div>`;
}
if (data.sickDays !== undefined && data.sickDays > 0) {
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
<strong>Krankheitstage:</strong> <span style="color: #e74c3c;">${data.sickDays} Tag${data.sickDays !== 1 ? 'e' : ''}</span>
</div>`;
}
if (statsHTML) {
statsDiv.insertAdjacentHTML('beforeend', statsHTML);
}
})
.catch(error => {
console.error('Fehler beim Laden der Statistiken:', error);
const loadingDiv = statsDiv.querySelector('.stats-loading');
if (loadingDiv) {
loadingDiv.textContent = 'Fehler beim Laden';
loadingDiv.style.color = 'red';
}
});
}
function initWeeksInContainer(root) {
if (!root) return;
// Statistiken für alle Wochen/Container laden (Header + nachgeladene Wochen)
root.querySelectorAll('.group-stats').forEach(statsDiv => {
if (!statsDiv.dataset.statsInitialized) {
statsDiv.dataset.statsInitialized = 'true';
loadStatsForDiv(statsDiv);
}
});
// Versionen-Gruppen auf-/zuklappen (innerhalb einer Kalenderwoche)
root.querySelectorAll('.toggle-versions-btn').forEach(btn => {
if (btn.dataset.initialized === 'true') return;
btn.dataset.initialized = 'true';
btn.addEventListener('click', function() {
const weekGroup = this.closest('.week-group');
if (!weekGroup) return;
const versionsContainer = weekGroup.querySelector('.versions-container');
const toggleIcon = this.querySelector('.toggle-icon');
if (versionsContainer) {
if (versionsContainer.style.display === 'none' || !versionsContainer.style.display) {
versionsContainer.style.display = 'block';
toggleIcon.textContent = '▲';
this.classList.add('active');
} else {
versionsContainer.style.display = 'none';
toggleIcon.textContent = '▼';
this.classList.remove('active');
}
}
});
});
// PDF-Download Marker aktualisieren
root.querySelectorAll('.pdf-download-link').forEach(link => {
if (link.dataset.initialized === 'true') return;
link.dataset.initialized = 'true';
link.addEventListener('click', function() {
const timesheetId = this.dataset.timesheetId;
const currentUser = '<%= user.firstname %> <%= user.lastname %>';
// Nach kurzer Verzögerung Marker aktualisieren (wenn Download erfolgreich war)
setTimeout(() => {
fetch(`/api/timesheet/download-info/${timesheetId}`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
if (data.downloaded) {
const marker = document.querySelector(`.timesheet-row[data-timesheet-id="${timesheetId}"] .pdf-not-downloaded-marker`);
if (marker) {
const downloadedBy = (data.downloaded_by_firstname && data.downloaded_by_lastname)
? `${data.downloaded_by_firstname} ${data.downloaded_by_lastname}`
: currentUser || 'Unbekannt';
const downloadedAt = data.downloaded_at
? new Date(data.downloaded_at).toLocaleString('de-DE')
: 'Gerade';
marker.outerHTML = `<span class="pdf-downloaded-marker" title="PDF wurde am ${downloadedAt} von ${downloadedBy} heruntergeladen">✓ Heruntergeladen von ${downloadedBy}</span>`;
}
}
})
.catch(err => {
console.error('Fehler beim Laden der Download-Info:', err);
const marker = document.querySelector(`.timesheet-row[data-timesheet-id="${timesheetId}"] .pdf-not-downloaded-marker`);
if (marker && currentUser) {
marker.outerHTML = `<span class="pdf-downloaded-marker" title="PDF wurde von ${currentUser} heruntergeladen">✓ Heruntergeladen von ${currentUser}</span>`;
}
});
}, 1500);
});
});
// PDF-Vorschau ein-/ausblenden (PDF erst bei Klick laden)
root.querySelectorAll('.toggle-pdf-btn').forEach(btn => {
if (btn.dataset.initialized === 'true') return;
btn.dataset.initialized = 'true';
btn.addEventListener('click', function() {
const timesheetId = this.dataset.timesheetId;
const previewRow = root.querySelector(`.pdf-preview-row[data-timesheet-id="${timesheetId}"]`);
const arrowIcon = this.querySelector('.arrow-icon');
const iframe = previewRow ? previewRow.querySelector('.pdf-iframe') : null;
if (previewRow && (previewRow.style.display === 'none' || !previewRow.style.display)) {
// Alle anderen PDF-Vorschauen schließen (nur innerhalb root relevant)
root.querySelectorAll('.pdf-preview-row').forEach(row => {
if (row.dataset.timesheetId !== timesheetId) {
row.style.display = 'none';
const otherBtn = root.querySelector(`.toggle-pdf-btn[data-timesheet-id="${row.dataset.timesheetId}"]`);
if (otherBtn) {
const otherIcon = otherBtn.querySelector('.arrow-icon');
if (otherIcon) otherIcon.textContent = '▶';
otherBtn.classList.remove('active');
}
}
});
previewRow.style.display = 'table-row';
arrowIcon.textContent = '▼';
this.classList.add('active');
if (iframe) {
const currentSrc = iframe.getAttribute('src');
if (!currentSrc) {
const dataSrc = iframe.getAttribute('data-src') || `/api/timesheet/pdf/${timesheetId}?inline=true`;
iframe.setAttribute('src', dataSrc);
}
}
setTimeout(() => {
previewRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 100);
} else {
if (previewRow) {
previewRow.style.display = 'none';
}
arrowIcon.textContent = '▶';
this.classList.remove('active');
}
});
});
// Schließen-Button für PDF-Vorschau
root.querySelectorAll('.close-pdf-btn').forEach(btn => {
if (btn.dataset.initialized === 'true') return;
btn.dataset.initialized = 'true';
btn.addEventListener('click', function() {
const timesheetId = this.dataset.timesheetId;
const previewRow = root.querySelector(`.pdf-preview-row[data-timesheet-id="${timesheetId}"]`);
const toggleBtn = root.querySelector(`.toggle-pdf-btn[data-timesheet-id="${timesheetId}"]`);
if (previewRow) previewRow.style.display = 'none';
if (toggleBtn) {
const icon = toggleBtn.querySelector('.arrow-icon');
if (icon) icon.textContent = '▶';
toggleBtn.classList.remove('active');
}
});
});
// Kommentar speichern
root.querySelectorAll('.save-comment-btn').forEach(btn => {
if (btn.dataset.initialized === 'true') return;
btn.dataset.initialized = 'true';
btn.addEventListener('click', async function() {
const timesheetId = this.dataset.timesheetId;
const commentInput = root.querySelector(`.admin-comment-input[data-timesheet-id="${timesheetId}"]`);
if (!commentInput) {
console.error('Kommentar-Input nicht gefunden');
return;
}
const comment = commentInput.value.trim();
const originalButtonText = this.innerHTML;
this.disabled = true;
this.innerHTML = '...';
this.title = 'Speichere...';
try {
const response = await fetch(`/api/verwaltung/timesheet/${timesheetId}/comment`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ comment: comment })
});
const result = await response.json();
if (result.success) {
this.innerHTML = '✓';
this.title = 'Gespeichert!';
this.style.backgroundColor = '#28a745';
setTimeout(() => {
this.innerHTML = originalButtonText;
this.title = 'Kommentar speichern';
this.style.backgroundColor = '';
this.disabled = false;
}, 2000);
} else {
alert('Fehler beim Speichern: ' + (result.error || 'Unbekannter Fehler'));
this.innerHTML = originalButtonText;
this.title = 'Kommentar speichern';
this.disabled = false;
}
} catch (error) {
console.error('Fehler beim Speichern des Kommentars:', error);
alert('Fehler beim Speichern des Kommentars');
this.innerHTML = originalButtonText;
this.title = 'Kommentar speichern';
this.disabled = false;
}
});
});
// Kommentar auch per Enter-Taste speichern (Strg+Enter)
root.querySelectorAll('.admin-comment-input').forEach(input => {
if (input.dataset.initialized === 'true') return;
input.dataset.initialized = 'true';
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
const timesheetId = this.dataset.timesheetId;
const saveBtn = root.querySelector(`.save-comment-btn[data-timesheet-id="${timesheetId}"]`);
if (saveBtn) {
saveBtn.click();
}
}
});
});
}
// Header-Statistiken (verbleibender Urlaub / Basiswerte) initial laden
initWeeksInContainer(document);
// Krankheitstage für alle Mitarbeiter laden
async function loadSickDays() {
const sickDaysElements = document.querySelectorAll('.sick-days-count');
const userIds = Array.from(sickDaysElements).map(el => el.dataset.userId);
const uniqueUserIds = [...new Set(userIds)];
for (const userId of uniqueUserIds) {
try {
const response = await fetch(`/api/verwaltung/user/${userId}/sick-days`);
if (!response.ok) {
throw new Error('Fehler beim Laden der Krankheitstage');
}
const data = await response.json();
// Alle Elemente für diesen User aktualisieren
document.querySelectorAll(`.sick-days-count[data-user-id="${userId}"]`).forEach(el => {
el.textContent = data.sickDays || 0;
});
} catch (error) {
console.error('Fehler beim Laden der Krankheitstage für User', userId, ':', error);
document.querySelectorAll(`.sick-days-count[data-user-id="${userId}"]`).forEach(el => {
el.textContent = '-';
});
}
}
}
// Aktuelle Überstunden für alle Mitarbeiter-Header laden (wie im Dashboard)
async function loadCurrentOvertime() {
const elements = document.querySelectorAll('.current-overtime-value');
const userIds = [...new Set(Array.from(elements).map(el => el.dataset.userId).filter(Boolean))];
if (userIds.length === 0) return;
try {
const response = await fetch('/api/verwaltung/employees/current-overtime?userIds=' + userIds.join(','));
if (!response.ok) throw new Error('Fehler beim Laden der Überstunden');
const data = await response.json();
elements.forEach(el => {
const userId = el.dataset.userId;
const value = data[userId] != null ? Number(data[userId]) : null;
if (value === null) {
el.textContent = '-';
el.style.color = '';
el.dataset.overtimeValue = '';
} else {
el.textContent = (value >= 0 ? '+' : '') + formatHoursMin(value);
el.style.color = value >= 0 ? '#27ae60' : '#e74c3c';
el.dataset.overtimeValue = String(value);
}
const group = document.querySelector(`.employee-group[data-employee-id="${userId}"]`);
if (group) {
group.dataset.overtimeValue = el.dataset.overtimeValue || '';
}
});
} catch (error) {
console.error('Fehler beim Laden der aktuellen Überstunden:', error);
elements.forEach(el => {
el.textContent = '-';
el.style.color = '';
el.dataset.overtimeValue = '';
});
}
}
// Verbleibenden Urlaub (Tage) für alle Mitarbeiter-Header laden
async function loadRemainingVacation() {
const elements = document.querySelectorAll('.remaining-vacation-value');
const byUser = {};
elements.forEach(el => {
const userId = el.dataset.userId;
if (!userId) return;
if (!byUser[userId]) byUser[userId] = [];
byUser[userId].push(el);
});
const userIds = Object.keys(byUser);
if (userIds.length === 0) return;
for (const userId of userIds) {
try {
// Nimm die erste (neueste) Woche für diesen Mitarbeiter
const statsDiv = document.querySelector(`.group-stats[data-user-id="${userId}"]`);
if (!statsDiv) {
byUser[userId].forEach(el => { el.textContent = '-'; });
continue;
}
const weekStart = statsDiv.dataset.weekStart;
const weekEnd = statsDiv.dataset.weekEnd;
if (!weekStart || !weekEnd) {
byUser[userId].forEach(el => { el.textContent = '-'; });
continue;
}
const resp = await fetch(`/api/verwaltung/user/${userId}/stats?week_start=${weekStart}&week_end=${weekEnd}`);
if (!resp.ok) {
byUser[userId].forEach(el => { el.textContent = '-'; });
continue;
}
const data = await resp.json();
const value = data && typeof data.remainingVacation === 'number'
? data.remainingVacation
: null;
byUser[userId].forEach(el => {
if (value === null) {
el.textContent = '-';
} else {
el.textContent = value.toFixed(1);
}
// Wert für Sortierung am Element und an der Mitarbeiter-Gruppe hinterlegen
el.dataset.remainingVacation = value !== null ? String(value) : '';
const group = el.closest('.employee-group') || document.querySelector(`.employee-group[data-employee-id="${el.dataset.userId}"]`);
if (group) {
group.dataset.remainingVacation = value !== null ? String(value) : '';
}
});
} catch (err) {
console.error('Fehler beim Laden des verbleibenden Urlaubs für User', userId, err);
byUser[userId].forEach(el => { el.textContent = '-'; });
}
}
}
// Krankheitstage beim Laden der Seite abrufen
loadSickDays();
loadCurrentOvertime();
loadRemainingVacation();
function sortEmployeeGroups(field, direction) {
const container = document.querySelector('.timesheet-groups');
if (!container) return;
const groups = Array.from(container.querySelectorAll('.employee-group'));
if (groups.length === 0) return;
const dir = direction === 'desc' ? -1 : 1;
function compareStrings(a, b) {
const sa = (a || '').toString().toLowerCase();
const sb = (b || '').toString().toLowerCase();
return sa.localeCompare(sb, 'de-DE');
}
function parseNumberOrNull(value) {
if (value === null || value === undefined || value === '') return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
groups.sort((a, b) => {
let va;
let vb;
if (field === 'name') {
const aLast = a.dataset.lastname || '';
const aFirst = a.dataset.firstname || '';
const bLast = b.dataset.lastname || '';
const bFirst = b.dataset.firstname || '';
const cmp = compareStrings(`${aLast} ${aFirst}`, `${bLast} ${bFirst}`);
return cmp * dir;
}
if (field === 'firstname') {
const aFirst = a.dataset.firstname || '';
const bFirst = b.dataset.firstname || '';
const firstCmp = compareStrings(aFirst, bFirst);
if (firstCmp !== 0) return firstCmp * dir;
// Bei gleichem Vornamen nach Nachname sortieren
const aLast = a.dataset.lastname || '';
const bLast = b.dataset.lastname || '';
const lastCmp = compareStrings(aLast, bLast);
return lastCmp * dir;
}
if (field === 'personalnummer') {
const aRaw = a.dataset.personalnummer || '';
const bRaw = b.dataset.personalnummer || '';
if (!aRaw && !bRaw) return 0;
if (!aRaw) return 1;
if (!bRaw) return -1;
const digitsOnly = /^\d+$/;
const aIsNum = digitsOnly.test(aRaw);
const bIsNum = digitsOnly.test(bRaw);
if (aIsNum && bIsNum) {
va = Number(aRaw);
vb = Number(bRaw);
if (va === vb) return 0;
return va < vb ? -1 * dir : 1 * dir;
}
const cmp = compareStrings(aRaw, bRaw);
return cmp * dir;
}
if (field === 'overtime') {
va = parseNumberOrNull(a.dataset.overtimeValue);
vb = parseNumberOrNull(b.dataset.overtimeValue);
} else if (field === 'remainingVacation') {
va = parseNumberOrNull(a.dataset.remainingVacation);
vb = parseNumberOrNull(b.dataset.remainingVacation);
}
if (va === null && vb === null) return 0;
if (va === null) return 1;
if (vb === null) return -1;
if (va === vb) return 0;
return va < vb ? -1 * dir : 1 * dir;
});
groups.forEach(group => container.appendChild(group));
}
// Überstunden-Korrektur-Historie laden/anzeigen
function parseSqliteDatetime(value) {
if (!value) return null;
const s = String(value);
if (s.includes('T')) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
// formatHoursMin aus format-hours.js (window.formatHoursMin)
function showOvertimeCorrectionReasonModal(opts) {
const title = opts && opts.title ? String(opts.title) : 'Grund für die Korrektur';
const promptText = opts && opts.prompt ? String(opts.prompt) : 'Bitte geben Sie einen Grund an, warum die Korrektur vorgenommen wird.';
const onSubmit = opts && typeof opts.onSubmit === 'function' ? opts.onSubmit : null;
const onCancel = opts && typeof opts.onCancel === 'function' ? opts.onCancel : null;
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
`;
const box = document.createElement('div');
box.style.cssText = `
background: #fff;
border-radius: 8px;
width: 90%;
max-width: 520px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
padding: 18px 18px 14px 18px;
`;
box.innerHTML = `
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom: 8px;">
<h3 style="margin: 0; font-size: 16px; color: #2c3e50;">${title}</h3>
<button type="button" data-action="close" class="btn btn-secondary btn-sm" style="padding: 6px 10px;">✕</button>
</div>
<div style="color:#666; font-size: 13px; margin-bottom: 10px;">${promptText}</div>
<textarea data-role="reason" rows="4" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-family: inherit; font-size: 13px; resize: vertical;" placeholder="Grund..."></textarea>
<div data-role="error" style="display:none; margin-top: 8px; color:#dc3545; font-size: 13px;"></div>
<div style="display:flex; justify-content:flex-end; gap: 10px; margin-top: 12px;">
<button type="button" data-action="cancel" class="btn btn-secondary">Abbrechen</button>
<button type="button" data-action="submit" class="btn btn-success">Speichern</button>
</div>
`;
modal.appendChild(box);
document.body.appendChild(modal);
const textarea = box.querySelector('textarea[data-role="reason"]');
const errorEl = box.querySelector('[data-role="error"]');
function close() {
document.body.removeChild(modal);
}
function cancel() {
try {
if (onCancel) onCancel();
} finally {
close();
}
}
function setError(msg) {
if (!errorEl) return;
errorEl.textContent = msg;
errorEl.style.display = msg ? 'block' : 'none';
}
async function submit() {
const reason = textarea ? textarea.value.trim() : '';
if (!reason) {
setError('Bitte Grund angeben.');
if (textarea) textarea.focus();
return;
}
setError('');
if (onSubmit) {
await onSubmit(reason);
}
close();
}
modal.addEventListener('click', (e) => {
if (e.target === modal) cancel();
});
box.querySelectorAll('button[data-action="close"], button[data-action="cancel"]').forEach(btn => {
btn.addEventListener('click', cancel);
});
const submitBtn = box.querySelector('button[data-action="submit"]');
if (submitBtn) submitBtn.addEventListener('click', submit);
if (textarea) {
textarea.focus();
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Escape') cancel();
if ((e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
e.preventDefault();
submit();
}
});
}
}
async function loadOvertimeCorrectionsForUser(userId) {
const container = document.querySelector(`.overtime-corrections-container[data-user-id="${userId}"]`);
if (!container) return;
const loadingEl = container.querySelector('.overtime-corrections-loading');
const emptyEl = container.querySelector('.overtime-corrections-empty');
const listEl = container.querySelector('.overtime-corrections-list');
if (loadingEl) loadingEl.style.display = 'block';
if (emptyEl) emptyEl.style.display = 'none';
if (listEl) listEl.innerHTML = '';
try {
const resp = await fetch(`/api/verwaltung/user/${userId}/overtime-corrections`);
const data = await resp.json().catch(() => ({}));
const corrections = Array.isArray(data.corrections) ? data.corrections : [];
if (!resp.ok) {
throw new Error(data.error || 'Fehler beim Laden der Korrekturen');
}
if (corrections.length === 0) {
if (emptyEl) emptyEl.style.display = 'block';
return;
}
corrections.forEach(c => {
const dt = parseSqliteDatetime(c.corrected_at);
const dateText = dt ? dt.toLocaleDateString('de-DE') : '';
const rawHours = Number(c.correction_hours) || 0;
const absHours = Math.abs(rawHours);
const signPrefix = rawHours >= 0 ? '+' : '-';
const hoursClass = rawHours >= 0 ? 'overtime-positive' : 'overtime-negative';
const hoursDisplay = signPrefix + formatHoursMin(absHours);
const reason = (c && c.reason != null) ? String(c.reason).trim() : '';
const li = document.createElement('li');
li.innerHTML = reason
? `Korrektur am ${dateText} <span class="${hoursClass}">${hoursDisplay}</span> ${reason}`
: `Korrektur am ${dateText} <span class="${hoursClass}">${hoursDisplay}</span>`;
if (listEl) listEl.appendChild(li);
});
} catch (e) {
console.error('Fehler beim Laden der Überstunden-Korrekturen:', e);
if (emptyEl) {
emptyEl.textContent = 'Fehler beim Laden der Korrekturen.';
emptyEl.style.display = 'block';
}
} finally {
if (loadingEl) loadingEl.style.display = 'none';
}
}
document.querySelectorAll('.toggle-overtime-corrections-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const userId = this.dataset.userId;
const container = document.querySelector(`.overtime-corrections-container[data-user-id="${userId}"]`);
if (!container) return;
const isOpen = container.style.display !== 'none' && container.style.display !== '';
if (isOpen) {
container.style.display = 'none';
return;
}
container.style.display = 'block';
// Beim Öffnen immer neu laden (damit neue Korrekturen sofort sichtbar sind)
await loadOvertimeCorrectionsForUser(userId);
container.dataset.loaded = 'true';
});
});
// Überstunden-Offset speichern
document.querySelectorAll('.save-overtime-offset-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const userId = this.dataset.userId;
const input = document.querySelector(`.overtime-offset-input[data-user-id="${userId}"]`);
if (!input) return;
if (this.dataset.modalOpen === 'true') return;
const originalText = this.textContent;
// leere Eingabe => 0 (Backend macht das auch, aber UI soll sauber sein)
const raw = (input.value || '').trim();
const value = (typeof parseHoursMin === 'function' ? parseHoursMin(raw) : null);
if (value === null && raw !== '') {
alert('Ungültiges Format. Bitte h:mm oder hh:mm eingeben (z. B. 1:30, 10:30 oder -0:45).');
return;
}
const decimalValue = (value === null ? 0 : value);
// Wenn keine Korrektur (0), nichts tun außer UI auf 0:00 zu normalisieren
if (decimalValue === 0) {
input.value = (typeof decimalHoursToHhMm === 'function' ? decimalHoursToHhMm(0) : '0:00');
this.textContent = originalText;
this.disabled = false;
return;
}
this.dataset.modalOpen = 'true';
this.disabled = true;
// Modal: Grund ist Pflicht
showOvertimeCorrectionReasonModal({
title: 'Grund für die Überstunden-Korrektur',
prompt: `Korrektur: ${decimalValue >= 0 ? '+' : ''}${formatHoursMin(decimalValue)}`,
onCancel: () => {
delete this.dataset.modalOpen;
this.disabled = false;
},
onSubmit: async (reason) => {
this.textContent = '...';
try {
const resp = await fetch(`/api/verwaltung/user/${userId}/overtime-offset`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ overtime_offset_hours: decimalValue, reason })
});
const data = await resp.json();
if (!resp.ok) {
alert(data.error || 'Fehler beim Speichern der Korrektur');
this.textContent = originalText;
this.disabled = false;
delete this.dataset.modalOpen;
return;
}
// Normalisiere Input auf h:mm (Backend gibt 0 zurück nach Speichern)
input.value = (typeof decimalHoursToHhMm === 'function')
? decimalHoursToHhMm((data.overtime_offset_hours !== undefined && data.overtime_offset_hours !== null) ? Number(data.overtime_offset_hours) : 0)
: '0:00';
// Stats für diesen User neu laden
const statDivs = document.querySelectorAll(`.group-stats[data-user-id="${userId}"]`);
statDivs.forEach(div => {
// loading indicator optional wieder anzeigen
const loading = div.querySelector('.stats-loading');
if (loading) {
loading.style.display = 'inline-block';
loading.style.color = '#666';
loading.textContent = 'Lade Statistiken...';
}
loadStatsForDiv(div);
});
loadCurrentOvertime();
// Historie (falls geöffnet) aktualisieren
const correctionsContainer = document.querySelector(`.overtime-corrections-container[data-user-id="${userId}"]`);
if (correctionsContainer && correctionsContainer.style.display !== 'none') {
await loadOvertimeCorrectionsForUser(userId);
}
this.textContent = '✓';
setTimeout(() => {
this.textContent = originalText;
this.disabled = false;
delete this.dataset.modalOpen;
}, 900);
} catch (e) {
console.error('Fehler beim Speichern der Korrektur:', e);
alert('Fehler beim Speichern der Korrektur');
this.textContent = originalText;
this.disabled = false;
delete this.dataset.modalOpen;
}
}
});
});
});
// Urlaubstage-Offset speichern
document.querySelectorAll('.save-vacation-offset-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const userId = this.dataset.userId;
const input = document.querySelector(`.vacation-offset-input[data-user-id="${userId}"]`);
if (!input) return;
const originalText = this.textContent;
this.disabled = true;
this.textContent = '...';
// leere Eingabe => 0 (Backend macht das auch, aber UI soll sauber sein)
const raw = (input.value || '').trim();
const value = raw === '' ? '' : Number(raw);
try {
const resp = await fetch(`/api/verwaltung/user/${userId}/vacation-offset`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vacation_offset_days: value })
});
const data = await resp.json();
if (!resp.ok) {
alert(data.error || 'Fehler beim Speichern des Offsets');
return;
}
// Normalisiere Input auf Zahl (Backend gibt number zurück)
input.value = (data.vacation_offset_days !== undefined && data.vacation_offset_days !== null)
? Number(data.vacation_offset_days)
: 0;
// Stats für diesen User neu laden
const statDivs = document.querySelectorAll(`.group-stats[data-user-id="${userId}"]`);
statDivs.forEach(div => {
// loading indicator optional wieder anzeigen
const loading = div.querySelector('.stats-loading');
if (loading) {
loading.style.display = 'inline-block';
loading.style.color = '#666';
loading.textContent = 'Lade Statistiken...';
}
loadStatsForDiv(div);
});
this.textContent = '✓';
setTimeout(() => {
this.textContent = originalText;
this.disabled = false;
}, 900);
} catch (e) {
console.error('Fehler beim Speichern des Offsets:', e);
alert('Fehler beim Speichern des Offsets');
} finally {
if (this.textContent === '...') {
this.textContent = originalText;
this.disabled = false;
}
}
});
});
// Mitarbeiter-Gruppen auf-/zuklappen (zeigt/versteckt Wochen, lazy load)
document.querySelectorAll('.toggle-employee-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const employeeGroup = this.closest('.employee-group');
if (!employeeGroup) return;
const weeksContainer = employeeGroup.querySelector('.weeks-container');
const toggleIcon = this.querySelector('.toggle-icon');
const userId = employeeGroup.dataset.employeeId;
if (!weeksContainer) return;
// Wochen bei erstem Öffnen per AJAX nachladen
if (weeksContainer.dataset.loaded !== 'true' && userId) {
weeksContainer.style.display = 'block';
weeksContainer.innerHTML = '<div class="weeks-loading" style="padding: 10px; color: #666;">Lade Kalenderwochen...</div>';
toggleIcon.textContent = '▲';
this.classList.add('active');
try {
const response = await fetch(`/api/verwaltung/employee/${userId}/weeks`);
if (!response.ok) {
throw new Error('Fehler beim Laden der Wochen');
}
const html = await response.text();
weeksContainer.innerHTML = html || '<div style="padding: 10px; color: #666;">Keine eingereichten Wochen vorhanden.</div>';
weeksContainer.dataset.loaded = 'true';
initWeeksInContainer(weeksContainer);
} catch (error) {
console.error('Fehler beim Nachladen der Wochen:', error);
weeksContainer.innerHTML = '<div class="weeks-error" style="padding: 10px; color: #dc3545;">Fehler beim Laden der Wochen. Bitte erneut versuchen.</div>';
}
return;
}
// Nur Anzeige toggeln, wenn bereits geladen
if (weeksContainer.style.display === 'none' || !weeksContainer.style.display) {
weeksContainer.style.display = 'block';
toggleIcon.textContent = '▲';
this.classList.add('active');
} else {
weeksContainer.style.display = 'none';
toggleIcon.textContent = '▼';
this.classList.remove('active');
}
});
});
// Massendownload für Kalenderwoche
const bulkDownloadBtn = document.getElementById('bulkDownloadBtn');
const bulkDownloadYear = document.getElementById('bulkDownloadYear');
const bulkDownloadWeek = document.getElementById('bulkDownloadWeek');
const bulkDownloadStatus = document.getElementById('bulkDownloadStatus');
if (bulkDownloadBtn) {
bulkDownloadBtn.addEventListener('click', async function() {
const year = parseInt(bulkDownloadYear.value);
const week = parseInt(bulkDownloadWeek.value);
// Validierung
if (!year || year < 2000 || year > 2100) {
bulkDownloadStatus.textContent = 'Bitte geben Sie ein gültiges Jahr ein (2000-2100)';
bulkDownloadStatus.style.display = 'block';
bulkDownloadStatus.style.color = '#dc3545';
return;
}
if (!week || week < 1 || week > 53) {
bulkDownloadStatus.textContent = 'Bitte geben Sie eine gültige Kalenderwoche ein (1-53)';
bulkDownloadStatus.style.display = 'block';
bulkDownloadStatus.style.color = '#dc3545';
return;
}
// Button deaktivieren und Status anzeigen
bulkDownloadBtn.disabled = true;
bulkDownloadBtn.textContent = 'Lädt...';
bulkDownloadStatus.textContent = 'PDFs werden generiert und ZIP erstellt...';
bulkDownloadStatus.style.display = 'block';
bulkDownloadStatus.style.color = '#666';
try {
const response = await fetch(`/api/verwaltung/bulk-download/${year}/${week}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unbekannter Fehler' }));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
// ZIP-Download starten
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Stundenzettel_KW${String(week).padStart(2, '0')}_${year}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
// Erfolgsmeldung
bulkDownloadStatus.textContent = `✓ ZIP erfolgreich heruntergeladen (KW ${week}/${year})`;
bulkDownloadStatus.style.color = '#28a745';
// Seite nach kurzer Verzögerung neu laden, um Download-Marker zu aktualisieren
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (error) {
console.error('Fehler beim Massendownload:', error);
bulkDownloadStatus.textContent = `Fehler: ${error.message || 'Unbekannter Fehler'}`;
bulkDownloadStatus.style.color = '#dc3545';
bulkDownloadBtn.disabled = false;
bulkDownloadBtn.textContent = 'Alle PDFs für KW herunterladen';
}
});
// Enter-Taste in Eingabefeldern
[bulkDownloadYear, bulkDownloadWeek].forEach(input => {
if (input) {
input.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
bulkDownloadBtn.click();
}
});
}
});
}
// Sortier-Buttons für Mitarbeiter-Gruppen
(function initTimesheetGroupSorting() {
const sortButtons = document.querySelectorAll('.timesheet-sort-btn');
if (!sortButtons.length) return;
let activeSortButton = null;
sortButtons.forEach(btn => {
btn.addEventListener('click', function() {
const field = this.dataset.sortField;
const direction = this.dataset.sortDirection || 'asc';
if (!field) return;
sortEmployeeGroups(field, direction);
if (activeSortButton && activeSortButton !== this) {
activeSortButton.classList.remove('active-sort');
}
this.classList.add('active-sort');
activeSortButton = this;
});
});
})();
</script>
<script>
// Rollenwechsel-Handler
document.addEventListener('DOMContentLoaded', function() {
const roleSwitcher = document.getElementById('roleSwitcher');
if (roleSwitcher) {
roleSwitcher.addEventListener('change', async function() {
const newRole = this.value;
try {
const response = await fetch('/api/user/switch-role', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ role: newRole })
});
const result = await response.json();
if (result.success) {
// Redirect basierend auf neuer Rolle
if (newRole === 'admin') {
window.location.href = '/admin';
} else if (newRole === 'verwaltung') {
window.location.href = '/verwaltung';
} else {
window.location.href = '/dashboard';
}
} else {
alert('Fehler beim Wechseln der Rolle: ' + (result.error || 'Unbekannter Fehler'));
// Wert zurücksetzen
this.value = '<%= user.currentRole || "verwaltung" %>';
}
} catch (error) {
console.error('Fehler beim Rollenwechsel:', error);
alert('Fehler beim Wechseln der Rolle');
// Wert zurücksetzen
this.value = '<%= user.currentRole || "verwaltung" %>';
}
});
}
const verwaltungNav = document.getElementById('verwaltungNav');
if (verwaltungNav) {
verwaltungNav.addEventListener('change', function() {
const url = this.value;
if (url) window.location.href = url;
});
}
});
</script>
<%- include('footer') %>
</body>
</html>