Viel neues

This commit is contained in:
Sven Steinert
2026-04-30 12:06:00 +02:00
parent 118809bfae
commit fce31ebcd7
1274 changed files with 181255 additions and 0 deletions

515
qa-tool/htdocs/js/pdf.js Normal file
View File

@@ -0,0 +1,515 @@
// auto-split module
async function ensureJsPdf() {
function loadScript(url) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = url;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
async function tryLoad(urls) {
for (const u of urls) {
try {
await loadScript(u);
} catch (_) {}
if (window.jspdf && window.jspdf.jsPDF) return true;
}
return !!(window.jspdf && window.jspdf.jsPDF);
}
// 1) jsPDF laden (lokal bevorzugt, dann CDN/Unpkg)
if (!(window.jspdf && window.jspdf.jsPDF)) {
await tryLoad([
'/vendor/jspdf/jspdf.umd.min.js',
'https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js',
'https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js'
]);
}
if (!(window.jspdf && window.jspdf.jsPDF)) return false;
// 2) autotable nachladen, falls nicht vorhanden
try {
const test = new window.jspdf.jsPDF();
if (typeof test.autoTable !== 'function') {
await tryLoad([
'/vendor/jspdf-autotable/jspdf.plugin.autotable.min.js',
'https://cdn.jsdelivr.net/npm/jspdf-autotable@3.8.2/dist/jspdf.plugin.autotable.min.js',
'https://unpkg.com/jspdf-autotable@3.8.2/dist/jspdf.plugin.autotable.min.js'
]);
}
} catch (_) {
/* egal PDF geht auch ohne autotable-Funktion (Fallback) */
}
return true;
}
async function generatePdfBlob(run) {
// Lade jsPDF + AutoTable falls nötig (lokal → CDN/Unpkg). Fällt ansonsten sauber auf Text-Blob zurück.
const pdfReady = await ensureJsPdf();
if (!pdfReady) {
const txt = 'QA Report\n' + JSON.stringify(run, null, 2);
return new Blob([txt], {
type: 'application/pdf'
});
}
const {
jsPDF
} = window.jspdf;
const doc = new jsPDF({
orientation: 'landscape',
unit: 'mm',
format: 'a4',
compress: true
});
// dynamic layout metrics for landscape A4
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const marginL = 14,
marginR = 14;
const xRight = pageW - marginR;
const footerY = pageH - 13;
// Farben (RGB, NICHT Hex!): "muted" war zuvor unsichtbar jetzt korrekt
const muted = [102, 102, 102];
const headFill = [242, 242, 242];
const ok = [0, 140, 0],
fail = [200, 0, 0],
skip = [160, 160, 0];
// Optionales Logo oben rechts proportional in 26×12 mm Box, rechtsbündig
let logo = null;
try {
logo = await imgToDataURL('logo_light.png');
} catch {}
if (logo) {
// 1) Bildabmessungen ermitteln (erst jsPDF, dann Fallback über <img>)
let iw = 0,
ih = 0;
try {
const props = doc.getImageProperties ? doc.getImageProperties(logo) : null;
if (props) {
iw = props.width || 0;
ih = props.height || 0;
}
} catch {}
if (!(iw && ih)) {
await new Promise((resolve) => {
const img = new Image();
img.onload = () => {
iw = img.naturalWidth;
ih = img.naturalHeight;
resolve();
};
img.onerror = () => resolve();
img.src = logo;
});
}
// 2) Proportional in die Box einpassen
const maxW = 26,
maxH = 12;
const ratio = (ih && iw) ? (ih / iw) : (10 / 26);
let w = maxW,
h = w * ratio;
if (h > maxH) {
h = maxH;
w = h / ratio;
}
// 3) Rechtsbündig relativ zur Nutzkante (xRight)
const x = xRight - w;
const y = 7.8;
try {
doc.addImage(logo, 'PNG', x, y, w, h, undefined, 'FAST');
} catch {}
}
// Titelzeile
doc.setFont('helvetica', 'bold');
doc.setFontSize(18);
doc.setTextColor(0, 0, 0);
doc.text('QA Report', 14, 16);
if (logo) {
try {
// Optionales Logo oben rechts proportional in 26×12 mm Box, rechtsbündig
let logo = null;
try {
logo = await imgToDataURL('logo_light.png');
} catch {}
if (logo) {
// Bildabmessungen: zuerst aus jsPDF, sonst via <img>-Fallback
let iw = 0,
ih = 0;
try {
const props = doc.getImageProperties ? doc.getImageProperties(logo) : null;
if (props) {
iw = props.width || 0;
ih = props.height || 0;
}
} catch {}
if (!(iw && ih)) {
await new Promise((resolve) => {
const img = new Image();
img.onload = () => {
iw = img.naturalWidth;
ih = img.naturalHeight;
resolve();
};
img.onerror = () => resolve();
img.src = logo;
});
}
// proportional in die Box einpassen
const maxW = 26,
maxH = 12;
const ratio = (ih && iw) ? (ih / iw) : (10 / 26);
let w = maxW,
h = w * ratio;
if (h > maxH) {
h = maxH;
w = h / ratio;
}
// rechtsbündig zur Nutzkante
const x = xRight - w;
const y = 7.8;
try {
doc.addImage(logo, 'PNG', x, y, w, h, undefined, 'FAST');
} catch {}
}
} catch {}
}
doc.setDrawColor(0, 0, 0);
doc.setLineWidth(0.2);
doc.line(marginL, 18, xRight, 18);
// Metadaten mit Labels (sichtbar!)
const meta = [
['Modul', sanitizeText(run.module)],
['Modul-Version', sanitizeText(run.module_version)],
['PBX-Version', sanitizeText(run.pbx_version)],
['OLM-Nummer', sanitizeText(run.olm_nummer)],
['Tester', sanitizeText(run.tester)],
['DocBee', sanitizeText(run.docbee_url || '-')],
];
let y = 24;
doc.setFontSize(10);
meta.forEach(([k, v]) => {
doc.setFont('helvetica', 'bold');
doc.setTextColor(...muted);
doc.text(k + ':', 14, y);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
doc.text(v || '—', 50, y);
y += 6;
});
// Zeilen aus Steps bauen
const rows = [];
let i = 0,
group = null;
(run.steps || []).forEach(s => {
if ((s.kind || 'step') === 'group') {
group = sanitizeText(s.title);
return;
}
i++;
rows.push({
nr: i,
group: sanitizeText(group || ''),
id: sanitizeText(s.id),
title: sanitizeText(s.title),
expected: sanitizeText(s.expected),
status: sanitizeText((s.status || '').toLowerCase()),
comment: sanitizeText(s.comment),
});
});
const body = rows.map(r => [r.nr, r.group, r.id, r.title, r.expected, r.status, r.comment]);
const startY = Math.max(y + 4, 24);
// Tabelle (autoTable)
// @ts-ignore
doc.autoTable({
startY,
tableWidth: pageW - (marginL + marginR),
tableWidth: pageW - (marginL + marginR),
head: [
['#', 'Gruppe', 'Step-ID', 'Titel', 'Erwartet', 'Status', 'Kommentar']
],
body,
styles: {
font: 'helvetica',
fontSize: 9,
cellPadding: 2,
overflow: 'linebreak',
valign: 'top'
},
headStyles: {
fillColor: headFill,
textColor: [0, 0, 0],
halign: 'left'
},
columnStyles: {
0: {
halign: 'right',
cellWidth: 8
},
1: {
cellWidth: 36
},
2: {
cellWidth: 24
},
3: {
cellWidth: 62
},
4: {
cellWidth: 76
},
5: {
cellWidth: 20,
halign: 'left'
},
6: {
cellWidth: 36
}
},
didParseCell: (d) => {
// Normalize every cell fragment to PDF-safe ASCII/Latin-1 and map arrows
if (d && d.cell) {
const arr = Array.isArray(d.cell.text) ? d.cell.text : [String(d.cell.text ?? '')];
d.cell.text = arr.map(t => toPdfSafe(replaceSymbols(String(t))));
}
if (d.section === 'body') {
// Status einfärben
if (d.column.index === 5) {
const val = String(d.cell.raw || '').toLowerCase();
if (val === 'pass') d.cell.styles.textColor = ok;
else if (val === 'fail') d.cell.styles.textColor = fail;
else if (val === 'skip' || val === 'na') d.cell.styles.textColor = skip;
}
// "Erwartet" als Monospace + leicht grauer Hintergrund
if (d.column.index === 4) {
d.cell.styles.font = 'courier';
d.cell.styles.fillColor = [250, 250, 250];
}
}
},
margin: {
left: marginL,
right: marginR
},
pageBreak: 'auto'
});
// Footer: Seitenzahlen & Zeitstempel
const pageCount = doc.getNumberOfPages();
const ts = new Date().toLocaleString();
for (let p = 1; p <= pageCount; p++) {
doc.setPage(p);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(...muted);
doc.text(`Seite ${p} / ${pageCount}`, xRight, footerY, {
align: 'right'
});
doc.text(ts, marginL, footerY);
}
return doc.output('blob');
}
async function printPDF() {
const run = collectRun();
if (!run) return;
if (!checkRequired(run)) return;
renumberSteps();
const logoDataUrl = await imgToDataURL('logo_light.png').catch(() => null);
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// ===== Funktion: badge (Arrow) =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
const badge = (status) => {
const map = {
pass: {
txt: '✅ PASS',
bg: '#DCFCE7',
bd: '#22c55e'
},
fail: {
txt: '❌ FAIL',
bg: '#FEE2E2',
bd: '#ef4444'
},
skip: {
txt: '⏭️ SKIP',
bg: '#E5E7EB',
bd: '#6b7280'
},
blocked: {
txt: '⛔ BLOCK',
bg: '#FEF3C7',
bd: '#f59e0b'
},
'': {
txt: '—',
bg: '#F3F4F6',
bd: '#9ca3af'
}
};
const m = map[status || ''] || map[''];
return `<span style="display:inline-block;padding:2px 8px;border:1px solid ${m.bd};border-radius:999px;background:${m.bg};font-weight:600;font-size:12px">${m.txt}</span>`;
};
const counts = {
pass: 0,
fail: 0,
skip: 0,
blocked: 0
};
run.steps.forEach(s => {
if ((s.kind || 'step') !== 'step') return;
if (counts[s.status] !== undefined) counts[s.status]++;
});
const summary = `${counts.pass}${counts.fail} ⏭️ ${counts.skip}${counts.blocked}`;
let rows = '';
let seenGroup = false;
for (const s of run.steps) {
if ((s.kind || 'step') === 'group') {
const t = esc(s.title || '');
const cls = `group${seenGroup ? ' pagebreak' : ''}`;
rows += `<tr class="${cls}"><td colspan="5"><strong>${t}</strong></td></tr>`;
seenGroup = true;
continue;
}
rows += `
<tr>
<td><strong>${esc(s.id)}</strong><div class="stitle">${esc(s.title)}${s.required?' 📌':''}</div></td>
<td>${esc(s.expected)}</td>
<td class="status">${badge(s.status||'')}</td>
<td>${esc(s.comment)}</td>
<td>${linkify(s.evidence)}</td>
</tr>`;
}
const html = `<!doctype html>
<html><head><meta charset="utf-8"><title>QA Report</title>
<style>
:root{ --text:#111827; --muted:#4b5563; --line:#d1d5db; --brandLight:#f3f4f6; }
*{box-sizing:border-box}
body{font-family:system-ui,Segoe UI,Roboto,Arial,sans-serif;margin:24px;color:var(--text); background:#fff}
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.brand{display:flex;gap:10px;align-items:center}
.brand img{height:28px;width:auto;object-fit:contain}
h1{margin:0;font-size:20px}
.summary{margin:6px 0 0 0;font-weight:600}
.meta{border:1px solid var(--line);border-radius:10px;padding:10px;background:#fff;margin:8px 0 14px 0}
.meta ul{margin:0;padding-left:16px;color:var(--muted)}
table{width:100%;border-collapse:collapse;margin-top:4px}
thead th{background:var(--brandLight);text-align:left;border-bottom:1px solid var(--line);padding:8px}
td{border-bottom:1px solid var(--line);padding:8px;vertical-align:top}
td.status{white-space:nowrap}
.stitle{color:var(--muted);font-size:12px;margin-top:2px}
.legend{font-size:12px;color:var(--muted);margin-top:4px}
tr.group td{ background:#e8f6fd; border-top:2px solid #bfe7fb; font-weight:700 }
/* Seitenumbruch vor jeder Gruppe (außer der ersten) */
@media print{
tr.pagebreak{ break-before: page; page-break-before: always; }
}
a{color:#1d4ed8;text-decoration:underline}
@media print{ body{margin:10mm} thead th, td{border-color:#000} }
</style>
</head>
<body>
<header>
<div class="brand">
${logoDataUrl ? `<img src="${logoDataUrl}" alt="Logo">` : ``}
<h1>Testprotokoll</h1>
</div>
<div class="summary">${summary}</div>
</header>
<section class="meta">
<ul>
<li><strong>Modul:</strong> ${esc(run.module||'')}</li>
<li><strong>Modul-Version:</strong> ${esc(run.module_version||'')}</li>
<li><strong>PBX-Version:</strong> ${esc(run.pbx_version||'')}</li>
<li><strong>DocBee:</strong> ${esc(run.docbee_url||'')}</li>
<li><strong>Tester:</strong> ${esc(run.tester||'')}</li>
<li><strong>Datum:</strong> ${new Date(run.ts).toLocaleString('de-DE')}</li>
</ul>
<div class="legend">Legende: ✅ Pass, ❌ Fail, ⏭️ Skip, ⛔ Blocked, 📌 Pflicht</div>
</section>
<table>
<thead><tr><th>Schritt</th><th>Erwartung</th><th>Status</th><th>Kommentar</th><th>Evidenz</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<script>
(function(){
function go(){
try{ window.focus(); }catch(e){}
setTimeout(function(){ window.print(); }, 120);
}
if (document.readyState === 'complete') go();
else window.addEventListener('load', go);
window.onafterprint = function(){
setTimeout(function(){ try{ window.close(); }catch(e){} }, 50);
};
setTimeout(function(){ try{ window.close(); }catch(e){} }, 3000);
})();
</script>
</body></html>`;
const blob = new Blob([html], {
type: 'text/html'
});
const url = URL.createObjectURL(blob);
const w = window.open(url, '_blank', 'noopener');
if (!w) {
alert('Pop-up blockiert. Bitte Pop-ups erlauben.');
URL.revokeObjectURL(url);
return;
}
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
function toPdfSafe(s) {
return String(s).replace(/[^\x09\x0A\x0D\x20-\x7E\u00A0-\u00FF]/g, '?');
}
async function imgToDataURL(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const c = document.createElement('canvas');
c.width = img.naturalWidth;
c.height = img.naturalHeight;
const ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0);
resolve(c.toDataURL('image/png'));
};
img.onerror = reject;
img.src = src;
});
}