516 lines
16 KiB
JavaScript
516 lines
16 KiB
JavaScript
// 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;
|
||
});
|
||
}
|
||
|