From 8152aab15b888c5a82b2f1b55e414de3cad4bbe1 Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Wed, 18 Mar 2026 17:24:13 +0100 Subject: [PATCH] PDF View i Adminbereich --- public/js/admin.js | 249 ++++++++++++++++++++++++++++++++++++++ rebuild-timesheet-pdfs.js | 129 ++++++++++++++++++++ routes/admin-routes.js | 173 ++++++++++++++++++++++++++ views/admin.ejs | 48 ++++++++ 4 files changed, 599 insertions(+) create mode 100644 rebuild-timesheet-pdfs.js diff --git a/public/js/admin.js b/public/js/admin.js index 3947b43..4d592d4 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -304,8 +304,257 @@ document.addEventListener('DOMContentLoaded', function() { } }); } + + // PDF-Archiv (Timesheets) + const pdfYearSelect = document.getElementById('pdfYearSelect'); + const pdfUserSelect = document.getElementById('pdfUserSelect'); + const pdfFilesContainer = document.getElementById('pdfFilesContainer'); + const pdfRefreshBtn = document.getElementById('pdfRefreshBtn'); + + if (pdfYearSelect && pdfUserSelect && pdfFilesContainer) { + if (pdfRefreshBtn) { + pdfRefreshBtn.addEventListener('click', function() { + clearPdfPreview(); + loadPdfYears(); + }); + } + + pdfYearSelect.addEventListener('change', function() { + clearPdfPreview(); + const year = this.value; + if (year) loadPdfUsers(year); + }); + + pdfUserSelect.addEventListener('change', function() { + clearPdfPreview(); + const year = pdfYearSelect.value; + const userId = this.value; + if (year && userId) loadPdfFiles(year, userId); + }); + + loadPdfYears(); + } }); +function setPdfStatus(text, color) { + const el = document.getElementById('pdfArchiveStatus'); + if (!el) return; + el.textContent = text || ''; + el.style.color = color || ''; +} + +function clearPdfPreview() { + const container = document.getElementById('pdfPreviewContainer'); + const iframe = document.getElementById('pdfPreviewIframe'); + if (container) container.style.display = 'none'; + if (iframe) iframe.src = ''; +} + +function formatBytes(bytes) { + if (bytes === null || bytes === undefined || !Number.isFinite(Number(bytes))) return '-'; + const num = Number(bytes); + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let n = num; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i += 1; + } + const fixed = i === 0 ? 0 : 1; + return `${n.toFixed(fixed)} ${units[i]}`; +} + +async function loadPdfYears() { + const pdfYearSelect = document.getElementById('pdfYearSelect'); + const pdfUserSelect = document.getElementById('pdfUserSelect'); + const pdfFilesContainer = document.getElementById('pdfFilesContainer'); + if (!pdfYearSelect || !pdfUserSelect || !pdfFilesContainer) return; + + setPdfStatus('Lade PDF-Jahre...', 'blue'); + pdfYearSelect.innerHTML = ''; + pdfUserSelect.innerHTML = ''; + pdfUserSelect.disabled = true; + pdfFilesContainer.innerHTML = ''; + clearPdfPreview(); + + try { + const response = await fetch('/admin/api/pdfs/years'); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result && result.error ? result.error : 'Fehler beim Laden der PDF-Jahre'); + } + + const years = Array.isArray(result.years) ? result.years : []; + if (years.length === 0) { + pdfYearSelect.innerHTML = ''; + setPdfStatus('Keine PDF-Dateien im Archiv gefunden.', ''); + return; + } + + pdfYearSelect.innerHTML = ''; + years.forEach(year => { + const opt = document.createElement('option'); + opt.value = year; + opt.textContent = year; + pdfYearSelect.appendChild(opt); + }); + + const firstYear = years[0]; + pdfYearSelect.value = firstYear; + setPdfStatus('Jahre geladen. Lade User...', ''); + await loadPdfUsers(firstYear); + } catch (error) { + console.error('Fehler beim Laden der PDF-Jahre:', error); + setPdfStatus('Fehler beim Laden der PDF-Jahre', 'red'); + pdfYearSelect.innerHTML = ''; + pdfUserSelect.disabled = true; + } +} + +async function loadPdfUsers(year) { + const pdfYearSelect = document.getElementById('pdfYearSelect'); + const pdfUserSelect = document.getElementById('pdfUserSelect'); + const pdfFilesContainer = document.getElementById('pdfFilesContainer'); + if (!pdfYearSelect || !pdfUserSelect || !pdfFilesContainer) return; + + setPdfStatus(`Lade PDFs für ${year}...`, 'blue'); + pdfUserSelect.disabled = true; + pdfUserSelect.innerHTML = ''; + pdfFilesContainer.innerHTML = ''; + clearPdfPreview(); + + try { + const response = await fetch(`/admin/api/pdfs/users?year=${encodeURIComponent(year)}`); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result && result.error ? result.error : 'Fehler beim Laden der User-Ordner'); + } + + const users = Array.isArray(result.users) + ? result.users + : (Array.isArray(result.userIds) ? result.userIds.map(id => ({ id, name: id })) : []); + + const userIds = users.map(u => u.id); + if (users.length === 0) { + pdfUserSelect.innerHTML = ''; + pdfUserSelect.disabled = true; + setPdfStatus(`Keine PDF-Dateien für das Jahr ${year} gefunden.`, ''); + return; + } + + pdfUserSelect.disabled = false; + pdfUserSelect.innerHTML = ''; + users.forEach(user => { + const userId = user.id; + const opt = document.createElement('option'); + opt.value = userId; + opt.textContent = user.name || userId; + pdfUserSelect.appendChild(opt); + }); + + const firstUserId = userIds[0]; + pdfUserSelect.value = firstUserId; + setPdfStatus('User geladen. Lade Dateien...', ''); + await loadPdfFiles(year, firstUserId); + } catch (error) { + console.error('Fehler beim Laden der PDF-User:', error); + setPdfStatus('Fehler beim Laden der PDF-User', 'red'); + pdfUserSelect.innerHTML = ''; + pdfUserSelect.disabled = true; + } +} + +async function loadPdfFiles(year, userId) { + const pdfFilesContainer = document.getElementById('pdfFilesContainer'); + if (!pdfFilesContainer) return; + + setPdfStatus('Lade PDF-Dateien...', 'blue'); + pdfFilesContainer.innerHTML = ''; + clearPdfPreview(); + + try { + const response = await fetch(`/admin/api/pdfs/files?year=${encodeURIComponent(year)}&userId=${encodeURIComponent(userId)}`); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result && result.error ? result.error : 'Fehler beim Laden der PDF-Dateien'); + } + + const files = Array.isArray(result.files) ? result.files : []; + if (files.length === 0) { + pdfFilesContainer.innerHTML = '

