Viel neues

This commit is contained in:
Sven Steinert
2026-04-30 12:06:00 +02:00
parent 118809bfae
commit fce31ebcd7
1274 changed files with 181255 additions and 0 deletions

151
qa-tool/htdocs/js/docbee.js Normal file
View File

@@ -0,0 +1,151 @@
// auto-split module
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
};
}
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
};
}
async function restoreTicketStatus(ticketId, prevStatusId) {
try {
const maxLoops = 5; // ~30s
let stable = 0;
for (let i = 0; i < maxLoops; i++) {
await sleep(100);
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);
}
}
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
};
}

View File

@@ -0,0 +1,93 @@
// auto-split module
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));
}
function exportTemplateYAML() {
const tpl = collectTemplateFromDOM();
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`;
}
});
}
const base = `qa-template-${safeName(tpl.module)}-${safeName(tpl.module_version)}-${safeName(tpl.pbx_version)}`;
download(yml, `${base}.yaml`, 'text/yaml');
}

314
qa-tool/htdocs/js/flow.js Normal file
View File

@@ -0,0 +1,314 @@
// auto-split module
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);
}
function go(){
try{ window.focus(); }catch(e){}
setTimeout(function(){ window.print(); }, 120);
}
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 || '';
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 || '';
renderSteps(template.steps);
recomputeGroupStyles();
if (els.statusTag) els.statusTag.textContent = 'Template geladen';
updateAutosave();
}
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
};
})
};
}
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]
};
}
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:
// 1) Falls API bereits einen Pfad liefert (z. B. "ticket/show/123"), normieren.
// 2) Sonst UI-Link über bekannte Struktur /ticket/show/%ID% bauen.
let url = null;
if (link) {
if (/^https?:\/\//i.test(link)) {
url = link; // bereits absolute URL
} else {
url = DOCBEE_UI_BASE.replace(/\/+$/, '') + '/' + String(link).replace(/^\/+/, '');
}
} else if (ticketId) {
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);
}
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 = '';
out += `QA REPORT\n`;
out += `===========\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`;
// 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.comment) out += ` • Kommentar: ${s.comment}\n`;
if (s.evidence) out += ` • Evidenz: ${s.evidence}\n`;
});
out += `${SEP}\n`;
out += `Legende: PASS=✅, FAIL=❌, SKIP=⏭️, BLOCK=⛔\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(800);
if (prevStatusId != null) {
await restoreTicketStatus(ticketId, prevStatusId);
}
appendResultLink(rMsg.text);
alert("Nachricht im Ticket angelegt (Status beibehalten).");
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');
}
}

102
qa-tool/htdocs/js/gitlab.js Normal file
View File

@@ -0,0 +1,102 @@
// auto-split module
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) || ""
};
// auto-split module
function glHeaders() {
const h = {
"Accept": "application/json"
};
if (GITLAB.token && GITLAB.token.trim()) h["PRIVATE-TOKEN"] = GITLAB.token.trim();
return h;
}
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
}));
}
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();
}
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);
}
}
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 (obj.pbx_version) els.pbxVersion.value = obj.pbx_version;
renderSteps(obj.steps);
if (els.statusTag) els.statusTag.textContent = 'Vorlage geladen';
updateAutosave();
}

253
qa-tool/htdocs/js/main.js Normal file
View File

@@ -0,0 +1,253 @@
// ===================== 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));
});
let template = null; // {name,module,module_version,pbx_version,steps:[...]}
// === 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
});
// === 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 =====================

515
qa-tool/htdocs/js/pdf.js Normal file
View File

