Viel neues
This commit is contained in:
515
qa-tool/htdocs/js/pdf.js
Normal file
515
qa-tool/htdocs/js/pdf.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
// ===== 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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user