Keine PDF-Dateien gefunden.

'; + setPdfStatus(`Keine PDFs für User ${userId} (${year}).`, ''); + return; + } + + const rowsHtml = files + .map(file => { + const mtimeLabel = file.mtime ? new Date(file.mtime).toLocaleString('de-DE') : '-'; + const sizeLabel = formatBytes(file.size); + // name ist serverseitig streng gefiltert; hier daher direkt in JS-String verwenden. + return ` + + + ${file.name} + + ${sizeLabel} + ${mtimeLabel} + + + + + + `; + }) + .join(''); + + pdfFilesContainer.innerHTML = ` + + + + + + + + + + + ${rowsHtml} + +
DateiGrößeÄnderungAktionen
+ `; + + setPdfStatus(`${files.length} PDF(s) geladen.`, 'green'); + } catch (error) { + console.error('Fehler beim Laden der PDF-Dateien:', error); + pdfFilesContainer.innerHTML = '

Fehler beim Laden der PDF-Dateien.

'; + setPdfStatus('Fehler beim Laden der PDF-Dateien', 'red'); + } +} + +function previewPdf(year, userId, name) { + const iframe = document.getElementById('pdfPreviewIframe'); + const container = document.getElementById('pdfPreviewContainer'); + if (!iframe || !container) return; + + setPdfStatus(`Vorschau: ${name}`, 'blue'); + container.style.display = 'block'; + iframe.src = `/admin/api/pdfs/file?year=${encodeURIComponent(year)}&userId=${encodeURIComponent(userId)}&name=${encodeURIComponent(name)}&inline=true`; +} + +function downloadPdf(year, userId, name) { + const url = `/admin/api/pdfs/file?year=${encodeURIComponent(year)}&userId=${encodeURIComponent(userId)}&name=${encodeURIComponent(name)}&inline=false`; + + setPdfStatus('Download gestartet...', ''); + const link = document.createElement('a'); + link.href = url; + link.target = '_blank'; + link.rel = 'noopener'; + link.click(); +} + async function loadTimesheetDuplicates() { const container = document.getElementById('timesheetDuplicatesContainer'); if (!container) return; diff --git a/rebuild-timesheet-pdfs.js b/rebuild-timesheet-pdfs.js new file mode 100644 index 0000000..20ca9f8 --- /dev/null +++ b/rebuild-timesheet-pdfs.js @@ -0,0 +1,129 @@ +// scripts/rebuild-timesheet-pdfs.js + +// Läuft im Projekt root: node scripts/rebuild-timesheet-pdfs.js +// Erzeugt (bzw. speichert) fehlende PDFs für alle eingereichten weekly_timesheets, +// basierend auf der bestehenden versionstreuen Generierungslogik. + +const path = require('path'); +const { db } = require('../database'); +const { generatePDFToBuffer } = require('../services/pdf-service'); +const { + resolvePdfLocationFromTimesheetRow, + fileExists, + savePdfBufferAtomic, +} = require('../services/pdf-storage-service'); + +async function getAllSubmittedTimesheets() { + const sql = ` + SELECT wt.id, wt.user_id, wt.week_start, wt.week_end, wt.version, + wt.pdf_path, + u.firstname, u.lastname, u.username + FROM weekly_timesheets wt + JOIN users u ON u.id = wt.user_id + WHERE wt.status = 'eingereicht' + ORDER BY wt.week_start, wt.user_id, wt.version + `; + + return new Promise((resolve, reject) => { + db.all(sql, [], (err, rows) => { + if (err) return reject(err); + resolve(rows || []); + }); + }); +} + +async function rebuildMissingPdfs() { + const all = await getAllSubmittedTimesheets(); + if (!all.length) { + console.log('Keine eingereichten weekly_timesheets gefunden.'); + return; + } + + console.log(`Gefundene eingereichte weekly_timesheets: ${all.length}`); + + let processed = 0; + let created = 0; + let skippedExists = 0; + let failed = 0; + + for (const row of all) { + processed += 1; + + try { + const loc = resolvePdfLocationFromTimesheetRow(row); + const absolutePath = loc.absolutePath; + + const exists = await fileExists(absolutePath); + if (exists) { + skippedExists += 1; + if (processed % 50 === 0) { + console.log( + `[${processed}/${all.length}] Timesheet #${row.id}: PDF existiert bereits -> übersprungen.`, + ); + } + continue; + } + + console.log( + `[${processed}/${all.length}] Erzeuge PDF für Timesheet #${row.id} (User ${row.user_id}, KW: ${row.week_start}..${row.week_end}, Version ${row.version})`, + ); + + // Mock-Request für generatePDFToBuffer – analog zu verwaltung-routes bulk-download + const mockReq = { + session: { + userId: null, // hier optional eine system-User-ID einsetzen, wenn benötigt + }, + query: {}, + }; + + const buffer = await generatePDFToBuffer(row.id, mockReq); + await savePdfBufferAtomic(absolutePath, buffer); + + // Optional: pdf_path in DB setzen, falls noch leer + if (!row.pdf_path) { + const relativePath = loc.relativePath; + await new Promise((resolve, reject) => { + db.run( + 'UPDATE weekly_timesheets SET pdf_path = ? WHERE id = ?', + [relativePath, row.id], + (err) => { + if (err) return reject(err); + resolve(); + }, + ); + }); + } + + created += 1; + } catch (err) { + failed += 1; + console.error( + `Fehler beim Erzeugen der PDF für Timesheet #${row.id}:`, + err && err.message ? err.message : err, + ); + } + } + + console.log('-----------------------------'); + console.log('Rebuild abgeschlossen.'); + console.log(`Gesamt: ${processed}`); + console.log(`Neu erzeugt: ${created}`); + console.log(`Bereits vorhanden: ${skippedExists}`); + console.log(`Fehler: ${failed}`); +} + +// Direktstart, wenn Datei via node aufgerufen wird +if (require.main === module) { + rebuildMissingPdfs() + .then(() => { + console.log('Fertig.'); + // DB-Verbindung optional schließen, falls du willst: + // db.close(); + }) + .catch((err) => { + console.error('Unerwarteter Fehler beim Rebuild:', err); + process.exitCode = 1; + }); +} + +module.exports = { rebuildMissingPdfs }; \ No newline at end of file diff --git a/routes/admin-routes.js b/routes/admin-routes.js index 2c98689..e815c92 100644 --- a/routes/admin-routes.js +++ b/routes/admin-routes.js @@ -1,9 +1,24 @@ // Admin Routes const bcrypt = require('bcryptjs'); +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); const { db } = require('../database'); const { requireAdmin } = require('../middleware/auth'); const { testMssqlConnection } = require('../services/mssql-infra-service'); +const { getPdfBaseDir } = require('../helpers/pdf-paths'); + +const YEAR_RE = /^\d{4}$/; +const USER_ID_RE = /^\d+$/; +const PDF_NAME_RE = /^[A-Za-z0-9._-]+\.pdf$/i; + +function resolveWithinBaseDir(baseDirResolved, ...parts) { + const targetPath = path.resolve(baseDirResolved, ...parts); + const prefix = baseDirResolved.endsWith(path.sep) ? baseDirResolved : baseDirResolved + path.sep; + if (targetPath !== baseDirResolved && !targetPath.startsWith(prefix)) return null; + return targetPath; +} // Routes registrieren function registerAdminRoutes(app) { @@ -412,6 +427,164 @@ function registerAdminRoutes(app) { res.json({ success: true }); }); }); + + // ----------------------- + // PDF-Archiv (Admin) + // ----------------------- + + // Liste vorhandener Jahre unter PDF_BASE_DIR + app.get('/admin/api/pdfs/years', requireAdmin, async (req, res) => { + try { + const baseDirResolved = path.resolve(getPdfBaseDir()); + const entries = await fsp.readdir(baseDirResolved, { withFileTypes: true }).catch(() => []); + const years = entries + .filter(d => d.isDirectory() && YEAR_RE.test(d.name)) + .map(d => d.name) + .sort((a, b) => Number(b) - Number(a)); + res.json({ years }); + } catch (err) { + console.error('Fehler beim Laden der PDF-Jahre:', err); + res.status(500).json({ error: 'Fehler beim Laden der PDF-Jahre' }); + } + }); + + // Liste vorhandener User-Ordner für ein Jahr + app.get('/admin/api/pdfs/users', requireAdmin, async (req, res) => { + const year = String(req.query.year || '').trim(); + if (!YEAR_RE.test(year)) return res.status(400).json({ error: 'Ungültiges Jahr' }); + + try { + const baseDirResolved = path.resolve(getPdfBaseDir()); + const yearDir = resolveWithinBaseDir(baseDirResolved, year); + if (!yearDir) return res.status(400).json({ error: 'Ungültige Pfadanfrage' }); + + const entries = await fsp.readdir(yearDir, { withFileTypes: true }).catch(() => []); + const userIds = entries + .filter(d => d.isDirectory() && USER_ID_RE.test(d.name)) + .map(d => d.name) + .sort((a, b) => Number(b) - Number(a)); + + // Zusätzlich Namen aus DB laden, damit der Dropdown menschenlesbar ist. + let usersWithNames = []; + if (userIds.length > 0) { + const placeholders = userIds.map(() => '?').join(','); + const params = userIds.map(id => parseInt(id, 10)); + + const rows = await new Promise((resolve, reject) => { + db.all( + `SELECT id, firstname, lastname, username FROM users WHERE id IN (${placeholders})`, + params, + (err2, r) => { + if (err2) return reject(err2); + resolve(r || []); + }, + ); + }); + + const map = new Map(); + (rows || []).forEach(u => { + const name = [u.firstname, u.lastname].filter(Boolean).join(' ').trim(); + map.set(String(u.id), name || u.username || String(u.id)); + }); + + usersWithNames = userIds.map(id => ({ + id, + name: map.get(String(id)) || String(id), + })); + } + + res.json({ userIds, users: usersWithNames }); + } catch (err) { + console.error('Fehler beim Laden der PDF-User-Ordner:', err); + res.status(500).json({ error: 'Fehler beim Laden der PDF-User-Ordner' }); + } + }); + + // Liste PDFs für year/userId + app.get('/admin/api/pdfs/files', requireAdmin, async (req, res) => { + const year = String(req.query.year || '').trim(); + const userId = String(req.query.userId || '').trim(); + + if (!YEAR_RE.test(year)) return res.status(400).json({ error: 'Ungültiges Jahr' }); + if (!USER_ID_RE.test(userId)) return res.status(400).json({ error: 'Ungültiger User' }); + + try { + const baseDirResolved = path.resolve(getPdfBaseDir()); + const userDir = resolveWithinBaseDir(baseDirResolved, year, userId); + if (!userDir) return res.status(400).json({ error: 'Ungültige Pfadanfrage' }); + + const entries = await fsp.readdir(userDir, { withFileTypes: true }).catch(() => []); + const pdfEntries = entries.filter(d => d.isFile() && PDF_NAME_RE.test(d.name)); + + const filesWithMeta = await Promise.all( + pdfEntries.map(async d => { + const stat = await fsp.stat(path.join(userDir, d.name)); + return { + name: d.name, + size: stat.size, + mtime: stat.mtime.toISOString() + }; + }) + ); + + filesWithMeta.sort((a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime()); + res.json({ files: filesWithMeta }); + } catch (err) { + console.error('Fehler beim Laden der PDF-Dateiliste:', err); + res.status(500).json({ error: 'Fehler beim Laden der PDF-Dateiliste' }); + } + }); + + // Streamt eine einzelne PDF (Vorschau oder Download) + app.get('/admin/api/pdfs/file', requireAdmin, async (req, res) => { + const year = String(req.query.year || '').trim(); + const userId = String(req.query.userId || '').trim(); + const nameRaw = String(req.query.name || ''); + const inline = String(req.query.inline || 'false') === 'true'; + + if (!YEAR_RE.test(year)) return res.status(400).json({ error: 'Ungültiges Jahr' }); + if (!USER_ID_RE.test(userId)) return res.status(400).json({ error: 'Ungültiger User' }); + + const safeName = path.basename(nameRaw); + if (!safeName || safeName !== nameRaw || !PDF_NAME_RE.test(safeName)) { + return res.status(400).json({ error: 'Ungültiger Dateiname' }); + } + + try { + const baseDirResolved = path.resolve(getPdfBaseDir()); + const absolutePath = resolveWithinBaseDir(baseDirResolved, year, userId, safeName); + if (!absolutePath) return res.status(400).json({ error: 'Ungültige Pfadanfrage' }); + + let stat; + try { + stat = await fsp.stat(absolutePath); + } catch (err) { + if (err && err.code === 'ENOENT') return res.status(404).json({ error: 'PDF nicht gefunden' }); + throw err; + } + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Length', stat.size); + if (inline) { + res.setHeader('X-Frame-Options', 'SAMEORIGIN'); + res.setHeader('Content-Disposition', `inline; filename="${safeName}"`); + } else { + res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`); + } + + const stream = fs.createReadStream(absolutePath); + stream.on('error', (streamErr) => { + console.error('Fehler beim Streamen der PDF:', streamErr); + if (!res.headersSent) res.status(500); + res.end('Fehler beim Lesen der PDF-Datei'); + }); + return stream.pipe(res); + } catch (err) { + console.error('Fehler beim Streamen der PDF:', err); + if (!res.headersSent) res.status(500).json({ error: 'Fehler beim Streamen der PDF' }); + } + }); } module.exports = registerAdminRoutes; diff --git a/views/admin.ejs b/views/admin.ejs index 15b577e..e2e726c 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -461,6 +461,40 @@ + +
+
+

PDF-Archiv (Timesheets)

+ +
+ + +
@@ -549,6 +583,20 @@ if (icon) icon.style.transform = 'rotate(0deg)'; } } + + function togglePdfArchiveSection() { + const content = document.getElementById('pdfArchiveContent'); + const icon = document.getElementById('pdfArchiveToggleIcon'); + if (!content) return; + + if (content.style.display === 'none' || content.style.display === '') { + content.style.display = 'block'; + if (icon) icon.style.transform = 'rotate(180deg)'; + } else { + content.style.display = 'none'; + if (icon) icon.style.transform = 'rotate(0deg)'; + } + } // Rollenwechsel-Handler document.addEventListener('DOMContentLoaded', function() {