diff --git a/database.js b/database.js index 548a5d7..4f8a4ad 100644 --- a/database.js +++ b/database.js @@ -109,6 +109,16 @@ function initDatabase() { // Fehler ignorieren wenn Spalte bereits existiert }); + // Migration: pdf_path Spalte hinzufügen (Filesystem-Cache für eingefrorene PDFs) + db.run(`ALTER TABLE weekly_timesheets ADD COLUMN pdf_path TEXT`, (err) => { + // Fehler ignorieren wenn Spalte bereits existiert + if (err && !err.message.includes('duplicate column')) { + if (!err.message.includes('duplicate column name')) { + console.warn('Warnung beim Hinzufügen der Spalte pdf_path:', err.message); + } + } + }); + // Migration: version_reason Spalte hinzufügen db.run(`ALTER TABLE weekly_timesheets ADD COLUMN version_reason TEXT`, (err) => { // Fehler ignorieren wenn Spalte bereits existiert diff --git a/doc/README.md b/doc/README.md index 585d077..f097cfd 100644 --- a/doc/README.md +++ b/doc/README.md @@ -135,7 +135,7 @@ Nach der Installation sind folgende Benutzer verfügbar: 2. Sie sehen alle eingereichten Stundenzettel im Postfach 3. **PDF erstellen:** - Klicken Sie auf "PDF herunterladen" neben dem gewünschten Stundenzettel - - Die PDF wird automatisch generiert und heruntergeladen + - Die PDF wird serverseitig erzeugt, gespeichert (eingefrorener Stand) und heruntergeladen 4. **Überstunden korrigieren:** - In der Wochenansicht können Sie manuelle Korrekturen (Offset) für jeden Mitarbeiter vornehmen 5. **Kommentare hinzufügen:** @@ -148,6 +148,14 @@ Nach der Installation sind folgende Benutzer verfügbar: - Urlaubstage - Gesamtstundensumme +#### PDF-Speicherung (eingefrorener Stand) + +- **Versionstreue**: Die PDF basiert auf dem Stand zum Zeitpunkt der Einreichung (`submitted_at`) und wird danach **nicht mehr** aus aktuellen DB-Daten „neu berechnet“. +- **Storage**: Generierte Stundenzettel-PDFs werden auf dem Server im Filesystem abgelegt (Cache/Archiv). +- **Konfiguration**: Der Speicherort kann über die Umgebungsvariable `PDF_BASE_DIR` gesetzt werden. + - Wenn `PDF_BASE_DIR` nicht gesetzt ist, wird standardmäßig `./pdf-cache/` (relativ zum Projekt) verwendet. +- **Zuordnung**: Der Pfad wird in `weekly_timesheets.pdf_path` gespeichert. + ## Technologie-Stack - **Backend:** Node.js + Express diff --git a/docker-compose.yml b/docker-compose.yml index 792f05a..083d4ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,4 +12,5 @@ services: - NODE_ENV=production - DB_PATH=/app/data/stundenerfassung.db - TZ=Europe/Berlin + - PDF_BASE_DIR=/app/data/pdfs restart: unless-stopped diff --git a/helpers/pdf-paths.js b/helpers/pdf-paths.js new file mode 100644 index 0000000..dd86f3a --- /dev/null +++ b/helpers/pdf-paths.js @@ -0,0 +1,55 @@ +const path = require('path'); +const { getCalendarWeek } = require('./utils'); + +function getPdfBaseDir() { + const configured = process.env.PDF_BASE_DIR && String(process.env.PDF_BASE_DIR).trim(); + if (configured) return path.resolve(configured); + return path.resolve(path.join(__dirname, '..', 'pdf-cache')); +} + +function asciiSafe(value) { + const s = String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, ''); + return s.replace(/[^A-Za-z0-9_-]+/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, ''); +} + +function getYearFromDate(dateStr) { + const d = new Date(dateStr); + const year = d.getFullYear(); + return Number.isFinite(year) ? String(year) : 'unknown-year'; +} + +function buildTimesheetPdfLocation(timesheetRow) { + if (!timesheetRow) throw new Error('timesheetRow is required'); + const baseDir = getPdfBaseDir(); + + const userId = timesheetRow.user_id; + const year = getYearFromDate(timesheetRow.week_start); + const kw = getCalendarWeek(timesheetRow.week_start); + const version = timesheetRow.version || 1; + + const first = asciiSafe(timesheetRow.firstname || ''); + const last = asciiSafe(timesheetRow.lastname || ''); + const namePart = [last, first].filter(Boolean).join('_') || `user_${userId}`; + + const filename = `${namePart}_timesheet_${timesheetRow.id}_v${version}_KW${String(kw).padStart(2, '0')}_${year}.pdf`; + const relativePath = path.join(year, String(userId), filename); + const absolutePath = path.join(baseDir, relativePath); + + return { + baseDir, + year, + kw, + filename, + relativePath, + absolutePath, + directory: path.dirname(absolutePath), + }; +} + +module.exports = { + getPdfBaseDir, + buildTimesheetPdfLocation, +}; + diff --git a/public/css/style.css b/public/css/style.css index 560e289..041ab63 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1179,6 +1179,12 @@ table input[type="text"] { gap: 5px; } +/* Sortier-Buttons in der Verwaltungsansicht */ +.timesheet-sort-btn.active-sort { + font-weight: 600; + box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.25); +} + /* Wochen-Container (Level 2) */ .weeks-container { margin-top: 20px; diff --git a/routes/timesheet-routes.js b/routes/timesheet-routes.js index fe7318e..80d09cb 100644 --- a/routes/timesheet-routes.js +++ b/routes/timesheet-routes.js @@ -2,9 +2,16 @@ const { db } = require('../database'); const { requireAuth, requireVerwaltung } = require('../middleware/auth'); -const { generatePDF } = require('../services/pdf-service'); +const { generatePDFToBuffer } = require('../services/pdf-service'); const { getHolidaysForDateRange } = require('../services/feiertage-service'); const { hasRole } = require('../helpers/utils'); +const fs = require('fs'); +const { + fileExists, + savePdfBufferAtomic, + resolvePdfLocationFromTimesheetRow, + resolveAbsolutePathFromRelative, +} = require('../services/pdf-storage-service'); /** Plausibilitätsprüfung Projektnummer: 7 Ziffern, beginnt mit 5, dann YY (Jahr), dann 4 freie Ziffern (z.B. 5260001). */ function isValidProjectNumber(value) { @@ -553,18 +560,90 @@ function registerTimesheetRoutes(app) { const isVerwaltung = hasRole(req, 'verwaltung') || hasRole(req, 'admin'); // Prüfe ob User Verwaltung/Admin ist oder ob das Timesheet dem User gehört - db.get(`SELECT user_id FROM weekly_timesheets WHERE id = ?`, [timesheetId], (err, timesheet) => { - if (err || !timesheet) { - return res.status(404).send('Stundenzettel nicht gefunden'); + db.get( + `SELECT wt.id, wt.user_id, wt.week_start, wt.week_end, wt.version, wt.submitted_at, wt.pdf_path, + u.firstname, u.lastname + FROM weekly_timesheets wt + JOIN users u ON wt.user_id = u.id + WHERE wt.id = ?`, + [timesheetId], + async (err, timesheet) => { + if (err || !timesheet) { + return res.status(404).send('Stundenzettel nicht gefunden'); + } + + // Zugriff erlauben wenn Verwaltung/Admin ODER wenn Timesheet dem User gehört + if (!(isVerwaltung || timesheet.user_id === userId)) { + return res.status(403).send('Zugriff verweigert'); + } + + const inline = req.query.inline === 'true'; + + // Zielpfad bestimmen: DB-Pfad bevorzugen, sonst berechnen + const computedLocation = resolvePdfLocationFromTimesheetRow(timesheet); + const relativePath = timesheet.pdf_path ? String(timesheet.pdf_path) : computedLocation.relativePath; + const absolutePath = timesheet.pdf_path ? resolveAbsolutePathFromRelative(relativePath) : computedLocation.absolutePath; + const filename = computedLocation.filename; + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + if (inline) { + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('X-Frame-Options', 'SAMEORIGIN'); + } else { + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + + // Marker setzen, dass PDF heruntergeladen wurde (nur bei Download, nicht bei Vorschau) + const downloadedBy = req.session.userId; + if (downloadedBy) { + db.run( + `UPDATE weekly_timesheets + SET pdf_downloaded_at = CURRENT_TIMESTAMP, + pdf_downloaded_by = ? + WHERE id = ?`, + [downloadedBy, timesheetId], + (updateErr) => { + if (updateErr) { + console.error('Fehler beim Setzen des Download-Markers:', updateErr); + } + } + ); + } + } + + try { + const exists = await fileExists(absolutePath); + if (exists) { + const stream = fs.createReadStream(absolutePath); + stream.on('error', (streamErr) => { + console.error('Fehler beim Lesen der PDF-Datei:', streamErr); + if (!res.headersSent) res.status(500); + res.end('Fehler beim Lesen der PDF-Datei'); + }); + return stream.pipe(res); + } + + // Nicht vorhanden: versionstreu generieren, speichern, dann senden + const pdfBuffer = await generatePDFToBuffer(timesheetId, req); + await savePdfBufferAtomic(absolutePath, pdfBuffer); + + // Relativen Pfad in DB speichern (optional, Fehler nicht fatal) + if (!timesheet.pdf_path) { + db.run('UPDATE weekly_timesheets SET pdf_path = ? WHERE id = ?', [relativePath, timesheetId], (pathErr) => { + if (pathErr) { + console.warn('Warnung beim Speichern des PDF-Pfads:', pathErr.message); + } + }); + } + + return res.end(pdfBuffer); + } catch (e) { + console.error('Fehler beim Generieren/Speichern der PDF:', e); + if (!res.headersSent) res.status(500); + return res.end('Fehler beim Erstellen des PDFs'); + } } - - // Zugriff erlauben wenn Verwaltung/Admin ODER wenn Timesheet dem User gehört - if (isVerwaltung || timesheet.user_id === userId) { - generatePDF(timesheetId, req, res); - } else { - res.status(403).send('Zugriff verweigert'); - } - }); + ); }); } diff --git a/services/pdf-storage-service.js b/services/pdf-storage-service.js new file mode 100644 index 0000000..aa0ac91 --- /dev/null +++ b/services/pdf-storage-service.js @@ -0,0 +1,44 @@ +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); +const { buildTimesheetPdfLocation, getPdfBaseDir } = require('../helpers/pdf-paths'); + +async function ensureDirectoryExists(dirPath) { + await fsp.mkdir(dirPath, { recursive: true }); +} + +async function fileExists(filePath) { + try { + await fsp.access(filePath, fs.constants.F_OK); + return true; + } catch { + return false; + } +} + +async function savePdfBufferAtomic(targetPath, buffer) { + const dir = path.dirname(targetPath); + await ensureDirectoryExists(dir); + + const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`; + await fsp.writeFile(tmpPath, buffer); + await fsp.rename(tmpPath, targetPath); +} + +function resolvePdfLocationFromTimesheetRow(timesheetRow) { + return buildTimesheetPdfLocation(timesheetRow); +} + +function resolveAbsolutePathFromRelative(relativePath) { + const baseDir = getPdfBaseDir(); + return path.join(baseDir, relativePath); +} + +module.exports = { + ensureDirectoryExists, + fileExists, + savePdfBufferAtomic, + resolvePdfLocationFromTimesheetRow, + resolveAbsolutePathFromRelative, +}; + diff --git a/views/verwaltung.ejs b/views/verwaltung.ejs index 94c4da7..52eb76b 100644 --- a/views/verwaltung.ejs +++ b/views/verwaltung.ejs @@ -73,6 +73,45 @@ + +
+
+ Sortieren nach: +
+ + + + + + + + + + +
+
+
+ <% if (!groupedByEmployee || groupedByEmployee.length === 0) { %>

Keine eingereichten Stundenzettel vorhanden.

@@ -81,7 +120,14 @@
<% groupedByEmployee.forEach(function(employee, employeeIndex) { %> -
+
@@ -513,9 +559,16 @@ if (value === null) { el.textContent = '-'; el.style.color = ''; + el.dataset.overtimeValue = ''; } else { el.textContent = (value >= 0 ? '+' : '') + formatHoursMin(value); el.style.color = value >= 0 ? '#27ae60' : '#e74c3c'; + el.dataset.overtimeValue = String(value); + } + + const group = document.querySelector(`.employee-group[data-employee-id="${userId}"]`); + if (group) { + group.dataset.overtimeValue = el.dataset.overtimeValue || ''; } }); } catch (error) { @@ -523,6 +576,7 @@ elements.forEach(el => { el.textContent = '-'; el.style.color = ''; + el.dataset.overtimeValue = ''; }); } } @@ -573,6 +627,12 @@ } else { el.textContent = value.toFixed(1); } + // Wert für Sortierung am Element und an der Mitarbeiter-Gruppe hinterlegen + el.dataset.remainingVacation = value !== null ? String(value) : ''; + const group = el.closest('.employee-group') || document.querySelector(`.employee-group[data-employee-id="${el.dataset.userId}"]`); + if (group) { + group.dataset.remainingVacation = value !== null ? String(value) : ''; + } }); } catch (err) { console.error('Fehler beim Laden des verbleibenden Urlaubs für User', userId, err); @@ -585,6 +645,91 @@ loadSickDays(); loadCurrentOvertime(); loadRemainingVacation(); + + function sortEmployeeGroups(field, direction) { + const container = document.querySelector('.timesheet-groups'); + if (!container) return; + const groups = Array.from(container.querySelectorAll('.employee-group')); + if (groups.length === 0) return; + const dir = direction === 'desc' ? -1 : 1; + + function compareStrings(a, b) { + const sa = (a || '').toString().toLowerCase(); + const sb = (b || '').toString().toLowerCase(); + return sa.localeCompare(sb, 'de-DE'); + } + + function parseNumberOrNull(value) { + if (value === null || value === undefined || value === '') return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + + groups.sort((a, b) => { + let va; + let vb; + + if (field === 'name') { + const aLast = a.dataset.lastname || ''; + const aFirst = a.dataset.firstname || ''; + const bLast = b.dataset.lastname || ''; + const bFirst = b.dataset.firstname || ''; + const cmp = compareStrings(`${aLast} ${aFirst}`, `${bLast} ${bFirst}`); + return cmp * dir; + } + + if (field === 'firstname') { + const aFirst = a.dataset.firstname || ''; + const bFirst = b.dataset.firstname || ''; + const firstCmp = compareStrings(aFirst, bFirst); + if (firstCmp !== 0) return firstCmp * dir; + // Bei gleichem Vornamen nach Nachname sortieren + const aLast = a.dataset.lastname || ''; + const bLast = b.dataset.lastname || ''; + const lastCmp = compareStrings(aLast, bLast); + return lastCmp * dir; + } + + if (field === 'personalnummer') { + const aRaw = a.dataset.personalnummer || ''; + const bRaw = b.dataset.personalnummer || ''; + + if (!aRaw && !bRaw) return 0; + if (!aRaw) return 1; + if (!bRaw) return -1; + + const digitsOnly = /^\d+$/; + const aIsNum = digitsOnly.test(aRaw); + const bIsNum = digitsOnly.test(bRaw); + + if (aIsNum && bIsNum) { + va = Number(aRaw); + vb = Number(bRaw); + if (va === vb) return 0; + return va < vb ? -1 * dir : 1 * dir; + } + + const cmp = compareStrings(aRaw, bRaw); + return cmp * dir; + } + + if (field === 'overtime') { + va = parseNumberOrNull(a.dataset.overtimeValue); + vb = parseNumberOrNull(b.dataset.overtimeValue); + } else if (field === 'remainingVacation') { + va = parseNumberOrNull(a.dataset.remainingVacation); + vb = parseNumberOrNull(b.dataset.remainingVacation); + } + + if (va === null && vb === null) return 0; + if (va === null) return 1; + if (vb === null) return -1; + if (va === vb) return 0; + return va < vb ? -1 * dir : 1 * dir; + }); + + groups.forEach(group => container.appendChild(group)); + } // Überstunden-Korrektur-Historie laden/anzeigen function parseSqliteDatetime(value) { @@ -1057,6 +1202,30 @@ } }); } + + // Sortier-Buttons für Mitarbeiter-Gruppen + (function initTimesheetGroupSorting() { + const sortButtons = document.querySelectorAll('.timesheet-sort-btn'); + if (!sortButtons.length) return; + + let activeSortButton = null; + + sortButtons.forEach(btn => { + btn.addEventListener('click', function() { + const field = this.dataset.sortField; + const direction = this.dataset.sortDirection || 'asc'; + if (!field) return; + + sortEmployeeGroups(field, direction); + + if (activeSortButton && activeSortButton !== this) { + activeSortButton.classList.remove('active-sort'); + } + this.classList.add('active-sort'); + activeSortButton = this; + }); + }); + })();