Massdownload

This commit is contained in:
Carsten Graf
2026-01-23 17:29:46 +01:00
parent a0acd188a8
commit daf4f9b77c
13 changed files with 2135 additions and 104 deletions

View File

@@ -27,6 +27,16 @@
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label style="display: flex; align-items: flex-start; cursor: pointer;">
<input type="checkbox" name="remember_me" id="remember_me" style="margin-right: 8px; margin-top: 2px;">
<span style="display: flex; flex-direction: column;">
<span>Angemeldet bleiben</span>
<span style="font-size: 0.9em; color: #666;">(30 Tage)</span>
</span>
</label>
</div>
<button type="submit" class="btn btn-primary">Anmelden</button>
</form>
</div>

View File

@@ -29,6 +29,41 @@
<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>
@@ -53,6 +88,25 @@
<div style="display: inline-block; margin-right: 20px;">
<strong>Urlaubstage:</strong> <span><%= employee.user.urlaubstage || '-' %></span>
</div>
<div style="display: inline-flex; gap: 8px; align-items: center; margin-right: 20px;">
<strong>Überstunden-Offset:</strong>
<input
type="number"
step="0.25"
class="overtime-offset-input"
data-user-id="<%= employee.user.id %>"
value="<%= (employee.user.overtime_offset_hours !== undefined && employee.user.overtime_offset_hours !== null) ? employee.user.overtime_offset_hours : 0 %>"
style="width: 90px; padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px;"
title="Manuelle Korrektur (positiv oder negativ) in Stunden" />
<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-Offset speichern">
Speichern
</button>
</div>
<div style="display: inline-block; margin-right: 20px;">
<strong>Kalenderwochen:</strong> <span><%= employee.weeks.length %></span>
</div>
@@ -70,7 +124,20 @@
<div class="week-header">
<div class="week-info">
<div class="week-dates">
<strong>Kalenderwoche:</strong> <%= new Date(week.week_start).toLocaleDateString('de-DE') %> -
<%
// 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;">
@@ -205,13 +272,12 @@
</div>
<script>
// Statistiken für alle Wochen laden
document.querySelectorAll('.group-stats').forEach(statsDiv => {
async function loadStatsForDiv(statsDiv) {
const userId = statsDiv.dataset.userId;
const weekStart = statsDiv.dataset.weekStart;
const weekEnd = statsDiv.dataset.weekEnd;
fetch(`/api/verwaltung/user/${userId}/stats?week_start=${weekStart}&week_end=${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');
@@ -219,17 +285,26 @@
loadingDiv.style.display = 'none';
}
// vorherige Stats entfernen (wenn reloaded)
statsDiv.querySelectorAll('.stats-inline').forEach(n => n.remove());
// Statistiken anzeigen
let statsHTML = '';
if (data.overtimeHours !== undefined) {
statsHTML += `<div style="display: inline-block; margin-right: 20px;">
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
<strong>Überstunden:</strong> <span>${data.overtimeHours.toFixed(2)} h</span>
${data.overtimeTaken > 0 ? `<span style="color: #666;">(davon genommen: ${data.overtimeTaken.toFixed(2)} h)</span>` : ''}
${data.remainingOvertime !== data.overtimeHours ? `<span style="color: #28a745;">(verbleibend: ${data.remainingOvertime.toFixed(2)} h)</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>${Number(data.overtimeOffsetHours).toFixed(2)} h</span>
${data.remainingOvertimeWithOffset !== undefined ? `<span style="color: #28a745;">(verbleibend inkl. Offset: ${Number(data.remainingOvertimeWithOffset).toFixed(2)} h)</span>` : ''}
</div>`;
}
if (data.vacationDays !== undefined) {
statsHTML += `<div style="display: inline-block; margin-right: 20px;">
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
<strong>Urlaub genommen:</strong> <span>${data.vacationDays.toFixed(1)} Tag${data.vacationDays !== 1 ? 'e' : ''}</span>
${data.remainingVacation !== undefined ? `<span style="color: #28a745;">(verbleibend: ${data.remainingVacation.toFixed(1)} Tage)</span>` : ''}
</div>`;
@@ -247,6 +322,71 @@
loadingDiv.style.color = 'red';
}
});
}
// Statistiken für alle Wochen initial laden
document.querySelectorAll('.group-stats').forEach(statsDiv => loadStatsForDiv(statsDiv));
// Ü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;
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}/overtime-offset`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ overtime_offset_hours: 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.overtime_offset_hours !== undefined && data.overtime_offset_hours !== null)
? Number(data.overtime_offset_hours)
: 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)
@@ -469,6 +609,88 @@
}
});
});
// 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