Files
proxmox-selfservice/qa-tool/htdocs/app.js
Sven Steinert fce31ebcd7 Viel neues
2026-04-30 12:06:00 +02:00

1817 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ===================== 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 = `<option value="">— GitLab-Templates laden —</option>`;
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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const escAttr = (s) => String(s ?? '').replaceAll('"', '&quot;');
const safeName = (s) => String(s || '').replace(/\s+/g, '-').toLowerCase().replace(/[^\w.-]/g, '');
const linkify = (s) => /^https?:\/\//i.test(String(s || '')) ? `<a href="${escHTML(s)}" target="_blank" rel="noopener">${escHTML(s)}</a>` : 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 = `
<td colspan="4">
<div class="step-head" style="grid-template-columns: 1fr auto auto;">
<input type="text" class="tpl-group-title group-title" placeholder="Gruppen-Titel (z. B. Einrichtung des Moduls)" value="${escAttr(s.title||'')}">
<div class="group-actions">
<button type="button" class="btn btn-group-status" data-status="pass" title="Alle in Gruppe: PASS">✅</button>
<button type="button" class="btn btn-group-status" data-status="fail" title="Alle in Gruppe: FAIL">❌</button>
<button type="button" class="btn btn-group-status" data-status="skip" title="Alle in Gruppe: SKIP">⏭️</button>
<button type="button" class="btn btn-group-status" data-status="blocked" title="Alle in Gruppe: BLOCK">⛔</button>
<button type="button" class="btn btn-toggle-group" title="Gruppe ein-/ausklappen"><span class="chev">${s.collapsed?'▸':'▾'}</span></button>
<button type="button" class="btn btn-delete-group" title="Diese Gruppe löschen">🗑️</button>
</div>
<div class="drag-handle" draggable="true" title="Ziehen zum Verschieben">⋮⋮</div>
</div>
</td>`;
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 = `
<td class="cell-step">
<div class="step-head">
<input type="text" class="tpl-id" value="${escAttr(id)}" />
<textarea class="tpl-title" placeholder="Titel">${escHTML(s.title||'')}</textarea>
<span class="req-pin" title="Pflichtschritt">${s.required ? '📌' : ''}</span>
<button type="button" class="btn btn-delete-step" title="Diesen Step löschen">🗑️</button>
<div class="drag-handle" draggable="true" title="Ziehen zum Verschieben">⋮⋮</div>
</div>
<label class="req-row"><input type="checkbox" class="tpl-required"${s.required?' checked':''}> required</label>
</td>
<td><textarea class="tpl-expected" placeholder="Erwartetes Verhalten">${escHTML(s.expected||'')}</textarea></td>
<td>
<select class="status" data-step="${escAttr(id)}">
<option value="" ${!s.status ? 'selected':''}></option>
<option value="pass" ${s.status==='pass'?'selected':''}>pass</option>
<option value="fail" ${s.status==='fail'?'selected':''}>fail</option>
<option value="skip" ${s.status==='skip'?'selected':''}>skip</option>
<option value="blocked" ${s.status==='blocked'?'selected':''}>blocked</option>
</select>
</td>
<td>
<textarea class="run-comment" placeholder="Kommentar">${escHTML(s.comment||'')}</textarea>
<input class="run-evidence" placeholder="Evidenz-URL" value="${escAttr(s.evidence||'')}">
</td>`;
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 <img>)
let iw = 0,
ih = 0;
try {
const props = doc.getImageProperties ? doc.getImageProperties(logo) : null;
if (props) {
iw = props.width || 0;
ih = props.height || 0;
}
} catch {}
if (!(iw && ih)) {
await new Promise((resolve) => {
const img = new Image();
img.onload = () => {
iw = img.naturalWidth;
ih = img.naturalHeight;
resolve();
};
img.onerror = () => resolve();
img.src = logo;
});
}
// 2) Proportional in die Box einpassen
const maxW = 26,
maxH = 12;
const ratio = (ih && iw) ? (ih / iw) : (10 / 26);
let w = maxW,
h = w * ratio;
if (h > maxH) {
h = maxH;
w = h / ratio;
}
// 3) Rechtsbündig relativ zur Nutzkante (xRight)
const x = xRight - w;
const y = 7.8;
try {
doc.addImage(logo, 'PNG', x, y, w, h, undefined, 'FAST');
} catch {}
}
// Titelzeile
doc.setFont('helvetica', 'bold');
doc.setFontSize(18);
doc.setTextColor(0, 0, 0);
doc.text('QA Report', 14, 16);
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: <a href="${url}" target="_blank" rel="noopener">${url}</a>`;
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 =====================