Massdownload
This commit is contained in:
@@ -185,6 +185,14 @@ function initDatabase() {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
});
|
||||
|
||||
// Migration: Überstunden-Offset (manuelle Korrektur durch Verwaltung)
|
||||
db.run(`ALTER TABLE users ADD COLUMN overtime_offset_hours REAL DEFAULT 0`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
if (err && !err.message.includes('duplicate column')) {
|
||||
console.warn('Warnung beim Hinzufügen der Spalte overtime_offset_hours:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Migration: ping_ip Spalte hinzufügen
|
||||
db.run(`ALTER TABLE users ADD COLUMN ping_ip TEXT`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
|
||||
@@ -74,6 +74,49 @@ function formatDateTime(dateStr) {
|
||||
return date.toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
// Helper: Berechnet Kalenderwoche aus einem Datum (ISO 8601)
|
||||
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;
|
||||
}
|
||||
|
||||
// Helper: Berechnet week_start (Montag) und week_end (Sonntag) aus Jahr und Kalenderwoche (ISO 8601)
|
||||
function getWeekDatesFromCalendarWeek(year, weekNumber) {
|
||||
// ISO 8601: Woche beginnt am Montag, erste Woche enthält den 4. Januar
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4));
|
||||
const jan4Day = jan4.getUTCDay() || 7; // 1 = Montag, 7 = Sonntag
|
||||
const daysToMonday = jan4Day === 1 ? 0 : 1 - jan4Day;
|
||||
|
||||
// Montag der ersten Woche
|
||||
const firstMonday = new Date(Date.UTC(year, 0, 4 + daysToMonday));
|
||||
|
||||
// Montag der gewünschten Woche (Woche 1 = erste Woche)
|
||||
const weekMonday = new Date(firstMonday);
|
||||
weekMonday.setUTCDate(firstMonday.getUTCDate() + (weekNumber - 1) * 7);
|
||||
|
||||
// Sonntag der Woche (6 Tage nach Montag)
|
||||
const weekSunday = new Date(weekMonday);
|
||||
weekSunday.setUTCDate(weekMonday.getUTCDate() + 6);
|
||||
|
||||
// Format: YYYY-MM-DD
|
||||
const formatDate = (date) => {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
return {
|
||||
week_start: formatDate(weekMonday),
|
||||
week_end: formatDate(weekSunday)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasRole,
|
||||
getDefaultRole,
|
||||
@@ -82,5 +125,7 @@ module.exports = {
|
||||
calculateBreakMinutes,
|
||||
updateTotalHours,
|
||||
formatDate,
|
||||
formatDateTime
|
||||
formatDateTime,
|
||||
getCalendarWeek,
|
||||
getWeekDatesFromCalendarWeek
|
||||
};
|
||||
|
||||
1450
package-lock.json
generated
1450
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,8 @@
|
||||
"pdfkit": "^0.13.0",
|
||||
"ldapjs": "^3.0.7",
|
||||
"node-cron": "^3.0.3",
|
||||
"ping": "^0.4.4"
|
||||
"ping": "^0.4.4",
|
||||
"archiver": "^7.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
|
||||
38
reset-db.js
38
reset-db.js
@@ -38,6 +38,8 @@ try {
|
||||
personalnummer TEXT,
|
||||
wochenstunden REAL,
|
||||
urlaubstage REAL,
|
||||
overtime_offset_hours REAL DEFAULT 0,
|
||||
ping_ip TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`, (err) => {
|
||||
if (err) console.error('Fehler bei users:', err);
|
||||
@@ -71,6 +73,9 @@ try {
|
||||
activity5_project_number TEXT,
|
||||
overtime_taken_hours REAL,
|
||||
vacation_type TEXT,
|
||||
sick_status INTEGER DEFAULT 0,
|
||||
pause_start_time TEXT,
|
||||
pause_end_time TEXT,
|
||||
status TEXT DEFAULT 'offen',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -138,6 +143,21 @@ try {
|
||||
else console.log('✅ Tabelle ldap_sync_log erstellt');
|
||||
});
|
||||
|
||||
// Ping-Status-Tabelle für IP-basierte Zeiterfassung
|
||||
db.run(`CREATE TABLE ping_status (
|
||||
user_id INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
last_successful_ping DATETIME,
|
||||
failed_ping_count INTEGER DEFAULT 0,
|
||||
start_time_set INTEGER DEFAULT 0,
|
||||
first_failed_ping_time DATETIME,
|
||||
PRIMARY KEY (user_id, date),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)`, (err) => {
|
||||
if (err) console.error('Fehler bei ping_status:', err);
|
||||
else console.log('✅ Tabelle ping_status erstellt');
|
||||
});
|
||||
|
||||
// Warte bis alle Tabellen erstellt sind
|
||||
db.run('SELECT 1', (err) => {
|
||||
if (err) {
|
||||
@@ -147,29 +167,29 @@ try {
|
||||
|
||||
console.log('\n👤 Erstelle Standard-Benutzer...\n');
|
||||
|
||||
// Standard Admin-Benutzer
|
||||
// Standard Admin-Benutzer (Rolle als JSON-Array)
|
||||
const adminPassword = bcrypt.hashSync('admin123', 10);
|
||||
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role)
|
||||
VALUES (1, 'admin', ?, 'System', 'Administrator', 'admin')`,
|
||||
[adminPassword], (err) => {
|
||||
VALUES (1, 'admin', ?, 'System', 'Administrator', ?)`,
|
||||
[adminPassword, JSON.stringify(['admin'])], (err) => {
|
||||
if (err) console.error('Fehler beim Erstellen des Admin-Users:', err);
|
||||
else console.log('✅ Admin-User erstellt (admin / admin123)');
|
||||
});
|
||||
|
||||
// Standard Verwaltungs-Benutzer
|
||||
// Standard Verwaltungs-Benutzer (Rolle als JSON-Array)
|
||||
const verwaltungPassword = bcrypt.hashSync('verwaltung123', 10);
|
||||
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role)
|
||||
VALUES (2, 'verwaltung', ?, 'Verwaltung', 'User', 'verwaltung')`,
|
||||
[verwaltungPassword], (err) => {
|
||||
VALUES (2, 'verwaltung', ?, 'Verwaltung', 'User', ?)`,
|
||||
[verwaltungPassword, JSON.stringify(['verwaltung'])], (err) => {
|
||||
if (err) console.error('Fehler beim Erstellen des Verwaltungs-Users:', err);
|
||||
else console.log('✅ Verwaltungs-User erstellt (verwaltung / verwaltung123)');
|
||||
});
|
||||
|
||||
// Test-Mitarbeiter (optional)
|
||||
// Test-Mitarbeiter (optional, Rolle als JSON-Array)
|
||||
const mitarbeiterPassword = bcrypt.hashSync('test123', 10);
|
||||
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role, wochenstunden, urlaubstage)
|
||||
VALUES (3, 'test', ?, 'Test', 'Mitarbeiter', 'mitarbeiter', 40, 25)`,
|
||||
[mitarbeiterPassword], (err) => {
|
||||
VALUES (3, 'test', ?, 'Test', 'Mitarbeiter', ?, 40, 25)`,
|
||||
[mitarbeiterPassword, JSON.stringify(['mitarbeiter'])], (err) => {
|
||||
if (err && !err.message.includes('UNIQUE constraint')) {
|
||||
console.error('Fehler beim Erstellen des Test-Users:', err);
|
||||
} else if (!err) {
|
||||
|
||||
@@ -6,7 +6,7 @@ const LDAPService = require('../ldap-service');
|
||||
const { getDefaultRole } = require('../helpers/utils');
|
||||
|
||||
// Helper-Funktion für erfolgreiche Anmeldung
|
||||
function handleSuccessfulLogin(req, res, user) {
|
||||
function handleSuccessfulLogin(req, res, user, rememberMe = false) {
|
||||
// Rollen als JSON-Array parsen
|
||||
let roles = [];
|
||||
try {
|
||||
@@ -35,6 +35,13 @@ function handleSuccessfulLogin(req, res, user) {
|
||||
req.session.firstname = user.firstname;
|
||||
req.session.lastname = user.lastname;
|
||||
|
||||
// Session-Gültigkeit setzen: 30 Tage wenn "Angemeldet bleiben" aktiviert, sonst 24 Stunden
|
||||
if (rememberMe) {
|
||||
req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 Tage
|
||||
} else {
|
||||
req.session.cookie.maxAge = 24 * 60 * 60 * 1000; // 24 Stunden
|
||||
}
|
||||
|
||||
// Redirect: Immer zu Dashboard wenn Mitarbeiter-Rolle vorhanden, sonst basierend auf Standard-Rolle
|
||||
if (roles.includes('mitarbeiter')) {
|
||||
res.redirect('/dashboard');
|
||||
@@ -56,7 +63,8 @@ function registerAuthRoutes(app) {
|
||||
|
||||
// Login-Verarbeitung
|
||||
app.post('/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
const { username, password, remember_me } = req.body;
|
||||
const rememberMe = remember_me === 'on' || remember_me === true;
|
||||
|
||||
// Prüfe ob LDAP aktiviert ist
|
||||
LDAPService.getConfig((err, ldapConfig) => {
|
||||
@@ -78,7 +86,7 @@ function registerAuthRoutes(app) {
|
||||
|
||||
// Versuche lokale Authentifizierung
|
||||
if (bcrypt.compareSync(password, user.password)) {
|
||||
handleSuccessfulLogin(req, res, user);
|
||||
handleSuccessfulLogin(req, res, user, rememberMe);
|
||||
} else {
|
||||
res.render('login', { error: 'Ungültiger Benutzername oder Passwort' });
|
||||
}
|
||||
@@ -90,7 +98,7 @@ function registerAuthRoutes(app) {
|
||||
return res.render('login', { error: 'Benutzer nicht in der Datenbank gefunden. Bitte führen Sie eine LDAP-Synchronisation durch.' });
|
||||
}
|
||||
|
||||
handleSuccessfulLogin(req, res, user);
|
||||
handleSuccessfulLogin(req, res, user, rememberMe);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -102,7 +110,7 @@ function registerAuthRoutes(app) {
|
||||
}
|
||||
|
||||
if (bcrypt.compareSync(password, user.password)) {
|
||||
handleSuccessfulLogin(req, res, user);
|
||||
handleSuccessfulLogin(req, res, user, rememberMe);
|
||||
} else {
|
||||
res.render('login', { error: 'Ungültiger Benutzername oder Passwort' });
|
||||
}
|
||||
|
||||
@@ -60,8 +60,7 @@ function registerTimesheetRoutes(app) {
|
||||
total_hours = 0;
|
||||
finalStartTime = null;
|
||||
finalEndTime = null;
|
||||
finalActivity1Desc = 'Überstunden';
|
||||
finalActivity1Hours = 0;
|
||||
// Keine Tätigkeit setzen - Überstunden werden über overtime_taken_hours in der PDF angezeigt
|
||||
} else if (vacation_type === 'full') {
|
||||
total_hours = 8; // Ganzer Tag Urlaub = 8 Stunden normale Arbeitszeit
|
||||
} else if (isSick) {
|
||||
@@ -77,47 +76,8 @@ function registerTimesheetRoutes(app) {
|
||||
// Die 4 Stunden Urlaub werden nur in der Überstunden-Berechnung hinzugezählt
|
||||
}
|
||||
|
||||
// Überstunden-Logik: Bei weniger als vollem Tag - füge "Überstunden" als Tätigkeit hinzu
|
||||
if (overtimeValue > 0 && !isFullDayOvertime && fullDayHours > 0) {
|
||||
// Prüfe ob "Überstunden" bereits in activities vorhanden
|
||||
const activities = [
|
||||
{ desc: finalActivity1Desc, hours: finalActivity1Hours },
|
||||
{ desc: finalActivity2Desc, hours: activity2_hours },
|
||||
{ desc: finalActivity3Desc, hours: activity3_hours },
|
||||
{ desc: finalActivity4Desc, hours: activity4_hours },
|
||||
{ desc: finalActivity5Desc, hours: activity5_hours }
|
||||
];
|
||||
|
||||
let foundOvertime = false;
|
||||
for (let i = 0; i < activities.length; i++) {
|
||||
if (activities[i].desc && activities[i].desc.trim().toLowerCase() === 'überstunden') {
|
||||
foundOvertime = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn nicht gefunden, füge zur ersten freien Activity-Spalte hinzu
|
||||
if (!foundOvertime) {
|
||||
for (let i = 0; i < activities.length; i++) {
|
||||
if (!activities[i].desc || activities[i].desc.trim() === '') {
|
||||
// Setze diese Activity auf "Überstunden"
|
||||
if (i === 0) {
|
||||
finalActivity1Desc = 'Überstunden';
|
||||
// Stunden bleiben unverändert (werden vom User eingegeben)
|
||||
} else if (i === 1) {
|
||||
finalActivity2Desc = 'Überstunden';
|
||||
} else if (i === 2) {
|
||||
finalActivity3Desc = 'Überstunden';
|
||||
} else if (i === 3) {
|
||||
finalActivity4Desc = 'Überstunden';
|
||||
} else if (i === 4) {
|
||||
finalActivity5Desc = 'Überstunden';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Überstunden werden nicht mehr als Tätigkeit hinzugefügt
|
||||
// Sie werden über overtime_taken_hours in der PDF angezeigt
|
||||
|
||||
// Prüfen ob Eintrag existiert - verwende den neuesten Eintrag falls mehrere existieren
|
||||
db.get('SELECT id FROM timesheet_entries WHERE user_id = ? AND date = ? ORDER BY updated_at DESC, id DESC LIMIT 1',
|
||||
|
||||
@@ -125,13 +125,14 @@ function registerUserRoutes(app) {
|
||||
const userId = req.session.userId;
|
||||
|
||||
// User-Daten abrufen
|
||||
db.get('SELECT wochenstunden, urlaubstage FROM users WHERE id = ?', [userId], (err, user) => {
|
||||
db.get('SELECT wochenstunden, urlaubstage, overtime_offset_hours FROM users WHERE id = ?', [userId], (err, user) => {
|
||||
if (err || !user) {
|
||||
return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' });
|
||||
}
|
||||
|
||||
const wochenstunden = user.wochenstunden || 0;
|
||||
const urlaubstage = user.urlaubstage || 0;
|
||||
const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0;
|
||||
|
||||
// Alle eingereichten Wochen abrufen
|
||||
db.all(`SELECT DISTINCT week_start, week_end
|
||||
@@ -147,12 +148,13 @@ function registerUserRoutes(app) {
|
||||
// Wenn keine Wochen vorhanden
|
||||
if (!weeks || weeks.length === 0) {
|
||||
return res.json({
|
||||
currentOvertime: 0,
|
||||
currentOvertime: overtimeOffsetHours,
|
||||
remainingVacation: urlaubstage,
|
||||
totalOvertimeHours: 0,
|
||||
totalOvertimeTaken: 0,
|
||||
totalVacationDays: 0,
|
||||
urlaubstage: urlaubstage
|
||||
urlaubstage: urlaubstage,
|
||||
overtimeOffsetHours: overtimeOffsetHours
|
||||
});
|
||||
}
|
||||
|
||||
@@ -235,7 +237,7 @@ function registerUserRoutes(app) {
|
||||
// Woche nicht vollständig - überspringe diese Woche
|
||||
processedWeeks++;
|
||||
if (processedWeeks === weeks.length && !hasError) {
|
||||
const currentOvertime = totalOvertimeHours - totalOvertimeTaken;
|
||||
const currentOvertime = (totalOvertimeHours - totalOvertimeTaken) + overtimeOffsetHours;
|
||||
const remainingVacation = urlaubstage - totalVacationDays;
|
||||
|
||||
res.json({
|
||||
@@ -244,7 +246,8 @@ function registerUserRoutes(app) {
|
||||
totalOvertimeHours: totalOvertimeHours,
|
||||
totalOvertimeTaken: totalOvertimeTaken,
|
||||
totalVacationDays: totalVacationDays,
|
||||
urlaubstage: urlaubstage
|
||||
urlaubstage: urlaubstage,
|
||||
overtimeOffsetHours: overtimeOffsetHours
|
||||
});
|
||||
}
|
||||
return; // Überspringe diese Woche
|
||||
@@ -288,7 +291,7 @@ function registerUserRoutes(app) {
|
||||
|
||||
// Wenn alle Wochen verarbeitet wurden, Antwort senden
|
||||
if (processedWeeks === weeks.length && !hasError) {
|
||||
const currentOvertime = totalOvertimeHours - totalOvertimeTaken;
|
||||
const currentOvertime = (totalOvertimeHours - totalOvertimeTaken) + overtimeOffsetHours;
|
||||
const remainingVacation = urlaubstage - totalVacationDays;
|
||||
|
||||
res.json({
|
||||
@@ -297,7 +300,8 @@ function registerUserRoutes(app) {
|
||||
totalOvertimeHours: totalOvertimeHours,
|
||||
totalOvertimeTaken: totalOvertimeTaken,
|
||||
totalVacationDays: totalVacationDays,
|
||||
urlaubstage: urlaubstage
|
||||
urlaubstage: urlaubstage,
|
||||
overtimeOffsetHours: overtimeOffsetHours
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
// Verwaltung Routes
|
||||
|
||||
const archiver = require('archiver');
|
||||
const { db } = require('../database');
|
||||
const { requireVerwaltung } = require('../middleware/auth');
|
||||
const { getWeekDatesFromCalendarWeek } = require('../helpers/utils');
|
||||
const { generatePDFToBuffer } = require('../services/pdf-service');
|
||||
|
||||
// Routes registrieren
|
||||
function registerVerwaltungRoutes(app) {
|
||||
// Verwaltungs-Bereich
|
||||
app.get('/verwaltung', requireVerwaltung, (req, res) => {
|
||||
db.all(`
|
||||
SELECT wt.*, u.firstname, u.lastname, u.username, u.personalnummer, u.wochenstunden, u.urlaubstage,
|
||||
SELECT wt.*, u.firstname, u.lastname, u.username, u.personalnummer, u.wochenstunden, u.urlaubstage, u.overtime_offset_hours,
|
||||
dl.firstname as downloaded_by_firstname,
|
||||
dl.lastname as downloaded_by_lastname,
|
||||
(SELECT COUNT(*) FROM weekly_timesheets wt2
|
||||
@@ -39,7 +42,8 @@ function registerVerwaltungRoutes(app) {
|
||||
username: ts.username,
|
||||
personalnummer: ts.personalnummer,
|
||||
wochenstunden: ts.wochenstunden,
|
||||
urlaubstage: ts.urlaubstage
|
||||
urlaubstage: ts.urlaubstage,
|
||||
overtime_offset_hours: ts.overtime_offset_hours
|
||||
},
|
||||
weeks: {}
|
||||
};
|
||||
@@ -89,19 +93,40 @@ function registerVerwaltungRoutes(app) {
|
||||
});
|
||||
});
|
||||
|
||||
// API: Überstunden-Offset für einen User setzen (positiv/negativ)
|
||||
app.put('/api/verwaltung/user/:id/overtime-offset', requireVerwaltung, (req, res) => {
|
||||
const userId = req.params.id;
|
||||
const raw = req.body ? req.body.overtime_offset_hours : undefined;
|
||||
|
||||
// Leere Eingabe => 0
|
||||
const normalized = (raw === '' || raw === null || raw === undefined) ? 0 : parseFloat(raw);
|
||||
if (!Number.isFinite(normalized)) {
|
||||
return res.status(400).json({ error: 'Ungültiger Überstunden-Offset' });
|
||||
}
|
||||
|
||||
db.run('UPDATE users SET overtime_offset_hours = ? WHERE id = ?', [normalized, userId], (err) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Speichern des Überstunden-Offsets:', err);
|
||||
return res.status(500).json({ error: 'Fehler beim Speichern des Überstunden-Offsets' });
|
||||
}
|
||||
res.json({ success: true, overtime_offset_hours: normalized });
|
||||
});
|
||||
});
|
||||
|
||||
// API: Überstunden- und Urlaubsstatistiken für einen User abrufen
|
||||
app.get('/api/verwaltung/user/:id/stats', requireVerwaltung, (req, res) => {
|
||||
const userId = req.params.id;
|
||||
const { week_start, week_end } = req.query;
|
||||
|
||||
// User-Daten abrufen
|
||||
db.get('SELECT wochenstunden, urlaubstage FROM users WHERE id = ?', [userId], (err, user) => {
|
||||
db.get('SELECT wochenstunden, urlaubstage, overtime_offset_hours FROM users WHERE id = ?', [userId], (err, user) => {
|
||||
if (err || !user) {
|
||||
return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' });
|
||||
}
|
||||
|
||||
const wochenstunden = user.wochenstunden || 0;
|
||||
const urlaubstage = user.urlaubstage || 0;
|
||||
const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0;
|
||||
|
||||
// Einträge für die Woche abrufen
|
||||
db.all(`SELECT date, total_hours, overtime_taken_hours, vacation_type
|
||||
@@ -155,6 +180,7 @@ function registerVerwaltungRoutes(app) {
|
||||
const totalHoursWithVacation = totalHours + vacationHours;
|
||||
const overtimeHours = totalHoursWithVacation - sollStunden;
|
||||
const remainingOvertime = overtimeHours - overtimeTaken;
|
||||
const remainingOvertimeWithOffset = remainingOvertime + overtimeOffsetHours;
|
||||
|
||||
// Verbleibende Urlaubstage
|
||||
const remainingVacation = urlaubstage - vacationDays;
|
||||
@@ -167,6 +193,8 @@ function registerVerwaltungRoutes(app) {
|
||||
overtimeHours,
|
||||
overtimeTaken,
|
||||
remainingOvertime,
|
||||
overtimeOffsetHours,
|
||||
remainingOvertimeWithOffset,
|
||||
vacationDays,
|
||||
remainingVacation,
|
||||
workdays
|
||||
@@ -190,6 +218,127 @@ function registerVerwaltungRoutes(app) {
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// API: Massendownload aller PDFs für eine Kalenderwoche
|
||||
app.get('/api/verwaltung/bulk-download/:year/:week', requireVerwaltung, async (req, res) => {
|
||||
const year = parseInt(req.params.year);
|
||||
const week = parseInt(req.params.week);
|
||||
const downloadedBy = req.session.userId;
|
||||
|
||||
// Validierung
|
||||
if (!year || year < 2000 || year > 2100) {
|
||||
return res.status(400).json({ error: 'Ungültiges Jahr' });
|
||||
}
|
||||
if (!week || week < 1 || week > 53) {
|
||||
return res.status(400).json({ error: 'Ungültige Kalenderwoche (1-53)' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Berechne week_start und week_end aus Jahr und KW
|
||||
const { week_start, week_end } = getWeekDatesFromCalendarWeek(year, week);
|
||||
|
||||
// Hole alle eingereichten Stundenzettel für diese KW
|
||||
db.all(`SELECT wt.id, wt.user_id, wt.version, u.firstname, u.lastname
|
||||
FROM weekly_timesheets wt
|
||||
JOIN users u ON wt.user_id = u.id
|
||||
WHERE wt.status = 'eingereicht'
|
||||
AND wt.week_start = ?
|
||||
AND wt.week_end = ?
|
||||
ORDER BY wt.user_id, wt.version DESC`,
|
||||
[week_start, week_end],
|
||||
async (err, allTimesheets) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Abrufen der Stundenzettel:', err);
|
||||
return res.status(500).json({ error: 'Fehler beim Abrufen der Stundenzettel' });
|
||||
}
|
||||
|
||||
if (!allTimesheets || allTimesheets.length === 0) {
|
||||
return res.status(404).json({ error: `Keine eingereichten Stundenzettel für KW ${week}/${year} gefunden` });
|
||||
}
|
||||
|
||||
// Gruppiere nach user_id und wähle neueste Version pro User
|
||||
const latestByUser = {};
|
||||
allTimesheets.forEach(ts => {
|
||||
if (!latestByUser[ts.user_id] || ts.version > latestByUser[ts.user_id].version) {
|
||||
latestByUser[ts.user_id] = ts;
|
||||
}
|
||||
});
|
||||
|
||||
const timesheetsToDownload = Object.values(latestByUser);
|
||||
const timesheetIds = timesheetsToDownload.map(ts => ts.id);
|
||||
|
||||
// Erstelle ZIP
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="Stundenzettel_KW${String(week).padStart(2, '0')}_${year}.zip"`);
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
archive.on('error', (err) => {
|
||||
console.error('Fehler beim Erstellen des ZIP:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Fehler beim Erstellen des ZIP-Archivs' });
|
||||
}
|
||||
});
|
||||
|
||||
archive.pipe(res);
|
||||
|
||||
// Generiere PDFs sequenziell und füge sie zum ZIP hinzu
|
||||
const errors = [];
|
||||
for (const ts of timesheetsToDownload) {
|
||||
try {
|
||||
// Erstelle Mock-Request-Objekt für generatePDFToBuffer
|
||||
const mockReq = {
|
||||
session: { userId: downloadedBy },
|
||||
query: {}
|
||||
};
|
||||
|
||||
const pdfBuffer = await generatePDFToBuffer(ts.id, mockReq);
|
||||
|
||||
// Dateiname: Stundenzettel_KW{week}_{Nachname}{Vorname}_Version{version}.pdf
|
||||
const employeeName = `${ts.lastname}${ts.firstname}`.replace(/\s+/g, '');
|
||||
const filename = `Stundenzettel_KW${String(week).padStart(2, '0')}_${employeeName}_Version${ts.version}.pdf`;
|
||||
|
||||
archive.append(pdfBuffer, { name: filename });
|
||||
} catch (pdfError) {
|
||||
console.error(`Fehler beim Generieren des PDFs für Timesheet ${ts.id}:`, pdfError);
|
||||
errors.push(`Fehler bei ${ts.firstname} ${ts.lastname}: ${pdfError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Warte auf ZIP-Finalisierung und markiere dann PDFs als heruntergeladen
|
||||
archive.on('end', () => {
|
||||
if (timesheetIds.length > 0 && downloadedBy) {
|
||||
// Update alle betroffenen timesheets
|
||||
const placeholders = timesheetIds.map(() => '?').join(',');
|
||||
db.run(`UPDATE weekly_timesheets
|
||||
SET pdf_downloaded_at = CURRENT_TIMESTAMP,
|
||||
pdf_downloaded_by = ?
|
||||
WHERE id IN (${placeholders})`,
|
||||
[downloadedBy, ...timesheetIds],
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Markieren der PDFs als heruntergeladen:', err);
|
||||
} else {
|
||||
console.log(`Massendownload: ${timesheetIds.length} PDFs als heruntergeladen markiert`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Finalisiere ZIP (startet den Stream)
|
||||
archive.finalize();
|
||||
|
||||
// Wenn Fehler aufgetreten sind, aber ZIP trotzdem erstellt wurde, logge sie
|
||||
if (errors.length > 0) {
|
||||
console.warn('Einige PDFs konnten nicht generiert werden:', errors);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Massendownload:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Fehler beim Massendownload: ' + error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = registerVerwaltungRoutes;
|
||||
|
||||
@@ -16,11 +16,12 @@ app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
// Session-Konfiguration
|
||||
// Standard: 24 Stunden, kann in der Login-Route auf 30 Tage erhöht werden wenn "Angemeldet bleiben" aktiviert ist
|
||||
app.use(session({
|
||||
secret: 'stundenerfassung-geheim-2024',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 Stunden
|
||||
cookie: { maxAge: 24 * 60 * 60 * 1000 } // Standard: 24 Stunden
|
||||
}));
|
||||
|
||||
// Datenbank initialisieren
|
||||
|
||||
@@ -268,4 +268,211 @@ function generatePDF(timesheetId, req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { generatePDF };
|
||||
// PDF als Buffer generieren (für ZIP-Downloads)
|
||||
function generatePDFToBuffer(timesheetId, req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(`SELECT wt.*, u.firstname, u.lastname, u.username, u.wochenstunden
|
||||
FROM weekly_timesheets wt
|
||||
JOIN users u ON wt.user_id = u.id
|
||||
WHERE wt.id = ?`, [timesheetId], (err, timesheet) => {
|
||||
|
||||
if (err || !timesheet) {
|
||||
return reject(new Error('Stundenzettel nicht gefunden'));
|
||||
}
|
||||
|
||||
// Hole Einträge die zum Zeitpunkt der Einreichung existierten
|
||||
db.all(`SELECT * FROM timesheet_entries
|
||||
WHERE user_id = ? AND date >= ? AND date <= ?
|
||||
AND (
|
||||
(updated_at IS NOT NULL AND updated_at <= ?) OR
|
||||
(updated_at IS NULL AND created_at IS NOT NULL AND created_at <= ?) OR
|
||||
(updated_at IS NULL AND created_at IS NULL)
|
||||
)
|
||||
ORDER BY date, updated_at DESC, id DESC`,
|
||||
[timesheet.user_id, timesheet.week_start, timesheet.week_end,
|
||||
timesheet.submitted_at, timesheet.submitted_at],
|
||||
(err, allEntries) => {
|
||||
if (err) {
|
||||
return reject(new Error('Fehler beim Abrufen der Einträge'));
|
||||
}
|
||||
|
||||
// Filtere auf neuesten Eintrag pro Tag
|
||||
const entriesByDate = {};
|
||||
(allEntries || []).forEach(entry => {
|
||||
const existing = entriesByDate[entry.date];
|
||||
if (!existing) {
|
||||
entriesByDate[entry.date] = entry;
|
||||
} else {
|
||||
const existingTime = existing.updated_at ? new Date(existing.updated_at).getTime() : 0;
|
||||
const currentTime = entry.updated_at ? new Date(entry.updated_at).getTime() : 0;
|
||||
if (currentTime > existingTime || (currentTime === existingTime && entry.id > existing.id)) {
|
||||
entriesByDate[entry.date] = entry;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const entries = Object.values(entriesByDate).sort((a, b) => {
|
||||
return new Date(a.date) - new Date(b.date);
|
||||
});
|
||||
|
||||
const doc = new PDFDocument({ margin: 50 });
|
||||
const buffers = [];
|
||||
|
||||
doc.on('data', buffers.push.bind(buffers));
|
||||
doc.on('end', () => {
|
||||
const pdfBuffer = Buffer.concat(buffers);
|
||||
resolve(pdfBuffer);
|
||||
});
|
||||
doc.on('error', reject);
|
||||
|
||||
// Header
|
||||
const calendarWeek = getCalendarWeek(timesheet.week_start);
|
||||
doc.fontSize(20).text(`Stundenzettel für KW ${calendarWeek}`, { align: 'center' });
|
||||
doc.moveDown();
|
||||
|
||||
// Mitarbeiter-Info
|
||||
doc.fontSize(12);
|
||||
doc.text(`Mitarbeiter: ${timesheet.firstname} ${timesheet.lastname}`);
|
||||
doc.text(`Zeitraum: ${formatDate(timesheet.week_start)} - ${formatDate(timesheet.week_end)}`);
|
||||
doc.text(`Eingereicht am: ${formatDateTime(timesheet.submitted_at)}`);
|
||||
doc.moveDown();
|
||||
|
||||
// Tabelle - Basis-Informationen
|
||||
const tableTop = doc.y;
|
||||
const colWidths = [80, 80, 80, 60, 80];
|
||||
const headers = ['Datum', 'Start', 'Ende', 'Pause', 'Stunden'];
|
||||
|
||||
// Tabellen-Header
|
||||
doc.fontSize(10).font('Helvetica-Bold');
|
||||
let x = 50;
|
||||
headers.forEach((header, i) => {
|
||||
doc.text(header, x, tableTop, { width: colWidths[i], align: 'left' });
|
||||
x += colWidths[i];
|
||||
});
|
||||
|
||||
doc.moveDown();
|
||||
let y = doc.y;
|
||||
doc.moveTo(50, y).lineTo(430, y).stroke();
|
||||
doc.moveDown(0.5);
|
||||
|
||||
// Tabellen-Daten
|
||||
doc.font('Helvetica');
|
||||
let totalHours = 0;
|
||||
let vacationHours = 0;
|
||||
|
||||
entries.forEach((entry) => {
|
||||
y = doc.y;
|
||||
x = 50;
|
||||
|
||||
const rowData = [
|
||||
formatDate(entry.date),
|
||||
entry.start_time || '-',
|
||||
entry.end_time || '-',
|
||||
entry.break_minutes ? `${entry.break_minutes} min` : '-',
|
||||
entry.total_hours ? entry.total_hours.toFixed(2) + ' h' : '-'
|
||||
];
|
||||
|
||||
rowData.forEach((data, i) => {
|
||||
doc.text(data, x, y, { width: colWidths[i], align: 'left' });
|
||||
x += colWidths[i];
|
||||
});
|
||||
|
||||
// Tätigkeiten sammeln
|
||||
const activities = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const desc = entry[`activity${i}_desc`];
|
||||
const hours = entry[`activity${i}_hours`];
|
||||
const projectNumber = entry[`activity${i}_project_number`];
|
||||
if (desc && desc.trim() && hours > 0) {
|
||||
activities.push({
|
||||
desc: desc.trim(),
|
||||
hours: parseFloat(hours),
|
||||
projectNumber: projectNumber ? projectNumber.trim() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Tätigkeiten anzeigen
|
||||
if (activities.length > 0) {
|
||||
doc.moveDown(0.3);
|
||||
doc.fontSize(9).font('Helvetica-Oblique');
|
||||
doc.text('Tätigkeiten:', 60, doc.y, { width: 380 });
|
||||
doc.moveDown(0.2);
|
||||
|
||||
activities.forEach((activity, idx) => {
|
||||
let activityText = `${idx + 1}. ${activity.desc}`;
|
||||
if (activity.projectNumber) {
|
||||
activityText += ` (Projekt: ${activity.projectNumber})`;
|
||||
}
|
||||
activityText += ` - ${activity.hours.toFixed(2)} h`;
|
||||
doc.fontSize(9).font('Helvetica');
|
||||
doc.text(activityText, 70, doc.y, { width: 360 });
|
||||
doc.moveDown(0.2);
|
||||
});
|
||||
doc.fontSize(10);
|
||||
}
|
||||
|
||||
// Überstunden und Urlaub anzeigen
|
||||
const overtimeInfo = [];
|
||||
if (entry.overtime_taken_hours && parseFloat(entry.overtime_taken_hours) > 0) {
|
||||
overtimeInfo.push(`Überstunden genommen: ${parseFloat(entry.overtime_taken_hours).toFixed(2)} h`);
|
||||
}
|
||||
if (entry.vacation_type) {
|
||||
const vacationText = entry.vacation_type === 'full' ? 'Ganzer Tag' : 'Halber Tag';
|
||||
overtimeInfo.push(`Urlaub: ${vacationText}`);
|
||||
}
|
||||
|
||||
if (overtimeInfo.length > 0) {
|
||||
doc.moveDown(0.2);
|
||||
doc.fontSize(9).font('Helvetica-Oblique');
|
||||
overtimeInfo.forEach((info, idx) => {
|
||||
doc.text(info, 70, doc.y, { width: 360 });
|
||||
doc.moveDown(0.15);
|
||||
});
|
||||
doc.fontSize(10);
|
||||
}
|
||||
|
||||
if (entry.total_hours) {
|
||||
totalHours += entry.total_hours;
|
||||
}
|
||||
|
||||
if (entry.vacation_type === 'full') {
|
||||
vacationHours += 8;
|
||||
} else if (entry.vacation_type === 'half') {
|
||||
vacationHours += 4;
|
||||
}
|
||||
|
||||
doc.moveDown(0.5);
|
||||
|
||||
y = doc.y;
|
||||
doc.moveTo(50, y).lineTo(430, y).stroke();
|
||||
doc.moveDown(0.3);
|
||||
});
|
||||
|
||||
// Summe
|
||||
y = doc.y;
|
||||
doc.moveTo(50, y).lineTo(550, y).stroke();
|
||||
doc.moveDown(0.5);
|
||||
doc.font('Helvetica-Bold');
|
||||
doc.text(`Gesamtstunden: ${totalHours.toFixed(2)} h`, 50, doc.y);
|
||||
|
||||
const wochenstunden = timesheet.wochenstunden || 0;
|
||||
const overtimeHours = totalHours - wochenstunden;
|
||||
|
||||
doc.moveDown(0.3);
|
||||
doc.font('Helvetica-Bold');
|
||||
if (overtimeHours > 0) {
|
||||
doc.text(`Überstunden: +${overtimeHours.toFixed(2)} h`, 50, doc.y);
|
||||
} else if (overtimeHours < 0) {
|
||||
doc.text(`Überstunden: ${overtimeHours.toFixed(2)} h`, 50, doc.y);
|
||||
} else {
|
||||
doc.text(`Überstunden: 0.00 h`, 50, doc.y);
|
||||
}
|
||||
|
||||
doc.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { generatePDF, generatePDFToBuffer };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user