1817 lines
64 KiB
JavaScript
1817 lines
64 KiB
JavaScript
// ===================== 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('&', '&').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 || '')) ? `<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 =====================
|