@@ -0,0 +1,515 @@
// auto-split module
async function ensureJsPdf() {
function loadScript(url) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = url;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
async function tryLoad(urls) {
for (const u of urls) {
try {
await loadScript(u);
} catch (_) {}
if (window.jspdf && window.jspdf.jsPDF) return true;
}
return !!(window.jspdf && window.jspdf.jsPDF);
}
// 1) jsPDF laden (lokal bevorzugt, dann CDN/Unpkg)
if (!(window.jspdf && window.jspdf.jsPDF)) {
await tryLoad([
'/vendor/jspdf/jspdf.umd.min.js',
'https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js',
'https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js'
]);
}
if (!(window.jspdf && window.jspdf.jsPDF)) return false;
// 2) autotable nachladen, falls nicht vorhanden
try {
const test = new window.jspdf.jsPDF();
if (typeof test.autoTable !== 'function') {
await tryLoad([
'/vendor/jspdf-autotable/jspdf.plugin.autotable.min.js',
'https://cdn.jsdelivr.net/npm/jspdf-autotable@3.8.2/dist/jspdf.plugin.autotable.min.js',
'https://unpkg.com/jspdf-autotable@3.8.2/dist/jspdf.plugin.autotable.min.js'
]);
}
} catch (_) {
/* egal PDF geht auch ohne autotable-Funktion (Fallback) */
}
return true;
}
async function generatePdfBlob(run) {
// Lade jsPDF + AutoTable falls nötig (lokal → CDN/Unpkg). Fällt ansonsten sauber auf Text-Blob zurück.
const pdfReady = await ensureJsPdf();
if (!pdfReady) {
const txt = 'QA Report\n' + JSON.stringify(run, null, 2);
return new Blob([txt], {
type: 'application/pdf'
});
}
const {
jsPDF
} = window.jspdf;
const doc = new jsPDF({
orientation: 'landscape',
unit: 'mm',
format: 'a4',
compress: true
});
// dynamic layout metrics for landscape A4
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const marginL = 14,
marginR = 14;
const xRight = pageW - marginR;
const footerY = pageH - 13;
// Farben (RGB, NICHT Hex!): "muted" war zuvor unsichtbar jetzt korrekt
const muted = [102, 102, 102];
const headFill = [242, 242, 242];
const ok = [0, 140, 0],
fail = [200, 0, 0],
skip = [160, 160, 0];
// Optionales Logo oben rechts proportional in 26×12 mm Box, rechtsbündig
let logo = null;
try {
logo = await imgToDataURL('logo_light.png');
} catch {}
if (logo) {
// 1) Bildabmessungen ermitteln (erst jsPDF, dann Fallback über <img>)
let iw = 0,
ih = 0;
try {
const props = doc.getImageProperties ? doc.getImageProperties(logo) : null;
if (props) {
iw = props.width || 0;
ih = props.height || 0;
}
} catch {}
if (!(iw && ih)) {
await new Promise((resolve) => {
const img = new Image();
img.onload = () => {
iw = img.naturalWidth;
ih = img.naturalHeight;
resolve();
};
img.onerror = () => resolve();
img.src = logo;
});
}
// 2) Proportional in die Box einpassen
const maxW = 26,
maxH = 12;
const ratio = (ih && iw) ? (ih / iw) : (10 / 26);
let w = maxW,
h = w * ratio;
if (h > maxH) {
h = maxH;
w = h / ratio;
}
// 3) Rechtsbündig relativ zur Nutzkante (xRight)
const x = xRight - w;
const y = 7.8;
try {
doc.addImage(logo, 'PNG', x, y, w, h, undefined, 'FAST');
} catch {}
}
// Titelzeile
doc.setFont('helvetica', 'bold');
doc.setFontSize(18);
doc.setTextColor(0, 0, 0);
doc.text('QA Report', 14, 16);
if (logo) {
try {
// Optionales Logo oben rechts proportional in 26×12 mm Box, rechtsbündig
let logo = null;
try {
logo = await imgToDataURL('logo_light.png');
} catch {}
if (logo) {
// Bildabmessungen: zuerst aus jsPDF, sonst via <img>-Fallback
let iw = 0,
ih = 0;
try {
const props = doc.getImageProperties ? doc.getImageProperties(logo) : null;
if (props) {
iw = props.width || 0;
ih = props.height || 0;
}
} catch {}
if (!(iw && ih)) {
await new Promise((resolve) => {
const img = new Image();
img.onload = () => {
iw = img.naturalWidth;
ih = img.naturalHeight;
resolve();
};
img.onerror = () => resolve();
img.src = logo;
});
}
// proportional in die Box einpassen
const maxW = 26,
maxH = 12;
const ratio = (ih && iw) ? (ih / iw) : (10 / 26);
let w = maxW,
h = w * ratio;
if (h > maxH) {
h = maxH;
w = h / ratio;
}
// rechtsbündig zur Nutzkante
const x = xRight - w;
const y = 7.8;
try {
doc.addImage(logo, 'PNG', x, y, w, h, undefined, 'FAST');
} catch {}
}
} catch {}
}
doc.setDrawColor(0, 0, 0);
doc.setLineWidth(0.2);
doc.line(marginL, 18, xRight, 18);
// Metadaten mit Labels (sichtbar!)
const meta = [
['Modul', sanitizeText(run.module)],
['Modul-Version', sanitizeText(run.module_version)],
['PBX-Version', sanitizeText(run.pbx_version)],
['OLM-Nummer', sanitizeText(run.olm_nummer)],
['Tester', sanitizeText(run.tester)],
['DocBee', sanitizeText(run.docbee_url || '-')],
];
let y = 24;
doc.setFontSize(10);
meta.forEach(([k, v]) => {
doc.setFont('helvetica', 'bold');
doc.setTextColor(...muted);
doc.text(k + ':', 14, y);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
doc.text(v || '—', 50, y);
y += 6;
});
// Zeilen aus Steps bauen
const rows = [];
let i = 0,
group = null;
(run.steps || []).forEach(s => {
if ((s.kind || 'step') === 'group') {
group = sanitizeText(s.title);
return;
}
i++;
rows.push({
nr: i,
group: sanitizeText(group || ''),
id: sanitizeText(s.id),
title: sanitizeText(s.title),
expected: sanitizeText(s.expected),
status: sanitizeText((s.status || '').toLowerCase()),
comment: sanitizeText(s.comment),
});
});
const body = rows.map(r => [r.nr, r.group, r.id, r.title, r.expected, r.status, r.comment]);
const startY = Math.max(y + 4, 24);
// Tabelle (autoTable)
// @ts-ignore
doc.autoTable({
startY,
tableWidth: pageW - (marginL + marginR),
tableWidth: pageW - (marginL + marginR),
head: [
['#', 'Gruppe', 'Step-ID', 'Titel', 'Erwartet', 'Status', 'Kommentar']
],
body,
styles: {
font: 'helvetica',
fontSize: 9,
cellPadding: 2,
overflow: 'linebreak',
valign: 'top'
},
headStyles: {
fillColor: headFill,
textColor: [0, 0, 0],
halign: 'left'
},
columnStyles: {
0: {
halign: 'right',
cellWidth: 8
},
1: {
cellWidth: 36
},
2: {
cellWidth: 24
},
3: {
cellWidth: 62
},
4: {
cellWidth: 76
},
5: {
cellWidth: 20,
halign: 'left'
},
6: {
cellWidth: 36
}
},
didParseCell: (d) => {
// Normalize every cell fragment to PDF-safe ASCII/Latin-1 and map arrows
if (d && d.cell) {
const arr = Array.isArray(d.cell.text) ? d.cell.text : [String(d.cell.text ?? '')];
d.cell.text = arr.map(t => toPdfSafe(replaceSymbols(String(t))));
}
if (d.section === 'body') {
// Status einfärben
if (d.column.index === 5) {
const val = String(d.cell.raw || '').toLowerCase();
if (val === 'pass') d.cell.styles.textColor = ok;
else if (val === 'fail') d.cell.styles.textColor = fail;
else if (val === 'skip' || val === 'na') d.cell.styles.textColor = skip;
}
// "Erwartet" als Monospace + leicht grauer Hintergrund
if (d.column.index === 4) {
d.cell.styles.font = 'courier';
d.cell.styles.fillColor = [250, 250, 250];
}
}
},
margin: {
left: marginL,
right: marginR
},
pageBreak: 'auto'
});
// Footer: Seitenzahlen & Zeitstempel
const pageCount = doc.getNumberOfPages();
const ts = new Date().toLocaleString();
for (let p = 1; p <= pageCount; p++) {
doc.setPage(p);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(...muted);
doc.text(`Seite ${p} / ${pageCount}`, xRight, footerY, {
align: 'right'
});
doc.text(ts, marginL, footerY);
}
return doc.output('blob');
}
async function printPDF() {
const run = collectRun();
if (!run) return;
if (!checkRequired(run)) return;
renumberSteps();
const logoDataUrl = await imgToDataURL('logo_light.png').catch(() => null);
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// ===== Funktion: badge (Arrow) =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
const badge = (status) => {
const map = {
pass: {
txt: '✅ PASS',
bg: '#DCFCE7',
bd: '#22c55e'
},
fail: {
txt: '❌ FAIL',
bg: '#FEE2E2',
bd: '#ef4444'
},
skip: {
txt: '⏭️ SKIP',
bg: '#E5E7EB',
bd: '#6b7280'
},
blocked: {
txt: '⛔ BLOCK',
bg: '#FEF3C7',
bd: '#f59e0b'
},
'': {
txt: '—',
bg: '#F3F4F6',
bd: '#9ca3af'
}
};
const m = map[status || ''] || map[''];
return `<span style="display:inline-block;padding:2px 8px;border:1px solid ${m.bd};border-radius:999px;background:${m.bg};font-weight:600;font-size:12px">${m.txt}</span>`;
};
const counts = {
pass: 0,
fail: 0,
skip: 0,
blocked: 0
};
run.steps.forEach(s => {
if ((s.kind || 'step') !== 'step') return;
if (counts[s.status] !== undefined) counts[s.status]++;
});
const summary = `${counts.pass}${counts.fail} ⏭️ ${counts.skip}${counts.blocked}`;
let rows = '';
let seenGroup = false;
for (const s of run.steps) {
if ((s.kind || 'step') === 'group') {
const t = esc(s.title || '');
const cls = `group${seenGroup ? ' pagebreak' : ''}`;
rows += `<tr class="${cls}"><td colspan="5"><strong>${t}</strong></td></tr>`;
seenGroup = true;
continue;
}
rows += `
<tr>
<td><strong>${esc(s.id)}</strong><div class="stitle">${esc(s.title)}${s.required?' 📌':''}</div></td>
<td>${esc(s.expected)}</td>
<td class="status">${badge(s.status||'')}</td>
<td>${esc(s.comment)}</td>
<td>${linkify(s.evidence)}</td>
</tr>`;
}
const html = `<!doctype html>
<html><head><meta charset="utf-8"><title>QA Report</title>
<style>
:root{ --text:#111827; --muted:#4b5563; --line:#d1d5db; --brandLight:#f3f4f6; }
*{box-sizing:border-box}
body{font-family:system-ui,Segoe UI,Roboto,Arial,sans-serif;margin:24px;color:var(--text); background:#fff}
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.brand{display:flex;gap:10px;align-items:center}
.brand img{height:28px;width:auto;object-fit:contain}
h1{margin:0;font-size:20px}
.summary{margin:6px 0 0 0;font-weight:600}
.meta{border:1px solid var(--line);border-radius:10px;padding:10px;background:#fff;margin:8px 0 14px 0}
.meta ul{margin:0;padding-left:16px;color:var(--muted)}
table{width:100%;border-collapse:collapse;margin-top:4px}
thead th{background:var(--brandLight);text-align:left;border-bottom:1px solid var(--line);padding:8px}
td{border-bottom:1px solid var(--line);padding:8px;vertical-align:top}
td.status{white-space:nowrap}
.stitle{color:var(--muted);font-size:12px;margin-top:2px}
.legend{font-size:12px;color:var(--muted);margin-top:4px}
tr.group td{ background:#e8f6fd; border-top:2px solid #bfe7fb; font-weight:700 }
/* Seitenumbruch vor jeder Gruppe (außer der ersten) */
@media print{
tr.pagebreak{ break-before: page; page-break-before: always; }
}
a{color:#1d4ed8;text-decoration:underline}
@media print{ body{margin:10mm} thead th, td{border-color:#000} }
</style>
</head>
<body>
<header>
<div class="brand">
${logoDataUrl ? `<img src="${logoDataUrl}" alt="Logo">` : ``}
<h1>Testprotokoll</h1>
</div>
<div class="summary">${summary}</div>
</header>
<section class="meta">
<ul>
<li><strong>Modul:</strong> ${esc(run.module||'')}</li>
<li><strong>Modul-Version:</strong> ${esc(run.module_version||'')}</li>
<li><strong>PBX-Version:</strong> ${esc(run.pbx_version||'')}</li>
<li><strong>DocBee:</strong> ${esc(run.docbee_url||'')}</li>
<li><strong>Tester:</strong> ${esc(run.tester||'')}</li>
<li><strong>Datum:</strong> ${new Date(run.ts).toLocaleString('de-DE')}</li>
</ul>
<div class="legend">Legende: ✅ Pass, ❌ Fail, ⏭️ Skip, ⛔ Blocked, 📌 Pflicht</div>
</section>
<table>
<thead><tr><th>Schritt</th><th>Erwartung</th><th>Status</th><th>Kommentar</th><th>Evidenz</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<script>
(function(){
function go(){
try{ window.focus(); }catch(e){}
setTimeout(function(){ window.print(); }, 120);
}
if (document.readyState === 'complete') go();
else window.addEventListener('load', go);
window.onafterprint = function(){
setTimeout(function(){ try{ window.close(); }catch(e){} }, 50);
};
setTimeout(function(){ try{ window.close(); }catch(e){} }, 3000);
})();
</script>
</body></html>`;
const blob = new Blob([html], {
type: 'text/html'
});
const url = URL.createObjectURL(blob);
const w = window.open(url, '_blank', 'noopener');
if (!w) {
alert('Pop-up blockiert. Bitte Pop-ups erlauben.');
URL.revokeObjectURL(url);
return;
}
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
function toPdfSafe(s) {
return String(s).replace(/[^\x09\x0A\x0D\x20-\x7E\u00A0-\u00FF]/g, '?');
}
async function imgToDataURL(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const c = document.createElement('canvas');
c.width = img.naturalWidth;
c.height = img.naturalHeight;
const ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0);
resolve(c.toDataURL('image/png'));
};
img.onerror = reject;
img.src = src;
});
}

