// ===================== 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 = `
${s.required ? '📌' : ''}
⋮⋮
`; 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 =====================