ITSM/static/app.js

343 lines
18 KiB
JavaScript
Executable File

/* ITSM Frontend-Logik -- CSP-konform: kein Inline-JS, keine onclick-Attribute.
Alle zustandsaendernden Requests senden das CSRF-Token als Header. */
(function () {
"use strict";
var CSRF = (document.querySelector('meta[name="csrf"]') || {}).content || "";
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function post(url, params) {
var body = new URLSearchParams(params || {}).toString();
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRF-Token": CSRF
},
body: body
}).then(function (r) { return r.json().catch(function () { return { ok: r.ok }; }); });
}
function initials(label) {
var local = String(label || "?").split("@")[0];
var parts = local.split(/[._\- ]+/).filter(Boolean);
var s = parts.slice(0, 2).map(function (p) { return p[0].toUpperCase(); }).join("");
return s || "?";
}
// ── Zeilen-Navigation + Ticket-Detail ────────────────────────────────────────
document.addEventListener("click", function (ev) {
var row = ev.target.closest(".row");
if (!row) return;
if (ev.target.closest("a, button, form, select, input, textarea")) return;
if (row.dataset.ticketId) openDetail(parseInt(row.dataset.ticketId, 10));
else if (row.dataset.href) location.href = row.dataset.href;
});
// Formulare mit Bestaetigung + Auto-Submit-Selects + Change-Typ-Anzeige
document.addEventListener("submit", function (ev) {
var f = ev.target;
if (f.dataset && f.dataset.confirm && !window.confirm(f.dataset.confirm)) {
ev.preventDefault();
}
});
document.addEventListener("change", function (ev) {
if (ev.target.matches("select[data-autosubmit]")) ev.target.form.submit();
if (ev.target.id === "new-kategorie") {
var row = document.getElementById("change-typ-row");
if (row) row.hidden = ev.target.value !== "Change";
}
if (ev.target.id === "check-all") {
document.querySelectorAll(".row-check").forEach(function (c) { c.checked = ev.target.checked; });
}
});
// ── Modal "Neues Ticket" ─────────────────────────────────────────────────────
var modal = document.getElementById("new-ticket-modal");
var openBtn = document.getElementById("open-new-ticket");
var closeBtn = document.getElementById("close-new-ticket");
if (openBtn) openBtn.addEventListener("click", function () { modal.hidden = false; });
if (closeBtn) closeBtn.addEventListener("click", function () { modal.hidden = true; });
if (modal) modal.addEventListener("click", function (ev) { if (ev.target === modal) modal.hidden = true; });
// ── Aktionen-Dropdown + Bulk-Status ──────────────────────────────────────────
var bulkBtn = document.getElementById("bulk-actions-btn");
var bulkMenu = document.getElementById("bulk-menu");
if (bulkBtn) {
bulkBtn.addEventListener("click", function (ev) {
ev.stopPropagation();
bulkMenu.hidden = !bulkMenu.hidden;
});
document.addEventListener("click", function () { bulkMenu.hidden = true; });
bulkMenu.querySelectorAll("[data-bulk-status]").forEach(function (b) {
b.addEventListener("click", function () {
var ids = Array.prototype.map.call(document.querySelectorAll(".row-check:checked"), function (c) { return c.value; });
if (!ids.length) { alert("Keine Tickets ausgewaehlt."); return; }
var status = b.dataset.bulkStatus;
var done = 0, errors = [];
var next = function () {
if (!ids.length) {
if (errors.length) alert("Teilweise fehlgeschlagen (ITIL-Statusmodell):\n" + errors.join("\n"));
location.reload();
return;
}
var id = ids.shift();
post("/tickets/" + id + "/status", { status: status }).then(function (d) {
if (!d.ok) errors.push("#" + id + ": " + (d.error || "Fehler"));
done++; next();
});
};
next();
});
});
}
// ── Detail-Panel ─────────────────────────────────────────────────────────────
var overlay = document.getElementById("detail-overlay");
var panel = document.getElementById("detail-panel");
if (overlay) overlay.addEventListener("click", closeDetail);
function openDetail(id, tab) {
fetch("/api/tickets/" + id, { headers: { "Accept": "application/json" } })
.then(function (r) { return r.json(); })
.then(function (t) {
if (!t || t.ok === false) return;
panel.innerHTML = renderDetail(t, tab || "details");
bindDetail(t, tab || "details");
panel.classList.add("open");
overlay.classList.add("open");
});
}
function closeDetail() {
panel.classList.remove("open");
overlay.classList.remove("open");
}
function pill(text, cls) {
return '<span class="pill ' + (cls || "offen") + '">' + esc(text) + "</span>";
}
function statusCls(s) {
return { "In Bearbeitung": "inbearbeitung", "Warten": "warten", "Geloest": "geloest",
"Geschlossen": "geschlossen" }[s] || "offen";
}
function prioCls(p) {
return { "Kritisch": "krit", "Hoch": "hoch", "Niedrig": "niedrig" }[p] || "mittel";
}
function renderDetail(t, tab) {
var h = "";
h += '<div class="dt-head"><h2>' + esc(t.ticket_nr) + '</h2><button class="linklike" id="dt-close">✕</button></div>';
h += '<div class="dt-service"><span class="avatar">' + esc(initials(t.service_name || t.titel)) + '</span>';
h += '<div><b>' + esc(t.service_name || "(kein Service)") + '</b><div class="sz">' + esc(t.titel) + '</div></div></div>';
h += '<div class="dt-kv">';
h += '<span class="k">Status</span><span>' + pill(t.status, statusCls(t.status)) + '</span>';
h += '<span class="k">Prioritaet</span><span>' + pill(t.prioritaet, prioCls(t.prioritaet)) + '</span>';
h += '<span class="k">Kategorie</span><span>' + pill(t.kategorie) +
(t.kategorie === "Change" && t.change_typ ? " " + pill(t.change_typ) + " " + pill("Freigabe: " + (t.approval_status || "-")) : "") +
(t.known_error ? " " + pill("Known Error", "ueberfaellig") : "") + '</span>';
h += '<span class="k">Zugewiesen an</span><span><span class="avatar sm">' + esc(initials(t.zugewiesen_an)) + '</span> ' + esc(t.zugewiesen_an || "--") + '</span>';
h += '<span class="k">Impact / Urgency</span><span>' + esc(t.impact) + ' / ' + esc(t.urgency) + '</span>';
h += '<span class="k">Fortschritt</span><span>' + t.fortschritt + '%<div class="bar-track"><div class="bar-fill" style="width:' + t.fortschritt + '%"></div></div></span>';
if (t.problem_nr) h += '<span class="k">Problem</span><span>' + esc(t.problem_nr) + '</span>';
h += '</div>';
var tabs = [["details", "Details"], ["akt", "Aktivitaeten"], ["anh", "Anhaenge"], ["auf", "Aufgaben"]];
h += '<div class="dt-tabs">' + tabs.map(function (x) {
return '<span data-dt-tab="' + x[0] + '" class="' + (tab === x[0] ? "active" : "") + '">' + x[1] + '</span>';
}).join("") + '</div>';
if (tab === "details") {
h += '<div class="sect">Beschreibung</div><div>' + esc(t.beschreibung || "--") + '</div>';
h += '<div class="sect">Betroffene Services</div><div>' + pill(t.service_name || "(kein Service)") + '</div>';
if (t.operative) {
h += '<div class="sect">CI / Betroffene Konfigurationselemente</div><div>';
(t.cis || []).forEach(function (c) {
h += '<span class="pill agent tagpill">' + esc(c.name) +
' <button class="linklike" data-ci-unlink="' + c.id + '">x</button></span>';
});
if (!(t.cis || []).length) h += '<span class="sz">keine</span>';
if (t.cis_available && t.cis_available.length) {
h += '<div style="margin-top:6px"><select id="dt-ci">';
t.cis_available.forEach(function (c) {
h += '<option value="' + c.id + '">' + esc(c.name + " (" + c.typ + ")") + "</option>";
});
h += '</select> <button class="btn ghost mini" id="dt-ci-add">+ Verknuepfen</button></div>';
}
h += '</div>';
}
if (t.sla) {
h += '<div class="sect">SLAs</div><div class="dt-kv">';
h += '<span class="k">Antwortzeit</span><span><span class="sla-chip' + (t.sla.response_breached ? " breach" : "") + '">' + esc(t.sla.response_label) + '</span></span>';
h += '<span class="k">Loesungszeit</span><span><span class="sla-chip' + (t.sla.resolve_breached ? " breach" : "") + '">' + esc(t.sla.resolve_label) + '</span></span>';
h += '</div>';
}
if (t.operative && t.kategorie !== "Problem" && t.problems && t.problems.length) {
h += '<div class="sect">Mit Problem verknuepfen</div><div>';
h += '<select id="dt-problem"><option value="">(keine Verknuepfung)</option>';
t.problems.forEach(function (p) {
var sel = t.problem_id === p.id ? " selected" : "";
h += '<option value="' + p.id + '"' + sel + ">" + esc(p.nr + " " + p.titel) + "</option>";
});
h += '</select> <button class="btn ghost mini" id="dt-problem-save">Speichern</button></div>';
}
if (t.operative && t.kategorie === "Problem") {
h += '<div class="sect">Problem Management</div><div>';
h += '<button class="btn ghost mini" data-known-error="' + (t.known_error ? "0" : "1") + '">' +
(t.known_error ? "Known-Error entfernen" : "Als Known Error markieren") + "</button></div>";
}
if (t.can_approve) {
h += '<div class="sect">Change-Freigabe (CAB)</div><div>';
h += '<button class="btn mini" data-approve="Genehmigt">Genehmigen</button> ';
h += '<button class="btn ghost mini" data-approve="Abgelehnt">Ablehnen</button></div>';
}
if (t.can_repo_edit) {
h += '<div class="sect">Repo-Datei bearbeiten (Forge, dokumentationspflichtig)</div>';
h += '<div class="panel">';
h += '<div class="grid-2"><input type="text" id="repo-name" placeholder="Repo (z.B. demo)">';
h += '<input type="text" id="repo-branch" placeholder="Branch" value="main"></div>';
h += '<div class="formrow"><input type="text" id="repo-path" placeholder="Dateipfad (z.B. README.md)"></div>';
h += '<button class="btn ghost mini" id="repo-load">Datei laden</button>';
h += '<div id="repo-status" class="sz" style="margin:6px 0"></div>';
h += '<textarea id="repo-content" rows="8" class="mono" hidden></textarea>';
h += '<input type="hidden" id="repo-sha">';
h += '<div class="formrow" id="repo-msg-row" hidden><input type="text" id="repo-msg" placeholder="Commit-Nachricht"></div>';
h += '<button class="btn mini" id="repo-save" hidden>Speichern (committen)</button>';
h += '</div>';
}
} else if (tab === "akt") {
h += '<div class="sect">Zeitleiste</div>';
h += ((t.timeline || []).map(function (e) {
return '<div class="tl-item"><b>' + esc(e.akteur || "") + '</b><div class="sz">' +
esc(e.zeit) + "</div><div>" + esc(e.text || "") + "</div></div>";
}).join("")) || '<div class="sz">Keine Ereignisse.</div>';
} else {
h += '<div class="sz" style="margin-top:10px">' +
(tab === "anh" ? "Anhaenge werden in einer spaeteren Ausbaustufe unterstuetzt." :
"Aufgaben werden in einer spaeteren Ausbaustufe unterstuetzt.") + '</div>';
}
h += '<div class="dt-footer">';
if (t.operative && t.allowed_next && t.allowed_next.length) {
h += '<select id="dt-status"><option value="">Status aendern ...</option>' +
t.allowed_next.map(function (s) { return '<option value="' + esc(s) + '">' + esc(s) + '</option>'; }).join("") +
'</select>';
}
h += '<button class="btn" id="dt-comment-btn">Kommentar hinzufuegen</button>';
h += '</div>';
h += '<div id="dt-comment-box" hidden style="margin-top:8px">';
h += '<textarea id="dt-comment" rows="2" placeholder="Kommentar (Worklog) ..."></textarea>';
h += '<div style="margin-top:6px"><button class="btn ghost mini" id="dt-comment-save">Speichern</button></div></div>';
return h;
}
function bindDetail(t, tab) {
var id = t.id;
panel.querySelectorAll("[data-dt-tab]").forEach(function (el) {
el.addEventListener("click", function () { openDetail(id, el.dataset.dtTab); });
});
var statusSel = panel.querySelector("#dt-status");
if (statusSel) statusSel.addEventListener("change", function () {
if (!statusSel.value) return;
post("/tickets/" + id + "/status", { status: statusSel.value }).then(function (d) {
if (d.ok) openDetail(id, tab); else alert(d.error || "Fehler");
});
});
panel.querySelectorAll("[data-approve]").forEach(function (b) {
b.addEventListener("click", function () {
post("/tickets/" + id + "/approval", { decision: b.dataset.approve }).then(function (d) {
if (d.ok) openDetail(id, tab); else alert(d.error || "Fehler");
});
});
});
panel.querySelectorAll("[data-known-error]").forEach(function (b) {
b.addEventListener("click", function () {
post("/tickets/" + id + "/known-error", { known_error: b.dataset.knownError }).then(function (d) {
if (d.ok) openDetail(id, tab); else alert(d.error || "Fehler");
});
});
});
panel.querySelectorAll("[data-ci-unlink]").forEach(function (b) {
b.addEventListener("click", function () {
post("/tickets/" + id + "/ci-unlink", { ci_id: b.dataset.ciUnlink }).then(function () { openDetail(id, tab); });
});
});
var ciAdd = panel.querySelector("#dt-ci-add");
if (ciAdd) ciAdd.addEventListener("click", function () {
post("/tickets/" + id + "/ci-link", { ci_id: panel.querySelector("#dt-ci").value })
.then(function () { openDetail(id, tab); });
});
var probSave = panel.querySelector("#dt-problem-save");
if (probSave) probSave.addEventListener("click", function () {
post("/tickets/" + id + "/problem-link", { problem_id: panel.querySelector("#dt-problem").value })
.then(function (d) { if (d.ok) openDetail(id, tab); else alert(d.error || "Fehler"); });
});
var commentBtn = panel.querySelector("#dt-comment-btn");
if (commentBtn) commentBtn.addEventListener("click", function () {
panel.querySelector("#dt-comment-box").hidden = false;
panel.querySelector("#dt-comment").focus();
});
var commentSave = panel.querySelector("#dt-comment-save");
if (commentSave) commentSave.addEventListener("click", function () {
var text = panel.querySelector("#dt-comment").value.trim();
if (!text) return;
post("/tickets/" + id + "/comment", { text: text }).then(function (d) {
if (d.ok) openDetail(id, "akt"); else alert(d.error || "Fehler");
});
});
var close = panel.querySelector("#dt-close");
if (close) close.addEventListener("click", closeDetail);
var repoLoad = panel.querySelector("#repo-load");
if (repoLoad) {
repoLoad.addEventListener("click", function () {
var repo = panel.querySelector("#repo-name").value.trim();
var path = panel.querySelector("#repo-path").value.trim();
var branch = panel.querySelector("#repo-branch").value.trim() || "main";
var status = panel.querySelector("#repo-status");
if (!repo || !path) { status.textContent = "Repo und Dateipfad angeben."; return; }
status.textContent = "Laedt...";
fetch("/api/tickets/" + id + "/repo-file?repo=" + encodeURIComponent(repo) +
"&path=" + encodeURIComponent(path) + "&branch=" + encodeURIComponent(branch))
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.ok) { status.textContent = "Fehler: " + d.error; return; }
status.textContent = "Geladen (sha " + d.sha.slice(0, 10) + ").";
var ta = panel.querySelector("#repo-content");
ta.value = d.content;
ta.hidden = false;
panel.querySelector("#repo-sha").value = d.sha;
panel.querySelector("#repo-msg-row").hidden = false;
panel.querySelector("#repo-save").hidden = false;
})
.catch(function () { status.textContent = "Fehler beim Laden."; });
});
panel.querySelector("#repo-save").addEventListener("click", function () {
var status = panel.querySelector("#repo-status");
var message = panel.querySelector("#repo-msg").value.trim();
if (!message) { status.textContent = "Bitte eine Commit-Nachricht angeben."; return; }
status.textContent = "Speichert...";
post("/tickets/" + id + "/repo-edit", {
repo: panel.querySelector("#repo-name").value.trim(),
path: panel.querySelector("#repo-path").value.trim(),
branch: panel.querySelector("#repo-branch").value.trim() || "main",
sha: panel.querySelector("#repo-sha").value,
content: panel.querySelector("#repo-content").value,
message: message
}).then(function (d) {
if (!d.ok) { status.textContent = "Fehler: " + d.error; return; }
status.textContent = "Gespeichert -- Commit " + d.commit.slice(0, 10) + ", im Worklog dokumentiert.";
openDetail(id, "akt");
}).catch(function () { status.textContent = "Fehler beim Speichern."; });
});
}
}
})();