PDF View i Adminbereich

This commit is contained in:
2026-03-18 17:24:13 +01:00
parent a92694f693
commit 8152aab15b
4 changed files with 599 additions and 0 deletions

View File

@@ -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 = '<option value="">-- Laden... --</option>';
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 = '<option value="">Keine PDFs gefunden</option>';
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 = '<option value="">Fehler</option>';
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 = '<option value="">-- Laden... --</option>';
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 = '<option value="">Keine PDFs</option>';
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 = '<option value="">Fehler</option>';
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 = '<p style="color:#666;">Keine PDF-Dateien gefunden.</p>';
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 `
<tr>
<td style="padding:4px; word-break:break-all;">
<code>${file.name}</code>
</td>
<td style="padding:4px;">${sizeLabel}</td>
<td style="padding:4px;">${mtimeLabel}</td>
<td style="padding:4px; white-space:nowrap;">
<button type="button" class="btn btn-primary btn-sm" onclick="previewPdf('${year}','${userId}','${file.name}')">Vorschau</button>
<button type="button" class="btn btn-secondary btn-sm" style="margin-left:6px;" onclick="downloadPdf('${year}','${userId}','${file.name}')">Download</button>
</td>
</tr>
`;
})
.join('');
pdfFilesContainer.innerHTML = `
<table style="width: 100%; min-width: 980px; border-collapse: collapse;">
<thead>
<tr>
<th style="text-align:left; padding:4px;">Datei</th>
<th style="text-align:left; padding:4px;">Größe</th>
<th style="text-align:left; padding:4px;">Änderung</th>
<th style="text-align:left; padding:4px;">Aktionen</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
`;
setPdfStatus(`${files.length} PDF(s) geladen.`, 'green');
} catch (error) {
console.error('Fehler beim Laden der PDF-Dateien:', error);
pdfFilesContainer.innerHTML = '<p style="color: red;">Fehler beim Laden der PDF-Dateien.</p>';
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;

129
rebuild-timesheet-pdfs.js Normal file
View File

@@ -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 };

View File

@@ -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;

View File

@@ -461,6 +461,40 @@
</div>
</div>
</div>
<div class="pdf-archive-section" style="margin-top: 40px;">
<div class="collapsible-header" onclick="togglePdfArchiveSection()" style="cursor: pointer; padding: 15px; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
<h2 style="margin: 0;">PDF-Archiv (Timesheets)</h2>
<span id="pdfArchiveToggleIcon" style="font-size: 18px; transition: transform 0.3s;">▼</span>
</div>
<div id="pdfArchiveContent" style="display: none; padding: 20px; border: 1px solid #ddd; border-top: none; border-radius: 0 0 4px 4px; background-color: #fff;">
<div class="form-row" style="display: flex; gap: 20px; flex-wrap: wrap; align-items: flex-end;">
<div class="form-group" style="min-width: 180px;">
<label for="pdfYearSelect">Jahr</label>
<select id="pdfYearSelect" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;"></select>
</div>
<div class="form-group" style="min-width: 180px;">
<label for="pdfUserSelect">User</label>
<select id="pdfUserSelect" disabled style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;"></select>
</div>
<div class="form-group" style="min-width: 180px;">
<button id="pdfRefreshBtn" class="btn btn-secondary" type="button" style="width: 100%;">Aktualisieren</button>
</div>
</div>
<div id="pdfArchiveStatus" style="margin-top: 15px; color: #666;"></div>
<div id="pdfFilesContainer" style="margin-top: 15px; overflow-x: auto; max-width: 100%;"></div>
<div id="pdfPreviewContainer" style="display: none; margin-top: 25px;">
<h3 style="margin-top: 0;">Vorschau</h3>
<iframe id="pdfPreviewIframe" style="width: 100%; height: 650px; border: 1px solid #ddd; border-radius: 4px;"></iframe>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -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() {