// ===================== QA App – komplette app.js =====================
// === DocBee: Token HIER eintragen ===
const DOCBEE_TOKEN = window.DOCBEE_TOKEN || ""; // DocBee Token eintragen"
const ENABLE_FALLBACK_NOTE = false; // auf true setzen, falls bei Fehler automatisch Notiz angelegt werden soll
window.addEventListener('error', e => {
try {
console.error(e.error || e.message || e);
} catch (_) {}
alert('JS-Fehler: ' + (e.message || e));
});
// === Keyboard Shortcuts ===
window.addEventListener('keydown', e => {
// Alt + S to add a new step
if (e.altKey && e.key.toLowerCase() === 's' && els.btnAddStep) {
e.preventDefault();
els.btnAddStep.click();
}
});
// === DOM-Refs ===
const els = {
yamlFile: document.getElementById('yamlFile'),
stepsTableBody: document.querySelector('#stepsTable tbody'),
tplName: document.getElementById('tplName'),
statusTag: document.getElementById('statusTag'),
module: document.getElementById('module'),
moduleVersion: document.getElementById('moduleVersion'),
pbxVersion: document.getElementById('pbxVersion'),
tester: document.getElementById('tester'),
olmNummer: document.getElementById('olmNummer'),
docbeeUrl: document.getElementById('docbeeUrl'),
btnAddStep: document.getElementById('btnAddStep'),
btnSaveJSON: document.getElementById('btnSaveJSON'),
loadJSON: document.getElementById('loadJSON'),
btnAddGroup: document.getElementById('btnAddGroup'),
btnExportTemplateYAML: document.getElementById('btnExportTemplateYAML'),
btnExportAll: document.getElementById('btnExportAll'),
gitlabTplSelect: document.getElementById('gitlabTplSelect'),
gitlabTplStatus: document.getElementById('gitlabTplStatus'),
docbeeTokenStatus: document.getElementById('docbeeTokenStatus'),
btnGitlabReload: document.getElementById('btnGitlabReload'),
};
// === Drag Guard + Observer: Nur Drag über expliziten Griff zulassen ===
function enforceDragHandleOnly() {
if (!els.stepsTableBody) return;
els.stepsTableBody.querySelectorAll('tr[draggable="true"]').forEach(tr => tr.setAttribute('draggable', 'false'));
els.stepsTableBody.querySelectorAll('.drag-handle').forEach(h => h.setAttribute('draggable', 'true'));
}
document.addEventListener('DOMContentLoaded', () => {
if (els.stepsTableBody) {
const mo = new MutationObserver(() => enforceDragHandleOnly());
mo.observe(els.stepsTableBody, {
childList: true,
subtree: true
});
enforceDragHandleOnly();
}
});
document.addEventListener('dragstart', (e) => {
if (!e.target.closest('.drag-handle')) {
e.preventDefault();
}
}, {
capture: true
});
let template = null; // {name,module,module_version,pbx_version,steps:[...]}
// === GitLab: Projektquelle für Templates ===
const GITLAB = {
host: (window.GITLAB && window.GITLAB.host) || "https://git.steinert.cc",
projectId: (window.GITLAB && window.GITLAB.projectId) || "qa/templates",
ref: (window.GITLAB && window.GITLAB.ref) || "main",
path: (window.GITLAB && window.GITLAB.path) || "templates",
token: (window.GITLAB && window.GITLAB.token) || ""
};
function glHeaders() {
const h = {
"Accept": "application/json"
};
if (GITLAB.token && GITLAB.token.trim()) h["PRIVATE-TOKEN"] = GITLAB.token.trim();
return h;
}
// Listet .yml/.yaml im Repo-Pfad
async function listGitlabTemplates() {
const url = `${GITLAB.host}/api/v4/projects/${encodeURIComponent(GITLAB.projectId)}/repository/tree?path=${encodeURIComponent(GITLAB.path)}&ref=${encodeURIComponent(GITLAB.ref)}&per_page=100`;
const r = await fetch(url, {
headers: glHeaders()
});
if (!r.ok) throw new Error(`GitLab Tree ${r.status}`);
const items = await r.json();
return items.filter(it => it.type === "blob" && /\.ya?ml$/i.test(it.name)).map(it => ({
name: it.name,
path: it.path
}));
}
// Holt RAW-Inhalt einer Datei
async function fetchGitlabFileRaw(filePath) {
const url = `${GITLAB.host}/api/v4/projects/${encodeURIComponent(GITLAB.projectId)}/repository/files/${encodeURIComponent(filePath)}/raw?ref=${encodeURIComponent(GITLAB.ref)}`;
const r = await fetch(url, {
headers: glHeaders(),
cache: "no-store"
});
if (!r.ok) throw new Error(`GitLab Raw ${r.status}`);
return await r.text();
}
// Dropdown füllen
async function populateGitlabDropdown() {
const sel = els.gitlabTplSelect,
tag = els.gitlabTplStatus;
if (!sel) return;
sel.innerHTML = ``;
try {
tag && (tag.textContent = "GitLab: lade Liste…");
const files = await listGitlabTemplates();
files.forEach(f => {
const opt = document.createElement('option');
opt.value = f.path;
opt.textContent = f.name;
sel.appendChild(opt);
});
tag && (tag.textContent = files.length ? `GitLab: ${files.length} Vorlage(n)` : "GitLab: keine YAMLs gefunden");
} catch (e) {
tag && (tag.textContent = "GitLab: Fehler");
console.error("[GitLab] list failed:", e);
alert("GitLab-Liste konnte nicht geladen werden: " + e.message);
}
}
// === Utils ===
const hasToken = () => typeof DOCBEE_TOKEN === 'string' && DOCBEE_TOKEN.trim().length > 0;
const escHTML = (s) => String(s ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
const escAttr = (s) => String(s ?? '').replaceAll('"', '"');
const safeName = (s) => String(s || '').replace(/\s+/g, '-').toLowerCase().replace(/[^\w.-]/g, '');
const linkify = (s) => /^https?:\/\//i.test(String(s || '')) ? `${escHTML(s)}` : escHTML(s);
// --- Utils: Logo laden + Text normalisieren ---
// ========= Text & Symbol Normalization (PDF-safe) =========
// Map many Unicode arrows/dashes to ASCII; avoids font issues in jsPDF
function replaceSymbols(s) {
return String(s)
// right arrows (→ ⇒ ⟶ ➔ ➜ …) → "->"
.replace(/[\u2192\u21A6\u21E8\u27A1\u2794\u27F6\u27F7\u27F9\u279D\u279E\u279F\u27A0]/g, '->')
// left arrows (← ⇐ ⟵ …) → "<-"
.replace(/[\u2190\u21A4\u21E6\u2B05\u27F5]/g, '<-')
// both directions (↔ ⇔ ⟷ …) → "<->"
.replace(/[\u2194\u21D4\u27F7\u27F8\u2B04]/g, '<->')
// normalize dashes
.replace(/[–—‑]/g, '-')
// ellipsis/multiply
.replace(/…/g, '...')
.replace(/×/g, 'x');
}
// Restrict to ASCII + Latin-1 (tabs/newlines allowed); replace others with '?'
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;
});
}
function sanitizeText(s) {
// Inline text normalization for labels/titles/comments
const out = String(s ?? '')
.replace(/\u00A0/g, ' ') // NBSP -> space
.replace(/[“”]/g, '"') // smart quotes -> ASCII
.replace(/[’‘]/g, "'")
.replace(/\s+/g, ' ')
.trim();
// Map arrows/dashes and limit to PDF-safe charset
return toPdfSafe(replaceSymbols(out));
}
// ===== Funktion: download =====
// Hilfsfunktion: Client-seitiges Herunterladen als Datei.
function download(content, filename, type) {
const blob = new Blob([content], {
type
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1500);
}
// === Autosave der Meta-Felder ===
// ===== Funktion: updateAutosave =====
function updateAutosave() {
const st = {
module: els.module?.value || '',
moduleVersion: els.moduleVersion?.value || '',
pbxVersion: els.pbxVersion?.value || '',
tester: els.tester?.value || '',
docbeeUrl: els.docbeeUrl?.value || '',
};
try {
localStorage.setItem('qaLiteState', JSON.stringify(st));
} catch {}
}
window.addEventListener('load', () => {
try {
const st = JSON.parse(localStorage.getItem('qaLiteState') || '{}');
if (st.module) els.module.value = st.module;
if (st.moduleVersion) els.moduleVersion.value = st.moduleVersion;
if (st.pbxVersion) els.pbxVersion.value = st.pbxVersion;
if (st.tester) els.tester.value = st.tester;
if (st.docbeeUrl) els.docbeeUrl.value = st.docbeeUrl;
} catch {}
// NEU: Token-Badge initial bewerten + GitLab-Dropdown füllen
if (typeof updateTokenBadge === 'function') updateTokenBadge();
if (els.gitlabTplSelect && typeof populateGitlabDropdown === 'function') {
populateGitlabDropdown();
}
});
['input', 'change'].forEach(ev => {
[els.module, els.moduleVersion, els.pbxVersion, els.tester, els.docbeeUrl].forEach(el => el && el.addEventListener(ev, updateAutosave));
});
// === Vorlage laden (YAML/JSON) ===
// ===== Funktion: parseTemplate =====
function parseTemplate(text) {
if (window.jsyaml && typeof jsyaml.load === 'function') {
try {
return jsyaml.load(text);
} catch (e) {}
}
try {
return JSON.parse(text);
} catch (e) {}
throw new Error('Vorlage konnte weder als YAML noch JSON gelesen werden.');
}
// ===== Funktion: loadYAMLText =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
function loadYAMLText(yamlText) {
const obj = parseTemplate(yamlText);
if (!obj || !Array.isArray(obj.steps)) throw new Error('Ungültige Vorlage: "steps" fehlt.');
// Normalisieren: type→kind, default "step"
obj.steps = obj.steps.map(s => {
const k = s.kind || s.type || 'step';
if (k === 'group') return {
kind: 'group',
title: s.title || s.name || '',
collapsed: !!s.collapsed
};
return {
kind: 'step',
id: s.id,
title: s.title,
expected: s.expected,
required: !!s.required,
status: s.status || '',
comment: s.comment || '',
evidence: s.evidence || ''
};
});
template = obj;
if (els.tplName) els.tplName.textContent = obj.name || '—';
if (obj.module) els.module.value = obj.module;
if (obj.module_version) els.moduleVersion.value = obj.module_version;
if (els.olmNummer) els.olmNummer.value = obj.olm_nummer || obj.olm || obj.olmNumber || '';
if (obj.pbx_version) els.pbxVersion.value = obj.pbx_version;
renderSteps(obj.steps);
if (els.statusTag) els.statusTag.textContent = 'Vorlage geladen';
updateAutosave();
}
// === DOM -> template ===
// ===== Funktion: captureEditsIntoTemplate =====
function captureEditsIntoTemplate() {
if (!template) return;
const rows = [...(els.stepsTableBody?.querySelectorAll('tr') || [])];
template.steps = rows.map((row, i) => {
const kind = row.getAttribute('data-kind') || 'step';
if (kind === 'group') {
return {
kind: 'group',
title: (row.querySelector('.tpl-group-title')?.value || '').trim(),
collapsed: row.getAttribute('data-collapsed') === '1'
};
}
return {
kind: 'step',
id: (row.querySelector('.tpl-id')?.value || '').trim() || `step-${String(i+1).padStart(3,'0')}`,
title: (row.querySelector('.tpl-title')?.value || row.querySelector('.tpl-title')?.textContent || '').trim(),
expected: (row.querySelector('.tpl-expected')?.value || '').trim(),
required: !!(row.querySelector('.tpl-required')?.checked),
status: row.querySelector('select.status')?.value || '',
comment: row.querySelector('.run-comment')?.value || '',
evidence: row.querySelector('.run-evidence')?.value || ''
};
});
}
// === Helper: nächsten Step-Index ermitteln (Gruppen ignorieren) ===
// ===== Funktion: nextStepNumber =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
function nextStepNumber() {
const stepsOnly = (template?.steps || []).filter(s => (s.kind || s.type || 'step') === 'step');
// Versuche numerischen Suffix aus "step-XYZ" zu lesen, sonst zähle Steps
const nums = stepsOnly
.map(s => String(s.id || ''))
.map(id => {
const m = id.match(/step-(\d+)/i);
return m ? parseInt(m[1], 10) : null;
})
.filter(n => Number.isFinite(n));
const base = nums.length ? Math.max(...nums) : stepsOnly.length;
return base + 1;
}
// ===== Funktion: makeStepId =====
function makeStepId(n) {
const num = String(Math.max(1, n)).padStart(3, '0');
return `step-${num}`;
}
// === Helper: IDs streng sequentiell vergeben (Gruppen ignorieren) ===
// ===== Funktion: renumberSteps =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
function renumberSteps() {
if (!template || !Array.isArray(template.steps)) return;
let n = 1;
template.steps.forEach(s => {
const k = s.kind || s.type || 'step';
if (k !== 'step') return;
const id = `step-${String(n).padStart(3,'0')}`;
s.id = id;
n++;
});
}
// ===== Funktion: ensureRenumberAndRender =====
function ensureRenumberAndRender() {
renumberSteps();
renderSteps(template.steps);
recomputeGroupStyles();
}
// === Steps rendern ===
// ===== Funktion: renderSteps =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
// Rendert die Steps-Tabelle, erzeugt Zeilen & bindet Events (Pass/Fail/Kommentar).
function renderSteps(steps) {
if (!els.stepsTableBody) return;
els.stepsTableBody.innerHTML = '';
let groupCollapsed = false;
steps.forEach((s, idx) => {
if ((s.kind || 'step') === 'group') {
const trG = document.createElement('tr');
trG.setAttribute('data-kind', 'group');
trG.className = 'group-row';
if (s.collapsed) trG.setAttribute('data-collapsed', '1');
/* drag via handle only */
// trG.setAttribute('draggable','true');
trG.innerHTML = `
| `;
els.stepsTableBody.appendChild(trG);
groupCollapsed = !!s.collapsed;
return;
}
const id = s.id || '';
const tr = document.createElement('tr');
tr.setAttribute('data-kind', 'step');
/* drag via handle only */
// tr.setAttribute('draggable','true');
if (groupCollapsed) tr.setAttribute('data-hidden', '1');
tr.innerHTML = `
|
|
|
| `;
els.stepsTableBody.appendChild(tr);
updateStatusClass(tr.querySelector('select.status'));
// nach jedem Render Schritt neu bewerten
recomputeGroupStyles();
});
}
// === Gruppenstatus einfärben ===
function recomputeGroupStyles() {
try {
const body = els.stepsTableBody;
if (!body) return;
const rows = [...body.querySelectorAll('tr')];
// Collect groups with following step rows until next group
let groups = [];
let current = null;
rows.forEach(r => {
const kind = r.getAttribute('data-kind') || 'step';
if (kind === 'group') {
current = {
row: r,
steps: []
};
groups.push(current);
} else if (current) {
current.steps.push(r);
}
});
groups.forEach(g => {
g.row.classList.remove('group-ok', 'group-fail');
const statuses = g.steps.map(tr => (tr.querySelector('select.status')?.value || '').toUpperCase());
if (!statuses.length) return;
const allPass = statuses.every(s => s === 'PASS');
const anyFail = statuses.some(s => s === 'FAIL' || s === 'BLOCKED');
if (allPass) g.row.classList.add('group-ok');
else if (anyFail) g.row.classList.add('group-fail');
});
} catch (e) {
console.warn('recomputeGroupStyles failed', e);
}
}
function updateStatusClass(selectEl) {
if (!selectEl) return;
const tr = selectEl.closest('tr');
['st-pass', 'st-fail', 'st-skip', 'st-blocked'].forEach(c => selectEl.classList.remove(c));
['row-pass', 'row-fail', 'row-skip', 'row-blocked'].forEach(c => tr.classList.remove(c));
const v = selectEl.value || '';
if (!v) return;
selectEl.classList.add('st-' + v);
tr.classList.add('row-' + v);
}
// === Lauf/Template sammeln ===
// ===== Funktion: collectRun =====
function collectRun() {
if (!template) return null;
captureEditsIntoTemplate();
return {
name: template?.name || '',
module: els.module.value,
module_version: els.moduleVersion.value,
pbx_version: els.pbxVersion.value,
olm_nummer: els.olmNummer ? els.olmNummer.value : '',
tester: els.tester.value,
docbee_url: els.docbeeUrl.value,
ts: new Date().toISOString(),
steps: [...template.steps]
};
}
// ===== Funktion: collectTemplateFromDOM =====
function collectTemplateFromDOM() {
if (!template) return {
name: '',
module: '',
module_version: '',
pbx_version: '',
olm_nummer: '',
steps: []
};
captureEditsIntoTemplate();
renumberSteps();
return {
name: template?.name || els.tplName?.textContent || '',
module: els.module.value,
module_version: els.moduleVersion.value,
pbx_version: els.pbxVersion.value,
olm_nummer: els.olmNummer ? els.olmNummer.value : '',
steps: template.steps.map(s => {
if ((s.kind || 'step') === 'group') return {
type: 'group',
title: s.title
};
return {
type: 'step',
id: s.id,
title: s.title,
expected: s.expected,
required: !!s.required
};
})
};
}
// ===== Funktion: checkRequired =====
function checkRequired(run) {
const missing = run.steps.filter(s => (s.kind || 'step') === 'step' && s.required && !s.status);
if (missing.length > 0) {
alert("Folgende Pflichtschritte haben keinen Status:\n" +
missing.map(s => `${s.id} – ${s.title}`).join("\n"));
return false;
}
return true;
}
// === Exporte ===
// === Export All (DocBee, DB, PDF) ===
async function exportAll() {
console.log('exportAll START');
const run = collectRun();
console.log('collectRun()', run);
if (!run) {
alert('Kein Template/keine Steps: bitte Template laden oder Steps anlegen.');
return;
}
if (!checkRequired(run)) {
console.log('checkRequired() failed');
return;
}
renumberSteps();
// 1) Push to DocBee using existing function (if token available)
let pushedDocBee = null;
try {
if (window.DOCBEE_TOKEN && window.DOCBEE_TOKEN.length > 10) {
pushedDocBee = await postToDocBee(true); // variant returning URL
if (pushedDocBee && typeof pushedDocBee === 'string') {
run.docbee_url = pushedDocBee;
}
}
} catch (e) {
console.warn('DocBee push failed:', e);
}
// 2) Generate PDF client-side
const pdfBlob = await generatePdfBlob(run).catch(() => null);
// 3) Upload to server (DB + PDF)
const fd = new FormData();
fd.append('run', JSON.stringify(run));
if (pdfBlob) fd.append('pdf', pdfBlob, 'report.pdf');
const res = await fetch('/api/export.php', {
method: 'POST',
body: fd
});
const raw = await res.text();
let json;
try {
json = JSON.parse(raw);
} catch {
json = null;
}
console.log('Server response:', raw);
if (!res.ok || !json || json.ok === false) {
alert('Serverfehler beim Export:\n' + (json?.error || raw || ('HTTP ' + res.status)));
return;
}
alert('Export fertig:\n' +
(run.docbee_url ? ('DocBee: ' + run.docbee_url + '\n') : '') +
(json.pdf_path ? ('PDF gespeichert: ' + json.pdf_path + '\n') : '') +
('Report-ID: ' + json.report_id));
}
// ========= jsPDF Loader (local + CDN) =========
// Lädt jsPDF (lokal -> CDN -> Unpkg) und optional autotable.
// Gibt true/false zurück, wirft NICHT.
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
)
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);
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),
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');
}
// === Template als YAML exportieren und zu GitLab pushen ===
// ===== Funktion: exportTemplateYAML =====
async function exportTemplateYAML() {
if (!GITLAB.token) {
alert('Kein GitLab-Token konfiguriert. Bitte Token setzen.');
return;
}
const tpl = collectTemplateFromDOM();
if (!tpl.module || !tpl.module_version || !tpl.pbx_version) {
alert('Bitte Modul, Modul-Version und PBX-Version angeben.');
return;
}
let yml = '';
if (window.jsyaml && jsyaml.dump) {
yml = jsyaml.dump(tpl, {
lineWidth: 100
});
} else {
yml += `name: "${tpl.name||''}"\n`;
yml += `module: "${tpl.module||''}"\n`;
yml += `module_version: "${tpl.module_version||''}"\n`;
yml += `pbx_version: "${tpl.pbx_version||''}"\n`;
yml += `olm_nummer: "${tpl.olm_nummer||''}"\n`;
yml += `steps:\n`;
tpl.steps.forEach(s => {
if (s.type === 'group') {
yml += ` - type: "group"\n`;
yml += ` title: "${(s.title||'').replace(/"/g,'\\"')}"\n`;
} else {
yml += ` - type: "step"\n`;
yml += ` id: "${s.id||''}"\n`;
yml += ` title: "${(s.title||'').replace(/"/g,'\\"')}"\n`;
yml += ` expected: "${(s.expected||'').replace(/"/g,'\\"')}"\n`;
yml += ` required: ${s.required ? 'true':'false'}\n`;
}
});
}
// Keep original filename behaviour: module name only
const filename = `${safeName(tpl.module)}.yaml`;
const filePath = GITLAB.path + '/' + filename;
// Keep base64 encoding but send the proper `encoding` flag so GitLab
// decodes and stores the real YAML. The prior bug encoded the content
// but did not set `encoding`, so GitLab stored the base64 string.
const content = btoa(unescape(encodeURIComponent(yml)));
try {
if (els.statusTag) els.statusTag.textContent = 'GitLab: Pushe Template...';
const url = `${GITLAB.host}/api/v4/projects/${encodeURIComponent(GITLAB.projectId)}/repository/files/${encodeURIComponent(filePath)}`;
// Try to get the file first to see if it exists
const checkRes = await fetch(url + `?ref=${encodeURIComponent(GITLAB.ref)}`, {
headers: glHeaders()
});
const method = checkRes.ok ? 'PUT' : 'POST';
const commitMessage = checkRes.ok ?
`Update QA template for ${tpl.module} ${tpl.module_version}` :
`Add QA template for ${tpl.module} ${tpl.module_version}`;
const response = await fetch(url, {
method,
headers: {
...glHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
branch: GITLAB.ref,
content: content,
encoding: 'base64',
commit_message: commitMessage,
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (els.statusTag) els.statusTag.textContent = 'GitLab: Template erfolgreich gepusht';
alert(`Template wurde erfolgreich zu GitLab gepusht:\n${filePath}`);
} catch (error) {
console.error('GitLab push failed:', error);
if (els.statusTag) els.statusTag.textContent = 'GitLab: Fehler beim Pushen';
alert('Fehler beim Pushen zu GitLab: ' + error.message);
}
}
// === JSON speichern/laden ===
els.btnSaveJSON && els.btnSaveJSON.addEventListener('click', () => {
const run = collectRun();
if (!run) return;
if (!checkRequired(run)) return;
renumberSteps();
const base = `qa-run-${safeName(run.module)}-${safeName(run.module_version)}-${safeName(run.pbx_version)}`;
download(JSON.stringify(run, null, 2), `${base}.json`, 'application/json');
});
// Rebind: entfernt ALLE alten Listener am File-Input und setzt den neuen
document.addEventListener('DOMContentLoaded', () => {
let inp = document.getElementById('loadJSON');
if (!inp) return;
const clone = inp.cloneNode(true); // droppt alle alten Listener
inp.replaceWith(clone);
els.loadJSON = clone; // unsere globale Referenz aktualisieren
clone.addEventListener('change', async (e) => {
const f = e.target.files?.[0];
if (!f) return;
const txt = await f.text();
const name = String(f.name || '');
const ext = (name.split('.').pop() || '').toLowerCase();
const looksLikeJSON = /^[\s\r\n]*[{\[]/.test(txt);
function applyRun(run) {
template = {
name: run.name || '',
steps: (run.steps || []).map(s => {
const k = s.kind || s.type || 'step';
if (k === 'group') return {
kind: 'group',
title: s.title || ''
};
return {
kind: 'step',
id: s.id,
title: s.title,
expected: s.expected,
required: !!s.required,
status: s.status || '',
comment: s.comment || '',
evidence: s.evidence || ''
};
})
};
if (els.tplName) els.tplName.textContent = run.name || '—';
if (els.module) els.module.value = run.module || '';
if (els.moduleVersion) els.moduleVersion.value = run.module_version || '';
if (els.pbxVersion) els.pbxVersion.value = run.pbx_version || '';
if (els.tester) els.tester.value = run.tester || '';
if (els.docbeeUrl) els.docbeeUrl.value = run.docbee_url || '';
if (els.olmNummer) els.olmNummer.value = run.olm_nummer || '';
renderSteps(template.steps);
recomputeGroupStyles();
if (els.statusTag) els.statusTag.textContent = 'Lauf geladen';
updateAutosave();
}
function applyTemplateOnly(tplObj) {
template = {
name: tplObj.name || '',
steps: (tplObj.steps || []).map(s => {
const k = s.kind || s.type || 'step';
if (k === 'group') return {
kind: 'group',
title: s.title || ''
};
return {
kind: 'step',
id: s.id || '',
title: s.title || '',
expected: s.expected || '',
required: !!s.required,
status: '',
comment: '',
evidence: ''
};
})
};
if (els.tplName) els.tplName.textContent = template.name || '—';
if (els.module) els.module.value = tplObj.module || '';
if (els.moduleVersion) els.moduleVersion.value = tplObj.module_version || '';
if (els.pbxVersion) els.pbxVersion.value = tplObj.pbx_version || '';
if (els.olmNummer) els.olmNummer.value = tplObj.olm_nummer || '';
renderSteps(template.steps);
recomputeGroupStyles();
if (els.statusTag) els.statusTag.textContent = 'Template geladen';
updateAutosave();
}
try {
if (ext === 'json' || looksLikeJSON) {
try {
const run = JSON.parse(txt);
applyRun(run);
} catch (jsonErr) {
if (window.jsyaml && jsyaml.load) {
const tplObj = jsyaml.load(txt) || {};
applyTemplateOnly(tplObj);
} else {
throw new Error('JSON ungültig und YAML nicht verfügbar: ' + jsonErr.message);
}
}
} else {
if (!(window.jsyaml && jsyaml.load)) throw new Error('YAML-Unterstützung (js-yaml) fehlt.');
const tplObj = jsyaml.load(txt) || {};
applyTemplateOnly(tplObj);
}
} catch (err) {
alert('Fehler beim Laden: ' + (err?.message || err));
}
});
});
// === Ticket-ID aus URL ===
// ===== Funktion: extractTicketId =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
function extractTicketId(u) {
const m = String(u || "").match(/(?:\/ticket\/show\/|\/tickets\/)(\d+)/i);
return m ? m[1] : null;
}
// === DocBee Calls ===
async function postJSON(url, body) {
const r = await fetch(url, {
method: "POST",
headers: {
"Authorization": "Bearer " + DOCBEE_TOKEN,
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json"
},
body: JSON.stringify(body)
});
const text = await r.text().catch(() => "-");
console.log("[DocBee][POST]", url, body);
console.log("[DocBee][RES]", r.status, text.slice(0, 700));
if (r.status === 401) {
try {
updateTokenBadge("bad");
} catch (_) {}
}
return {
ok: r.ok,
status: r.status,
text
};
}
// --- Helpers (DocBee) -------------------------------------------------
if (typeof sleep !== 'function') {
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
}
if (typeof getJSON !== 'function') {
async function getJSON(url) {
const r = await fetch(url, {
method: "GET",
headers: {
"Authorization": "Bearer " + DOCBEE_TOKEN,
"Accept": "application/json"
}
});
const text = await r.text().catch(() => "-");
let json = null;
try {
json = JSON.parse(text);
} catch {}
console.log("[DocBee][GET]", url);
console.log("[DocBee][RES]", r.status, text.slice(0, 700));
return {
ok: r.ok,
status: r.status,
json,
text
};
}
}
// --- DocBee-Helper global (hoisted) ---
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function getJSON(url) {
const r = await fetch(url, {
method: "GET",
headers: {
"Authorization": "Bearer " + DOCBEE_TOKEN,
"Accept": "application/json"
}
});
const text = await r.text().catch(() => "-");
let json = null;
try {
json = JSON.parse(text);
} catch {}
console.log("[DocBee][GET]", url);
console.log("[DocBee][RES]", r.status, text.slice(0, 700));
return {
ok: r.ok,
status: r.status,
json,
text
};
}
async function putJSON(url, body) {
const r = await fetch(url, {
method: "PUT",
headers: {
"Authorization": "Bearer " + DOCBEE_TOKEN,
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json"
},
body: JSON.stringify(body)
});
const text = await r.text().catch(() => "-");
console.log("[DocBee][PUT]", url, body);
console.log("[DocBee][RES]", r.status, text.slice(0, 700));
let json = null;
try {
json = JSON.parse(text);
} catch {}
return {
ok: r.ok,
status: r.status,
json,
text
};
}
// Vorgangs-Status robust auf vorherige ID zurücksetzen (primär ticketStatus, Fallback status)
async function restoreTicketStatus(ticketId, prevStatusId) {
try {
const maxLoops = 5; // ~30s
let stable = 0;
for (let i = 0; i < maxLoops; i++) {
await sleep(200);
const tCur = await getJSON(`${DOCBEE_BASEURL}/restApi/v1/ticket/${ticketId}?fields=ticketStatus`);
const curId = tCur?.json?.ticketStatus ?? null;
if (curId == null) break;
if (curId !== prevStatusId) {
let r = await putJSON(`${DOCBEE_BASEURL}/restApi/v1/ticket/${ticketId}`, {
ticketStatus: {
id: prevStatusId
}
});
if (!r.ok) {
await putJSON(`${DOCBEE_BASEURL}/restApi/v1/ticket/${ticketId}`, {
status: {
id: prevStatusId
}
});
}
stable = 0;
} else {
stable++;
if (stable >= 2) break;
}
}
} catch (e) {
console.warn("[DocBee] restoreTicketStatus failed:", e);
}
}
if (typeof putJSON !== 'function') {
async function putJSON(url, body) {
const r = await fetch(url, {
method: "PUT",
headers: {
"Authorization": "Bearer " + DOCBEE_TOKEN,
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json"
},
body: JSON.stringify(body)
});
const text = await r.text().catch(() => "-");
console.log("[DocBee][PUT]", url, body);
console.log("[DocBee][RES]", r.status, text.slice(0, 700));
let json = null;
try {
json = JSON.parse(text);
} catch {}
return {
ok: r.ok,
status: r.status,
json,
text
};
}
}
async function restoreTicketStatus(ticketId, prevStatusId) {
try {
const maxLoops = 30; // ~30s
let stable = 0;
for (let i = 0; i < maxLoops; i++) {
await sleep(200);
const tCur = await getJSON(`${DOCBEE_BASEURL}/restApi/v1/ticket/${ticketId}?fields=ticketStatus.id,ticketStatus.name,status.id,status.name`);
const curId = tCur?.json?.ticketStatus?.id ?? tCur?.json?.status?.id ?? null;
if (curId == null) break;
if (curId !== prevStatusId) {
const r = await setTicketStatus(ticketId, prevStatusId);
console.log("[DocBee][restore] result:", r);
stable = 0; // nach Setzen erneut beobachten
} else {
stable++;
if (stable >= 2) break; // 2x stabil reicht
}
}
} catch (e) {
console.warn("[DocBee] restoreTicketStatus failed:", e);
}
}
// Status-Setter
async function setTicketStatus(ticketId, statusId) {
const base = `${DOCBEE_BASEURL}/restApi/v1`;
const tries = [
() => putJSON(`${base}/ticket/${ticketId}`, {
ticketStatus: statusId
}),
];
let last = null;
for (let i = 0; i < tries.length; i++) {
try {
const r = await tries[i]();
console.log(`[DocBee][STATUS][try ${i}]`, r?.status, r?.ok);
if (r && r.ok) return {
ok: true,
variant: i,
status: r.status,
text: r.text
};
last = r;
} catch (e) {
console.warn(`[DocBee][STATUS][try ${i}] exception`, e);
last = {
ok: false,
status: 0,
text: String(e)
};
}
}
return last || {
ok: false
};
}
// ===== Funktion: appendResultLink =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
const DOCBEE_UI_BASE = DOCBEE_BASEURL; // feste Instanzbasis für UI-Links
function appendResultLink(createdJSON) {
let link = null;
try {
const j = JSON.parse(createdJSON || "{}");
link = j?.link || null;
} catch {}
// Ticket-ID aus dem Eingabefeld (falls vorhanden) extrahieren
const ticketId = extractTicketId(els.docbeeUrl?.value || '');
// Ziel-URL bestimmen:
let url = `${DOCBEE_UI_BASE.replace(/\/+$/,'')}/ticket/show/${ticketId}`;
if (!url) return;
const hint = document.createElement('div');
hint.className = 'docbee-hint';
hint.innerHTML = `✅ Angelegt: ${url}`;
document.querySelector('.actions')?.appendChild(hint);
}
// ===== Funktion: formatDocBeeMessage =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
function formatDocBeeMessage(run) {
const pad = (s, n) => (s || '').length > n ? (s || '').slice(0, n - 1) + '…' : (s || '').padEnd(n, ' ');
const fmtDate = new Date(run.ts).toLocaleString('de-DE');
// Kurz-Summary (nur echte Steps zählen)
const counts = {
pass: 0,
fail: 0,
skip: 0,
blocked: 0
};
(run.steps || []).forEach(s => {
const k = s.kind || s.type || 'step';
if (k !== 'step') return;
if (counts[s.status] !== undefined) counts[s.status]++;
});
const summary = `✅ ${counts.pass} | ❌ ${counts.fail} | ⏭️ ${counts.skip} | ⛔ ${counts.blocked}`;
// Kopf + Metadaten (out VOR jeglicher Nutzung initialisieren)
let out = '';
if (counts.fail === 0 && counts.blocked === 0) {
out += `✅ QA Bestanden\n`;
} else {
out += `❌ QA nicht Bestanden\n`;
}
out += `Modul: ${run.module || ''}\n`;
out += `Modul-Version:${run.module_version || ''}\n`;
out += `PBX-Version: ${run.pbx_version || ''}\n`;
out += `Tester: ${run.tester || ''}\n`;
if (run.docbee_url) out += `Ticket: ${run.docbee_url}\n`;
out += `Datum: ${fmtDate}\n\n`;
out += `Übersicht: ${summary}\n\n`;
out += `\n`;
// Tabelle (monospace-geeignet)
const SEP = '─────────────────────────────────────────────────────────────────────';
out += `${SEP}\n`;
out += `${pad('Schritt', 12)} ${pad('Status', 7)} ${pad('Titel', 52)}\n`;
out += `${SEP}\n`;
(run.steps || []).forEach(s => {
const k = s.kind || s.type || 'step';
if (k === 'group') {
out += `\n## ${s.title || ''}\n\n`;
return;
}
const st = (s.status || '').toUpperCase(); // PASS/FAIL/SKIP/BLOCKED/…
const stShort = st === 'BLOCKED' ? 'BLOCK' : st;
const SMAP = {
pass: '✅',
fail: '❌',
skip: '⏭️',
blocked: '⛔'
};
const stLabel = (SMAP[(s.status || '').toLowerCase()] ?
SMAP[(s.status || '').toLowerCase()] + ' ' :
'') + stShort;
const req = s.required ? '📌 ' : '';
out += `${pad(s.id || '', 12)} ${pad(stLabel, 9)} ${pad(req + (s.title || ''), 50)}\n`;
if (s.expected) out += ` • Erwartet: ${s.expected}\n`;
if (s.comment) out += ` • Kommentar: ${s.comment}\n`;
if (s.evidence) out += ` • Evidenz: ${s.evidence}\n`;
});
out += `${SEP}\n`;
out += `Legende: PASS=✅, FAIL=❌, SKIP=⏭️, BLOCK=⛔, PFLICHTSCHRITT=📌 \n`;
return out;
}
async function postToDocBee() {
const run = collectRun();
if (!run) return;
if (!checkRequired(run)) return;
const ticketId = extractTicketId(els.docbeeUrl?.value || '');
if (!ticketId) {
alert("Keine Ticket-ID in der DocBee-URL gefunden.");
return;
}
if (!hasToken()) {
alert("Kein API-Token konfiguriert.");
return;
}
// Kommentarinhalt (Markdown)
const content = formatDocBeeMessage(run);
// alten Vorgangs-Status holen (Vorgangs-Status = ticketStatus)
const tGet = await getJSON(`${DOCBEE_BASEURL}/restApi/v1/ticket/${ticketId}?fields=ticketStatus`);
const prevStatusId = tGet?.json?.ticketStatus ?? null;
// Busy-State
els.btnPushDocBee?.setAttribute('disabled', '');
els.btnPushDocBee?.classList.add('is-busy');
try {
// *** Message posten (muss Message sein) ***
const url = `${DOCBEE_BASEURL}/restApi/v1/ticket/${ticketId}/message`;
const rMsg = await postJSON(url, {
content: content,
subject: `QA Report ${ticketId}`,
internal: true,
hidden: false
});
if (rMsg.ok) {
// kurze Wartezeit, dann Status ggf. zurücksetzen (gegen Auto-"Antwort erhalten")
await sleep(400);
if (prevStatusId != null) {
await restoreTicketStatus(ticketId, prevStatusId);
}
appendResultLink(rMsg.text);
//alert("Nachricht im Ticket angelegt (Status beibehalten)."); // Debug Message anzeigen
return;
}
if (ENABLE_FALLBACK_NOTE) {
const rNote = await postJSON(`${DOCBEE_BASEURL}/restApi/v1/note`, {
note: {
ticket: {
id: Number(ticketId)
},
subject: `QA Report ${ticketId}`,
text: content,
internal: false
}
});
if (rNote.ok) {
appendResultLink(rNote.text);
alert("Notiz angelegt (Fallback).");
return;
}
alert(`Fehler: MSG ${rMsg.status}, NOTE ${rNote.status}`);
} else {
alert(`Nachricht fehlgeschlagen (Status ${rMsg.status}). Details siehe Konsole.`);
}
} catch (e) {
alert("DocBee-Request fehlgeschlagen: " + String(e));
} finally {
els.btnPushDocBee?.removeAttribute('disabled');
els.btnPushDocBee?.classList.remove('is-busy');
}
}
// === Token Badge ===
function updateTokenBadge(state) {
const el = els.docbeeTokenStatus;
if (!el) return;
const has = !!(typeof DOCBEE_TOKEN !== 'undefined' && String(DOCBEE_TOKEN || '').trim().length > 10);
el.classList.remove('ok', 'bad', 'warn');
if (state === 'bad') {
el.textContent = 'DocBee: ungültig/401';
el.classList.add('bad');
return;
}
if (state === 'ok' || (state === undefined && has)) {
el.textContent = 'DocBee: Token gesetzt';
el.classList.add('ok');
return;
}
el.textContent = 'DocBee: fehlt';
el.classList.add('warn');
}
// === Events ===
els.yamlFile && els.yamlFile.addEventListener('change', async (e) => {
const f = e.target.files?.[0];
if (!f) return;
const txt = await f.text();
try {
loadYAMLText(txt);
} catch (err) {
alert('Vorlage konnte nicht geladen werden: ' + err.message);
}
});
els.btnAddStep && els.btnAddStep.addEventListener('click', () => {
if (!template) template = {
name: 'Neues Template',
steps: []
};
captureEditsIntoTemplate();
template.steps.push({
kind: 'step',
id: '',
title: '',
expected: '',
required: true,
status: '',
comment: '',
evidence: ''
});
ensureRenumberAndRender();
});
els.btnAddGroup && els.btnAddGroup.addEventListener('click', () => {
if (!template) template = {
name: 'Neues Template',
steps: []
};
captureEditsIntoTemplate();
template.steps.push({
kind: 'group',
title: 'Neue Gruppe',
collapsed: false
});
ensureRenumberAndRender();
});
els.stepsTableBody && els.stepsTableBody.addEventListener('click', (e) => {
const btnStep = e.target.closest('.btn btn-delete-step, .btn-delete-step');
const btnGroup = e.target.closest('.btn btn-delete-group, .btn-delete-group');
const btnToggle = e.target.closest('.btn btn-toggle-group, .btn-toggle-group');
const btnMass = e.target.closest('.btn btn-group-status, .btn-group-status');
if (!btnStep && !btnGroup && !btnToggle && !btnMass) return;
captureEditsIntoTemplate();
const tr = e.target.closest('tr');
const idx = [...els.stepsTableBody.children].indexOf(tr);
if (idx < 0) return;
if (btnToggle) {
if ((template.steps[idx] || {}).kind === 'group') {
template.steps[idx].collapsed = !template.steps[idx].collapsed;
ensureRenumberAndRender();
}
return;
}
if (btnMass && (template.steps[idx] || {}).kind === 'group') {
const status = (btnMass.getAttribute('data-status') || '').toLowerCase();
if (!['pass', 'fail', 'skip', 'blocked'].includes(status)) return;
// Schritte bis zur nächsten Gruppe finden
let start = idx + 1;
let end = template.steps.length;
for (let i = start; i < template.steps.length; i++) {
const k = (template.steps[i].kind || template.steps[i].type || 'step');
if (k === 'group') {
end = i;
break;
}
}
// Warnung, falls überschrieben würde
const willOverwrite = template.steps.slice(start, end)
.some(s => (s.kind || 'step') === 'step' && s.status && s.status !== status);
if (willOverwrite) {
const ok = confirm(`Einige Schritte in dieser Gruppe haben bereits einen anderen Status.\nAlle auf ${status.toUpperCase()} setzen?`);
if (!ok) return;
}
for (let i = start; i < end; i++) {
if ((template.steps[i].kind || 'step') !== 'step') continue;
template.steps[i].status = status;
}
ensureRenumberAndRender();
return;
}
// löschen (mit Warnung wenn Gruppe und nicht leer)
if (btnGroup && (template.steps[idx] || {}).kind === 'group') {
// zähle Steps bis zur nächsten Gruppe
let cnt = 0;
for (let i = idx + 1; i < template.steps.length; i++) {
const k = (template.steps[i].kind || template.steps[i].type || 'step');
if (k === 'group') break;
if (k === 'step') cnt++;
}
if (cnt > 0) {
const ok = confirm(`Diese Gruppe enthält ${cnt} Schritt(e).\nGruppe wirklich löschen? Die Schritte bleiben bestehen und werden nicht gelöscht.`);
if (!ok) return;
}
}
template.steps.splice(idx, 1);
ensureRenumberAndRender();
});
// === GitLab Events ===
if (els.gitlabTplSelect) els.gitlabTplSelect.addEventListener('change', async (e) => {
const p = e.target.value;
if (!p) return;
try {
els.gitlabTplStatus && (els.gitlabTplStatus.textContent = "GitLab: lade Datei…");
const y = await fetchGitlabFileRaw(p);
loadYAMLText(y);
els.gitlabTplStatus && (els.gitlabTplStatus.textContent = `GitLab: geladen – ${p.split('/').pop()}`);
} catch (err) {
els.gitlabTplStatus && (els.gitlabTplStatus.textContent = "GitLab: Fehler");
console.error("[GitLab] raw failed:", err);
alert("Vorlage aus GitLab konnte nicht geladen werden: " + err.message);
}
});
if (els.btnGitlabReload) els.btnGitlabReload.addEventListener('click', (e) => {
e.preventDefault();
populateGitlabDropdown();
});
// === Drag & Drop (Rows verschieben, inkl. Gruppen) ===
let dragIndex = -1;
// ===== Funktion: rowIndexOf =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
function rowIndexOf(target) {
const tr = target.closest('tr');
if (!tr) return -1;
return [...els.stepsTableBody.children].indexOf(tr);
}
els.stepsTableBody && els.stepsTableBody.addEventListener('dragstart', (e) => {
if (!e.target.closest('.drag-handle')) {
e.preventDefault();
return;
}
const i = rowIndexOf(e.target);
if (i < 0) return;
dragIndex = i;
e.dataTransfer?.setData('text/plain', String(i));
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
const row = e.target.closest('tr') || e.target;
e.dataTransfer?.setDragImage?.(row, 16, 16);
row.classList.add('dragging');
});
els.stepsTableBody && els.stepsTableBody.addEventListener('dragend', (e) => {
const tr = e.target.closest('tr');
if (tr) tr.classList.remove('dragging');
[...els.stepsTableBody.children].forEach(r => {
r.classList.remove('drag-over');
r.removeAttribute('data-pos');
});
dragIndex = -1;
});
els.stepsTableBody && els.stepsTableBody.addEventListener('dragover', (e) => {
e.preventDefault();
const tr = e.target.closest('tr');
if (!tr) return;
[...els.stepsTableBody.children].forEach(r => {
r.classList.remove('drag-over');
r.removeAttribute('data-pos');
});
tr.classList.add('drag-over');
const rect = tr.getBoundingClientRect();
const pos = (e.clientY - rect.top) < rect.height / 2 ? 'before' : 'after';
tr.setAttribute('data-pos', pos);
});
els.stepsTableBody && els.stepsTableBody.addEventListener('drop', (e) => {
e.preventDefault();
captureEditsIntoTemplate();
const from = dragIndex >= 0 ? dragIndex : parseInt(e.dataTransfer?.getData('text/plain') || '-1', 10);
const tr = e.target.closest('tr');
if (from < 0 || !tr) return;
const toBase = rowIndexOf(tr);
const pos = tr.getAttribute('data-pos') || 'after';
let to = toBase + (pos === 'after' ? 1 : 0);
if (to === from || to - 1 === from) {
renderSteps(template.steps);
recomputeGroupStyles();
return;
}
// Element verschieben
const item = template.steps.splice(from, 1)[0];
if (to > from) to--; // nach dem Entfernen verschiebt sich Index
template.steps.splice(to, 0, item);
ensureRenumberAndRender();
});
document.getElementById('stepsTable')?.addEventListener('change', (e) => {
if (e.target && e.target.matches('select.status')) {
updateStatusClass(e.target);
recomputeGroupStyles();
}
});
els.btnExportMD && els.btnExportMD.addEventListener('click', exportMD);
els.btnExportCSV && els.btnExportCSV.addEventListener('click', exportCSV);
els.btnPrintPDF && els.btnPrintPDF.addEventListener('click', printPDF);
els.btnExportTemplateYAML && els.btnExportTemplateYAML.addEventListener('click', exportTemplateYAML);
els.btnSaveJSON && els.btnSaveJSON.addEventListener('click', () => {
const run = collectRun();
if (!run) return;
if (!checkRequired(run)) return;
const base = `qa-run-${safeName(run.module)}-${safeName(run.module_version)}-${safeName(run.pbx_version)}`;
download(JSON.stringify(run, null, 2), `${base}.json`, 'application/json');
});
els.loadJSON && els.loadJSON.addEventListener('change', async (e) => {
const f = e.target.files?.[0];
if (!f) return;
const txt = await f.text();
try {
const run = JSON.parse(txt);
template = {
name: run.name || '',
steps: (run.steps || []).map(s => ({
id: s.id,
title: s.title,
expected: s.expected,
required: !!s.required,
status: s.status || '',
comment: s.comment || '',
evidence: s.evidence || ''
}))
};
if (els.tplName) els.tplName.textContent = run.name || '—';
els.module.value = run.module || '';
els.moduleVersion.value = run.module_version || '';
els.pbxVersion.value = run.pbx_version || '';
els.tester.value = run.tester || '';
els.docbeeUrl.value = run.docbee_url || '';
renderSteps(template.steps);
recomputeGroupStyles();
if (els.statusTag) els.statusTag.textContent = 'Lauf geladen';
updateAutosave();
} catch (err) {
alert('Ungültiges JSON: ' + err.message);
}
});
// === Bindings absichern + Busy-Fix + Mini-CSS ===
(function ensureBindings() {
// ===== Funktion: bind =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
function bind() {
const b = document.getElementById('btnPushDocBee');
if (b) {
b.removeAttribute('disabled');
b.classList.remove('is-busy');
b.style.pointerEvents = 'auto';
b.addEventListener('click', postToDocBee, {
once: false
});
}
const p = document.getElementById('btnPrintPDF');
const btn = document.getElementById('btnExportAll');
if (btn) {
btn.addEventListener('click', e => {
e.preventDefault();
exportAll().catch(err => alert('Export error: ' + err.message));
});
console.log('Export-Button gebunden');
} else {
alert('Export-Button nicht gefunden (id=btnExportAll).');
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind);
else bind();
window.addEventListener('pageshow', () => {
const b = document.getElementById('btnPushDocBee');
if (b) {
b.removeAttribute('disabled');
b.classList.remove('is-busy');
b.style.pointerEvents = 'auto';
}
});
})();
(function injectAuxCss() {
const s = document.createElement('style');
s.textContent = `
.is-busy{opacity:.6;pointer-events:none}
.docbee-hint{margin-top:8px;font-size:14px}
.docbee-hint a{text-decoration:underline}
`;
document.head.appendChild(s);
})();
// ===================== Ende app.js =====================