195
qa-tool/htdocs/js/steps.js Normal file
View File

@@ -0,0 +1,195 @@
// auto-split module
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.');
}
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 || ''
};
});
}
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;
}
function makeStepId(n) {
const num = String(Math.max(1, n)).padStart(3, '0');
return `step-${num}`;
}
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++;
});
}
function ensureRenumberAndRender() {
renumberSteps();
renderSteps(template.steps);
recomputeGroupStyles();
}
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();
});
}
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 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'));
}

101
qa-tool/htdocs/js/ui.js Normal file
View File

@@ -0,0 +1,101 @@
// auto-split module
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);
}
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).');
}
}
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');
}
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;
}
const badge = (status) => {
const map = {
pass: {
txt: '✅ PASS',
bg: '#DCFCE7',
bd: '#22c55e'
},
fail: {
txt: '❌ FAIL',
bg: '#FEE2E2',
bd: '#ef4444'
},
skip: {
txt: '⏭️ SKIP',
bg: '#E5E7EB',
bd: '#6b7280'
},
blocked: {
txt: '⛔ BLOCK',
bg: '#FEF3C7',
bd: '#f59e0b'
},
'': {
txt: '—',
bg: '#F3F4F6',
bd: '#9ca3af'
}
};
const m = map[status || ''] || map[''];
return `<span style="display:inline-block;padding:2px 8px;border:1px solid ${m.bd};border-radius:999px;background:${m.bg};font-weight:600;font-size:12px">${m.txt}</span>`;
}

View File

@@ -0,0 +1,96 @@
// auto-split module
const DOCBEE_UI_BASE = DOCBEE_BASEURL; // feste Instanzbasis für UI-Links
const SEP = '────────────────────────────────────────────────────────────────────────';
const SMAP = {
pass: '✅',
fail: '❌',
skip: '⏭️',
blocked: '⛔'
};
// auto-split module
const escAttr = (s) => String(s ?? '').replaceAll('"', '&quot;');
const escHTML = (s) => String(s ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
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);
const hasToken = () => typeof DOCBEE_TOKEN === 'string' && DOCBEE_TOKEN.trim().length > 0;
// auto-split module
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);
}
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);
});
}
function extractTicketId(u) {
const m = String(u || "").match(/(?:\/ticket\/show\/|\/tickets\/)(\d+)/i);
return m ? m[1] : null;
}
function rowIndexOf(target) {
const tr = target.closest('tr');
if (!tr) return -1;
return [...els.stepsTableBody.children].indexOf(tr);
}
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');
}
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));
}