Files
SDSStundenerfassung/views/verwaltung.ejs

1195 lines
55 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>
<% 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>
<% 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 %>">
<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.weeks.length %></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>
</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 %>" style="display: none;">
<% employee.weeks.forEach(function(week, weekIndex) { %>
<div class="week-group" data-employee-index="<%= employeeIndex %>" data-week-index="<%= weekIndex %>">
<div class="week-header">
<div class="week-info">
<div class="week-dates">
<%
// Kalenderwoche berechnen
function getCalendarWeek(dateStr) {
const date = new Date(dateStr);
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
return weekNo;
}
const calendarWeek = getCalendarWeek(week.week_start);
%>
<strong>Kalenderwoche <%= String(calendarWeek).padStart(2, '0') %>:</strong> <%= new Date(week.week_start).toLocaleDateString('de-DE') %> -
<%= new Date(week.week_end).toLocaleDateString('de-DE') %>
</div>
<div class="group-stats" data-user-id="<%= employee.user.id %>" data-week-start="<%= week.week_start %>" data-week-end="<%= week.week_end %>" style="margin-top: 10px;">
<div class="stats-loading" style="display: inline-block; color: #666;">Lade Statistiken...</div>
</div>
<div class="week-versions-info" style="margin-top: 5px;">
<span class="version-count"><%= week.total_versions %> Version<%= week.total_versions !== 1 ? 'en' : '' %></span>
</div>
<% if (week.has_new_version_after_download) { %>
<div class="new-version-warning" style="margin-top: 10px;">
<strong>ACHTUNG: Neue version eingereicht</strong>
</div>
<% } %>
</div>
<button class="btn btn-secondary btn-sm toggle-versions-btn" data-employee-index="<%= employeeIndex %>" data-week-index="<%= weekIndex %>">
<span class="toggle-icon">▼</span> Versionen
</button>
</div>
<!-- Level 3: Versionen -->
<div class="versions-container" data-employee-index="<%= employeeIndex %>" data-week-index="<%= weekIndex %>" style="display: none;">
<table class="timesheet-table versions-table">
<thead>
<tr>
<th>Version</th>
<th>Eingereicht am</th>
<th>Grund</th>
<th>Kommentar</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<% week.versions.forEach(function(ts) { %>
<tr class="timesheet-row" data-timesheet-id="<%= ts.id %>">
<td>
<span class="version-badge">
Version <%= ts.version || 1 %>
</span>
<% if (ts.pdf_downloaded_at) { %>
<%
let downloadedByName = 'Unbekannt';
if (ts.downloaded_by_firstname && ts.downloaded_by_lastname) {
downloadedByName = `${ts.downloaded_by_firstname} ${ts.downloaded_by_lastname}`;
} else if (ts.downloaded_by_firstname) {
downloadedByName = ts.downloaded_by_firstname;
} else if (ts.downloaded_by_lastname) {
downloadedByName = ts.downloaded_by_lastname;
}
%>
<span class="pdf-downloaded-marker" title="PDF wurde am <%= new Date(ts.pdf_downloaded_at).toLocaleString('de-DE') %> von <%= downloadedByName %> heruntergeladen">
✓ Heruntergeladen von <%= downloadedByName %>
</span>
<% } else { %>
<span class="pdf-not-downloaded-marker">⭕ Nicht heruntergeladen</span>
<% } %>
</td>
<td><%= new Date(ts.submitted_at).toLocaleString('de-DE') %></td>
<td>
<% if (ts.version_reason && ts.version_reason.trim() !== '') { %>
<span style="color: #666; font-size: 13px;" title="<%= ts.version_reason %>">
<%= ts.version_reason.length > 50 ? ts.version_reason.substring(0, 50) + '...' : ts.version_reason %>
</span>
<% } else { %>
<span style="color: #999; font-style: italic;">-</span>
<% } %>
</td>
<td>
<div class="admin-comment-cell" style="display: flex; gap: 8px; align-items: flex-start; min-width: 300px;">
<textarea
class="admin-comment-input"
data-timesheet-id="<%= ts.id %>"
rows="2"
style="flex: 1; min-width: 250px; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px; resize: vertical;"
placeholder="Kommentar..."><%= ts.admin_comment || '' %></textarea>
<button
class="btn btn-success btn-sm save-comment-btn"
data-timesheet-id="<%= ts.id %>"
style="white-space: nowrap; padding: 8px 12px;"
title="Kommentar speichern">
💾
</button>
</div>
</td>
<td>
<button class="btn btn-info btn-sm toggle-pdf-btn" data-timesheet-id="<%= ts.id %>">
<span class="arrow-icon">▶</span> PDF anzeigen
</button>
<a href="/api/timesheet/pdf/<%= ts.id %>" class="btn btn-primary btn-sm pdf-download-link" data-timesheet-id="<%= ts.id %>" target="_blank" download>
📥 PDF herunterladen
</a>
</td>
</tr>
<tr class="pdf-preview-row" data-timesheet-id="<%= ts.id %>" style="display: none;">
<td colspan="5">
<div class="pdf-preview-container">
<div class="pdf-preview-header">
<h3>PDF-Vorschau: <%= employee.user.firstname %> <%= employee.user.lastname %> - Version <%= ts.version || 1 %> - <%= new Date(ts.week_start).toLocaleDateString('de-DE') %> bis <%= new Date(ts.week_end).toLocaleDateString('de-DE') %></h3>
<button class="btn btn-secondary btn-sm close-pdf-btn" data-timesheet-id="<%= ts.id %>">
✕ Schließen
</button>
</div>
<div class="pdf-viewer-wrapper">
<iframe
src="/api/timesheet/pdf/<%= ts.id %>?inline=true"
class="pdf-iframe"
frameborder="0"
type="application/pdf"
allow="fullscreen">
<p>Ihr Browser unterstützt keine PDF-Vorschau.
<a href="/api/timesheet/pdf/<%= ts.id %>?inline=true" target="_blank">PDF in neuem Tab öffnen</a>
</p>
</iframe>
<div class="pdf-fallback">
<p>PDF wird geladen...</p>
<a href="/api/timesheet/pdf/<%= ts.id %>?inline=true" target="_blank" class="btn btn-primary">
PDF in neuem Tab öffnen
</a>
</div>
</div>
</div>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
</div>
<% }); %>
</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';
}
});
}
// Statistiken für alle Wochen initial laden
document.querySelectorAll('.group-stats').forEach(statsDiv => loadStatsForDiv(statsDiv));
// 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 = '';
} else {
el.textContent = (value >= 0 ? '+' : '') + formatHoursMin(value);
el.style.color = value >= 0 ? '#27ae60' : '#e74c3c';
}
});
} catch (error) {
console.error('Fehler beim Laden der aktuellen Überstunden:', error);
elements.forEach(el => {
el.textContent = '-';
el.style.color = '';
});
}
}
// 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);
}
});
} 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();
// Ü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 hoursText = formatHoursMin(c.correction_hours);
const reason = (c && c.reason != null) ? String(c.reason).trim() : '';
const li = document.createElement('li');
li.textContent = reason
? `Korrektur am ${dateText} ${hoursText} ${reason}`
: `Korrektur am ${dateText} ${hoursText}`;
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)
document.querySelectorAll('.toggle-employee-btn').forEach(btn => {
btn.addEventListener('click', function() {
const employeeIndex = this.dataset.employeeIndex;
const weeksContainer = document.querySelector(`.weeks-container[data-employee-index="${employeeIndex}"]`);
const toggleIcon = this.querySelector('.toggle-icon');
if (weeksContainer) {
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');
}
}
});
});
// Versionen-Gruppen auf-/zuklappen (innerhalb einer Kalenderwoche)
document.querySelectorAll('.toggle-versions-btn').forEach(btn => {
btn.addEventListener('click', function() {
const employeeIndex = this.dataset.employeeIndex;
const weekIndex = this.dataset.weekIndex;
const versionsContainer = document.querySelector(`.versions-container[data-employee-index="${employeeIndex}"][data-week-index="${weekIndex}"]`);
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
document.querySelectorAll('.pdf-download-link').forEach(link => {
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)
// Lade aktualisierte Daten vom Server
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) {
// Verwende Server-Daten, oder Fallback auf aktuellen User
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);
// Fallback: Verwende aktuellen User
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
document.querySelectorAll('.toggle-pdf-btn').forEach(btn => {
btn.addEventListener('click', function() {
const timesheetId = this.dataset.timesheetId;
const previewRow = document.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
document.querySelectorAll('.pdf-preview-row').forEach(row => {
if (row.dataset.timesheetId !== timesheetId) {
row.style.display = 'none';
const otherBtn = document.querySelector(`.toggle-pdf-btn[data-timesheet-id="${row.dataset.timesheetId}"]`);
if (otherBtn) {
otherBtn.querySelector('.arrow-icon').textContent = '▶';
otherBtn.classList.remove('active');
}
}
});
// Diese PDF-Vorschau öffnen
previewRow.style.display = 'table-row';
arrowIcon.textContent = '▼';
this.classList.add('active');
// Setze iframe src wenn noch nicht gesetzt (für besseres Laden)
if (iframe) {
const currentSrc = iframe.src || iframe.getAttribute('src');
if (!currentSrc || !currentSrc.includes('inline=true')) {
iframe.src = `/api/timesheet/pdf/${timesheetId}?inline=true`;
}
}
// Scroll zur PDF-Vorschau
setTimeout(() => {
previewRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 100);
} else {
// PDF-Vorschau schließen
if (previewRow) {
previewRow.style.display = 'none';
}
arrowIcon.textContent = '▶';
this.classList.remove('active');
}
});
});
// Schließen-Button
document.querySelectorAll('.close-pdf-btn').forEach(btn => {
btn.addEventListener('click', function() {
const timesheetId = this.dataset.timesheetId;
const previewRow = document.querySelector(`.pdf-preview-row[data-timesheet-id="${timesheetId}"]`);
const toggleBtn = document.querySelector(`.toggle-pdf-btn[data-timesheet-id="${timesheetId}"]`);
previewRow.style.display = 'none';
if (toggleBtn) {
toggleBtn.querySelector('.arrow-icon').textContent = '▶';
toggleBtn.classList.remove('active');
}
});
});
// Kommentar speichern
document.querySelectorAll('.save-comment-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const timesheetId = this.dataset.timesheetId;
const commentInput = document.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;
// Button deaktivieren während des Speicherns
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) {
// Erfolgs-Feedback
this.innerHTML = '✓';
this.title = 'Gespeichert!';
this.style.backgroundColor = '#28a745';
// Nach 2 Sekunden zurücksetzen
setTimeout(() => {
this.innerHTML = originalButtonText;
this.title = 'Kommentar speichern';
this.style.backgroundColor = '';
}, 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)
document.querySelectorAll('.admin-comment-input').forEach(input => {
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
const timesheetId = this.dataset.timesheetId;
const saveBtn = document.querySelector(`.save-comment-btn[data-timesheet-id="${timesheetId}"]`);
if (saveBtn) {
saveBtn.click();
}
}
});
});
// 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();
}
});
}
});
}
</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" %>';
}
});
}
});
</script>
<%- include('footer') %>
</body>
</html>