Add EPI-Support monitoring & analytics platform

Internal Flask app that monitors the epi-helpdesk.zammad.com tickets via
the Zammad API, deployable as a systemd service behind Apache under
/episupport.

Features:
- Poller: session-login API sync, snapshot diff to detect agent changes
  (status, owner, priority, closures, new replies); email notifications
- Dashboard: ticket overview with sorting, "not closed" filter and
  highlighting of tickets with unconfirmed changes; per-item and bulk
  "mark as read"
- Analytics: KPIs, response/resolution times, monthly SVG trend chart,
  distributions (status/priority/group/agent), Excel export
- Conversation view: full per-ticket thread cached in the DB (avoids
  proxy timeouts), inline image attachments, safe HTML-to-text rendering
- Write actions: reply to tickets and create new tickets with a
  confirmation step and attachment upload (incl. clipboard paste);
  self-triggered actions are synced back so they aren't flagged as changes
- Local timezone display (Europe/Berlin) for all timestamps

Includes deploy tooling: setup.sh, systemd units and Apache config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 09:19:53 +02:00
co-authored by Claude Opus 4.8
parent da2f7c3c86
commit 1041f283a4
17 changed files with 600 additions and 57 deletions
+78
View File
@@ -0,0 +1,78 @@
// Anhänge: Dateiauswahl, Einfügen aus Zwischenablage (Strg+V) und Drag & Drop.
(function () {
function initAttach(form) {
var input = form.querySelector(".attach-input");
var list = form.querySelector(".attach-list");
if (!input) return;
var dt = new DataTransfer();
var pasteCount = 0;
function refresh() {
input.files = dt.files;
list.innerHTML = "";
Array.prototype.forEach.call(dt.files, function (f, i) {
var chip = document.createElement("span");
chip.className = "attach-chip";
chip.textContent = (f.name || "Datei") + " ";
var x = document.createElement("button");
x.type = "button";
x.textContent = "×";
x.title = "Entfernen";
x.addEventListener("click", function () {
var keep = new DataTransfer();
Array.prototype.forEach.call(dt.files, function (ff, j) {
if (j !== i) keep.items.add(ff);
});
dt = keep;
refresh();
});
chip.appendChild(x);
list.appendChild(chip);
});
}
function addFiles(files) {
var added = false;
Array.prototype.forEach.call(files, function (f) {
if (f) { dt.items.add(f); added = true; }
});
if (added) refresh();
}
input.addEventListener("change", function () {
addFiles(input.files);
});
form.addEventListener("paste", function (e) {
var items = e.clipboardData && e.clipboardData.items;
if (!items) return;
var imgs = [];
for (var i = 0; i < items.length; i++) {
if (items[i].kind === "file") {
var f = items[i].getAsFile();
if (f) {
if (!f.name || f.name === "image.png") {
pasteCount++;
var ext = (f.type && f.type.split("/")[1]) || "png";
f = new File([f], "einfuegung-" + pasteCount + "." + ext, { type: f.type });
}
imgs.push(f);
}
}
}
if (imgs.length) { e.preventDefault(); addFiles(imgs); }
});
["dragover", "dragenter"].forEach(function (ev) {
form.addEventListener(ev, function (e) { e.preventDefault(); form.classList.add("dragging"); });
});
["dragleave", "drop"].forEach(function (ev) {
form.addEventListener(ev, function (e) { e.preventDefault(); form.classList.remove("dragging"); });
});
form.addEventListener("drop", function (e) {
if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
});
}
document.querySelectorAll("form.attach-form").forEach(initAttach);
})();