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 @@
+ +Keine eingereichten Stundenzettel vorhanden.
@@ -81,7 +120,14 @@