Viel neues
This commit is contained in:
151
qa-tool/htdocs/js/docbee.js
Normal file
151
qa-tool/htdocs/js/docbee.js
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
93
qa-tool/htdocs/js/export.js
Normal file
93
qa-tool/htdocs/js/export.js
Normal 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
314
qa-tool/htdocs/js/flow.js
Normal 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
102
qa-tool/htdocs/js/gitlab.js
Normal 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
253
qa-tool/htdocs/js/main.js
Normal 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
515
qa-tool/htdocs/js/pdf.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
// ===== 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
195
qa-tool/htdocs/js/steps.js
Normal 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
101
qa-tool/htdocs/js/ui.js
Normal 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>`;
|
||||
}
|
||||
96
qa-tool/htdocs/js/utils.js
Normal file
96
qa-tool/htdocs/js/utils.js
Normal 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('"', '"');
|
||||
|
||||
|
||||
const escHTML = (s) => String(s ?? '').replaceAll('&', '&').replaceAll('<', '<').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);
|
||||
|
||||
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user