[deploy] expand ticket dashboard ui
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s

This commit is contained in:
Pascal Prießnitz
2025-12-03 13:56:37 +01:00
parent 5bf42f4610
commit a02aede837
2 changed files with 591 additions and 62 deletions

View File

@@ -0,0 +1,38 @@
-- AlterTable
ALTER TABLE "Ticket" ADD COLUMN "firstClaimAt" TIMESTAMP(3),
ADD COLUMN "firstResponseAt" TIMESTAMP(3),
ADD COLUMN "kbSuggestionSentAt" TIMESTAMP(3),
ALTER COLUMN "status" SET DEFAULT 'neu';
-- CreateTable
CREATE TABLE "TicketAutomationRule" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"condition" JSONB NOT NULL,
"action" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TicketAutomationRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KnowledgeBaseArticle" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"keywords" TEXT[],
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KnowledgeBaseArticle_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TicketAutomationRule_guildId_active_idx" ON "TicketAutomationRule"("guildId", "active");
-- CreateIndex
CREATE INDEX "KnowledgeBaseArticle_guildId_idx" ON "KnowledgeBaseArticle"("guildId");

View File

@@ -1,4 +1,4 @@
import { Router } from 'express'; import { Router } from 'express';
const router = Router(); const router = Router();
@@ -43,18 +43,18 @@ router.get('/', (req, res) => {
<aside class="sidebar"> <aside class="sidebar">
<div class="brand">Papo Control</div> <div class="brand">Papo Control</div>
<div class="nav"> <div class="nav">
<a class="active" href="#overview" data-target="overview"><span class="icon">🏠</span> Uebersicht</a> <a class="active" href="#overview" data-target="overview"><span class="icon">🏠</span> Uebersicht</a>
<a href="#tickets" data-target="tickets"><span class="icon">🎫</span> Ticketsystem</a> <a href="#tickets" data-target="tickets"><span class="icon">🎫</span> Ticketsystem</a>
<a href="#automod" data-target="automod" class="automod-link"><span class="icon">🛡️</span> Automod</a> <a href="#automod" data-target="automod" class="automod-link"><span class="icon">🛡️</span> Automod</a>
<a href="#welcome" data-target="welcome" class="welcome-link"><span class="icon"></span> Willkommen</a> <a href="#welcome" data-target="welcome" class="welcome-link"><span class="icon">✨</span> Willkommen</a>
<a href="#dynamicvoice" data-target="dynamicvoice" class="dynamicvoice-link"><span class="icon">🎧</span> Dynamic Voice</a> <a href="#dynamicvoice" data-target="dynamicvoice" class="dynamicvoice-link"><span class="icon">🎧</span> Dynamic Voice</a>
<a href="#birthday" data-target="birthday" class="birthday-link"><span class="icon">🎂</span> Birthday</a> <a href="#birthday" data-target="birthday" class="birthday-link"><span class="icon">🎂</span> Birthday</a>
<a href="#reactionroles" data-target="reactionroles" class="reactionroles-link"><span class="icon">😎</span> Reaction Roles</a> <a href="#reactionroles" data-target="reactionroles" class="reactionroles-link"><span class="icon">😎</span> Reaction Roles</a>
<a href="#statuspage" data-target="statuspage" class="statuspage-link"><span class="icon">🖥️</span> Statuspage</a> <a href="#statuspage" data-target="statuspage" class="statuspage-link"><span class="icon">🖥️</span> Statuspage</a>
<a href="#settings" data-target="settings"><span class="icon">⚙️</span> Einstellungen</a> <a href="#settings" data-target="settings"><span class="icon">⚙️</span> Einstellungen</a>
<a href="#modules" data-target="modules"><span class="icon">🧩</span> Module</a> <a href="#modules" data-target="modules"><span class="icon">🧩</span> Module</a>
<a href="#events" data-target="events" class="events-link"><span class="icon">📅</span> Events</a> <a href="#events" data-target="events" class="events-link"><span class="icon">📅</span> Events</a>
<a href="#admin" data-target="admin" class="admin-link hidden"><span class="icon">🛡</span> Admin</a> <a href="#admin" data-target="admin" class="admin-link hidden"><span class="icon">🛡</span> Admin</a>
</div> </div>
<div class="muted">Angemeldet als <span id="userInfo"></span></div> <div class="muted">Angemeldet als <span id="userInfo"></span></div>
<button id="logoutBtn" class="logout">Logout</button> <button id="logoutBtn" class="logout">Logout</button>
@@ -186,6 +186,9 @@ router.get('/', (req, res) => {
.status-in-progress { background:rgba(255,184,70,0.16); color:#fbbf24; border-color:rgba(255,184,70,0.42); } .status-in-progress { background:rgba(255,184,70,0.16); color:#fbbf24; border-color:rgba(255,184,70,0.42); }
.status-closed { background:rgba(239,68,68,0.16); color:#f87171; border-color:rgba(239,68,68,0.42); } .status-closed { background:rgba(239,68,68,0.16); color:#f87171; border-color:rgba(239,68,68,0.42); }
.ticket-empty { padding:20px; text-align:center; color:var(--muted); border:1px dashed rgba(255,255,255,0.16); border-radius:14px; background:rgba(255,255,255,0.03); } .ticket-empty { padding:20px; text-align:center; color:var(--muted); border:1px dashed rgba(255,255,255,0.16); border-radius:14px; background:rgba(255,255,255,0.03); }
.ticket-tab { display:none; }
.ticket-tab.active { display:block; }
.ticket-tab-btn.active { background:rgba(249,115,22,0.18); border-color:rgba(249,115,22,0.45); color:var(--accent-strong); }
form { display:flex; flex-direction:column; gap:10px; margin-top:4px; } form { display:flex; flex-direction:column; gap:10px; margin-top:4px; }
.form-field { display:flex; flex-direction:column; gap:6px; } .form-field { display:flex; flex-direction:column; gap:6px; }
.form-label { font-size:13px; color:var(--muted); letter-spacing:0.2px; font-weight:600; } .form-label { font-size:13px; color:var(--muted); letter-spacing:0.2px; font-weight:600; }
@@ -323,41 +326,167 @@ router.get('/', (req, res) => {
</section> </section>
</div> </div>
</div> </div>
<div class="section" data-section="tickets"> <div class="section" data-section="tickets">
<section class="card"> <div class="card" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:space-between;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;"> <div>
<div> <p class="section-title">Tickets</p>
<p class="section-title">Tickets</p> <p class="section-sub">Übersicht, Pipeline, SLA, Automationen, Knowledge-Base.</p>
<p class="section-sub">Links auswaehlen, Details im Modal. Plus oeffnet Panel-Erstellung.</p>
</div>
<div class="row" style="gap:10px;">
<button class="secondary-btn" id="openSupportLogin" type="button">Support-Login Einstellungen</button>
<button class="icon-button" id="openPanelModal" title="Ticket-Panel erstellen">+</button>
</div>
</div> </div>
<div class="ticket-list-pane" id="ticketListPane"></div> <div class="row" style="gap:8px; flex-wrap:wrap;">
</section> <button class="secondary-btn ticket-tab-btn active" data-tab="overview">Übersicht</button>
<section class="card"> <button class="secondary-btn ticket-tab-btn" data-tab="pipeline">Pipeline</button>
<div class="row" style="justify-content:space-between; align-items:center;"> <button class="secondary-btn ticket-tab-btn" data-tab="sla">SLA</button>
<div> <button class="secondary-btn ticket-tab-btn" data-tab="automations">Automationen</button>
<p class="section-title">Support-Login Status</p> <button class="secondary-btn ticket-tab-btn" data-tab="kb">Knowledge-Base</button>
<p class="section-sub">Aktive Supporter und letzte Sessions.</p>
</div>
<button class="icon-button" id="refreshSupportStatus">↻</button>
</div> </div>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap:12px;"> </div>
<div>
<p class="label">Aktiv</p> <div id="ticketTabOverview" class="ticket-tab active">
<div id="supportActiveList" class="ticket-list-pane" style="min-height:80px;"></div> <section class="card">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div>
<p class="section-title">Ticketliste</p>
<p class="section-sub">Links auswählen, Details im Modal. Plus öffnet Panel-Erstellung.</p>
</div>
<div class="row" style="gap:10px;">
<button class="secondary-btn" id="openSupportLogin" type="button">Support-Login Einstellungen</button>
<button class="icon-button" id="openPanelModal" title="Ticket-Panel erstellen">+</button>
</div>
</div> </div>
<div> <div class="ticket-list-pane" id="ticketListPane"></div>
<p class="label">Letzte Sessions</p> </section>
<div id="supportRecentList" class="ticket-list-pane" style="min-height:80px;"></div> <section class="card">
<div class="row" style="justify-content:space-between; align-items:center;">
<div>
<p class="section-title">Support-Login Status</p>
<p class="section-sub">Aktive Supporter und letzte Sessions.</p>
</div>
<button class="icon-button" id="refreshSupportStatus">⟳</button>
</div> </div>
</div> <div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap:12px;">
</section> <div>
</div> <p class="label">Aktiv</p>
<div class="section" data-section="automod"> <div id="supportActiveList" class="ticket-list-pane" style="min-height:80px;"></div>
</div>
<div>
<p class="label">Letzte Sessions</p>
<div id="supportRecentList" class="ticket-list-pane" style="min-height:80px;"></div>
</div>
</div>
</section>
</div>
<div id="ticketTabPipeline" class="ticket-tab">
<section class="card">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
<div>
<p class="section-title">Status-Pipeline</p>
<p class="section-sub">Tickets nach Phase. Status per Dropdown ändern.</p>
</div>
</div>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap:12px;" id="pipelineGrid">
<div><p class="label">Neu</p><div class="ticket-list-pane" id="pipelineNeu"></div></div>
<div><p class="label">In Bearbeitung</p><div class="ticket-list-pane" id="pipelineIn"></div></div>
<div><p class="label">Warten auf User</p><div class="ticket-list-pane" id="pipelineWait"></div></div>
<div><p class="label">Erledigt</p><div class="ticket-list-pane" id="pipelineDone"></div></div>
</div>
</section>
</div>
<div id="ticketTabSla" class="ticket-tab">
<section class="card">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
<div>
<p class="section-title">SLA / Response-Zeiten</p>
<p class="section-sub">Average Time to Claim / First Response.</p>
</div>
<select id="slaRange">
<option value="7">Letzte 7 Tage</option>
<option value="30" selected>Letzte 30 Tage</option>
<option value="90">Letzte 90 Tage</option>
</select>
</div>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap:12px;">
<div>
<p class="label">SLA pro Supporter</p>
<table style="width:100%; border-collapse:collapse;" id="slaSupporterTable">
<thead><tr><th style="text-align:left;">Supporter</th><th>Tickets</th><th>TTC</th><th>TTFR</th></tr></thead>
<tbody id="slaSupporterBody"></tbody>
</table>
</div>
<div>
<p class="label">SLA pro Tag</p>
<table style="width:100%; border-collapse:collapse;" id="slaDaysTable">
<thead><tr><th style="text-align:left;">Datum</th><th>Tickets</th><th>TTC</th><th>TTFR</th></tr></thead>
<tbody id="slaDaysBody"></tbody>
</table>
</div>
</div>
</section>
</div>
<div id="ticketTabAutomations" class="ticket-tab">
<section class="card">
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Automationen</p>
<p class="section-sub">Regeln für Ticket-Aktionen.</p>
</div>
<button class="secondary-btn" id="addAutomation">Neue Regel</button>
</div>
<div id="automationList" class="ticket-list-pane" style="margin-top:10px;"></div>
<div id="automationFormWrap" style="margin-top:12px;">
<p class="label">Regel bearbeiten</p>
<form id="automationForm" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(180px,1fr)); gap:10px;">
<input type="hidden" id="automationId" />
<div class="form-field"><label class="form-label">Name</label><input id="automationName" placeholder="Regelname" /></div>
<div class="form-field"><label class="form-label">Bedingung</label><select id="automationConditionType">
<option value="category">Kategorie</option>
<option value="status">Status</option>
<option value="age">Ticketalter (Stunden)</option>
</select></div>
<div class="form-field"><label class="form-label">Wert</label><input id="automationConditionValue" placeholder="z.B. Bug oder 24" /></div>
<div class="form-field"><label class="form-label">Aktion</label><select id="automationActionType">
<option value="pingRole">Rolle pingen</option>
<option value="reminder">Reminder posten</option>
<option value="flag">Status setzen</option>
</select></div>
<div class="form-field"><label class="form-label">Rolle/Status/Nachricht</label><input id="automationActionValue" placeholder="Rollen-ID, Status oder Nachricht" /></div>
<div class="form-field"><label class="form-label">Aktiv</label><div id="automationActive" class="switch on"></div></div>
<div class="form-field" style="grid-column:1/-1; display:flex; gap:8px;">
<button type="submit">Speichern</button>
<button type="button" class="secondary-btn" id="automationReset">Reset</button>
</div>
</form>
</div>
</section>
</div>
<div id="ticketTabKb" class="ticket-tab">
<section class="card">
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Knowledge-Base</p>
<p class="section-sub">Artikel für Self-Service.</p>
</div>
<button class="secondary-btn" id="addKb">Neuer Artikel</button>
</div>
<div id="kbList" class="ticket-list-pane" style="margin-top:10px;"></div>
<div id="kbFormWrap" style="margin-top:12px;">
<form id="kbForm" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px,1fr)); gap:10px;">
<input type="hidden" id="kbId" />
<div class="form-field"><label class="form-label">Titel</label><input id="kbTitle" placeholder="Titel" /></div>
<div class="form-field"><label class="form-label">Keywords (kommagetrennt)</label><input id="kbKeywords" placeholder="bug, musik, ticket" /></div>
<div class="form-field" style="grid-column:1/-1;"><label class="form-label">Inhalt/Link</label><textarea id="kbContent" rows="3" placeholder="Kurze Anleitung oder Link"></textarea></div>
<div class="form-field" style="grid-column:1/-1; display:flex; gap:8px;">
<button type="submit">Speichern</button>
<button type="button" class="secondary-btn" id="kbReset">Reset</button>
</div>
</form>
</div>
</section>
</div>
</div><div class="section" data-section="automod">
<section class="card"> <section class="card">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;"> <div style="display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<div> <div>
@@ -457,7 +586,7 @@ router.get('/', (req, res) => {
</div> </div>
<div class="form-field"> <div class="form-field">
<label class="form-label">Embed Footer</label> <label class="form-label">Embed Footer</label>
<input id="welcomeFooter" placeholder="Schön, dass du da bist!" /> <input id="welcomeFooter" placeholder="Schön, dass du da bist!" />
</div> </div>
</div> </div>
<div class="form-field"> <div class="form-field">
@@ -480,7 +609,7 @@ router.get('/', (req, res) => {
<div class="embed-color" id="welcomePreviewColor"></div> <div class="embed-color" id="welcomePreviewColor"></div>
<div class="embed-body"> <div class="embed-body">
<div class="embed-title" id="welcomePreviewTitle">Willkommen!</div> <div class="embed-title" id="welcomePreviewTitle">Willkommen!</div>
<div class="embed-desc" id="welcomePreviewDesc">Schön, dass du da bist.</div> <div class="embed-desc" id="welcomePreviewDesc">Schön, dass du da bist.</div>
<div class="embed-footer" id="welcomePreviewFooter">Footer</div> <div class="embed-footer" id="welcomePreviewFooter">Footer</div>
<img id="welcomePreviewImage" class="embed-image" style="display:none;" /> <img id="welcomePreviewImage" class="embed-image" style="display:none;" />
</div> </div>
@@ -607,7 +736,7 @@ router.get('/', (req, res) => {
</div> </div>
<div class="form-field"> <div class="form-field">
<label class="form-label">Eintraege (Emoji | Role ID | Label | Beschreibung)</label> <label class="form-label">Eintraege (Emoji | Role ID | Label | Beschreibung)</label>
<textarea id="reactionRoleEntries" rows="4" placeholder="😀 | 123456789 | Freunde | Erhaelt Freunde Rolle"></textarea> <textarea id="reactionRoleEntries" rows="4" placeholder="😀 | 123456789 | Freunde | Erhaelt Freunde Rolle"></textarea>
<p class="muted">Eine Zeile pro Zuordnung. Label/Beschreibung optional.</p> <p class="muted">Eine Zeile pro Zuordnung. Label/Beschreibung optional.</p>
</div> </div>
<div style="display:flex; justify-content:flex-end; gap:10px;"> <div style="display:flex; justify-content:flex-end; gap:10px;">
@@ -681,7 +810,7 @@ router.get('/', (req, res) => {
<section class="card"> <section class="card">
<div class="row" style="justify-content:space-between;"> <div class="row" style="justify-content:space-between;">
<div> <div>
<p class="section-title">Aktivität (letzte 24h)</p> <p class="section-title">Aktivität (letzte 24h)</p>
<p class="section-sub">Events/Commands pro Stunde</p> <p class="section-sub">Events/Commands pro Stunde</p>
</div> </div>
</div> </div>
@@ -691,7 +820,7 @@ router.get('/', (req, res) => {
<div class="row" style="justify-content:space-between;"> <div class="row" style="justify-content:space-between;">
<div> <div>
<p class="section-title">Logs</p> <p class="section-title">Logs</p>
<p class="section-sub">Neueste Einträge</p> <p class="section-sub">Neueste Einträge</p>
</div> </div>
</div> </div>
<ul class="log-list" id="adminLogs"></ul> <ul class="log-list" id="adminLogs"></ul>
@@ -718,13 +847,13 @@ router.get('/', (req, res) => {
</div> </div>
</div> </div>
<div class="option-grid"> <div class="option-grid">
<div class="option-card"><span>👋 User Join / Leave</span><div id="logJoinLeave" class="switch on"></div></div> <div class="option-card"><span>👋 User Join / Leave</span><div id="logJoinLeave" class="switch on"></div></div>
<div class="option-card"><span>✏️ Message Edit</span><div id="logMsgEdit" class="switch on"></div></div> <div class="option-card"><span>✏️ Message Edit</span><div id="logMsgEdit" class="switch on"></div></div>
<div class="option-card"><span>🗑️ Message Delete</span><div id="logMsgDelete" class="switch on"></div></div> <div class="option-card"><span>🗑️ Message Delete</span><div id="logMsgDelete" class="switch on"></div></div>
<div class="option-card"><span>🛡️ Automod Actions</span><div id="logAutomod" class="switch on"></div></div> <div class="option-card"><span>🛡️ Automod Actions</span><div id="logAutomod" class="switch on"></div></div>
<div class="option-card"><span>🎫 Ticket Actions</span><div id="logTickets" class="switch on"></div></div> <div class="option-card"><span>🎫 Ticket Actions</span><div id="logTickets" class="switch on"></div></div>
<div class="option-card"><span>🎵 Musik-Events</span><div id="logMusic" class="switch on"></div></div> <div class="option-card"><span>🎵 Musik-Events</span><div id="logMusic" class="switch on"></div></div>
<div class="option-card"><span>⚙️ System / Channels</span><div id="logSystem" class="switch on"></div></div> <div class="option-card"><span>⚙️ System / Channels</span><div id="logSystem" class="switch on"></div></div>
</div> </div>
<div style="display:flex; justify-content:flex-end; gap:10px; margin-top:12px;"> <div style="display:flex; justify-content:flex-end; gap:10px; margin-top:12px;">
<button id="loggingSave" type="button">Logging speichern</button> <button id="loggingSave" type="button">Logging speichern</button>
@@ -1065,6 +1194,11 @@ router.get('/', (req, res) => {
}; };
let currentGuild = '${guildId}'; let currentGuild = '${guildId}';
let ticketCache = []; let ticketCache = [];
let pipelineCache = { neu: [], in_bearbeitung: [], warten_auf_user: [], erledigt: [] };
let slaSupporters = [];
let slaDays = [];
let automationCache = [];
let kbCache = [];
let selectedTicket = null; let selectedTicket = null;
let activeModal = null; let activeModal = null;
let automodConfigCache = {}; let automodConfigCache = {};
@@ -1092,6 +1226,15 @@ router.get('/', (req, res) => {
setTimeout(() => toastEl.classList.remove('show'), 2200); setTimeout(() => toastEl.classList.remove('show'), 2200);
} }
function formatDuration(ms) {
if (ms === null || ms === undefined) return '-';
const minutes = Math.max(0, Math.round(ms / 60000));
if (minutes < 60) return minutes + 'm';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return hours + 'h ' + (mins ? mins + 'm' : '');
}
function applyNavVisibility() { function applyNavVisibility() {
const autoEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'automodEnabled') ? modulesCache['automodEnabled'] : true; const autoEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'automodEnabled') ? modulesCache['automodEnabled'] : true;
const welcomeEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'welcomeEnabled') ? modulesCache['welcomeEnabled'] : true; const welcomeEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'welcomeEnabled') ? modulesCache['welcomeEnabled'] : true;
@@ -1276,7 +1419,7 @@ router.get('/', (req, res) => {
actions.className = 'row'; actions.className = 'row';
const del = document.createElement('button'); const del = document.createElement('button');
del.className = 'danger-btn'; del.className = 'danger-btn';
del.textContent = 'Löschen'; del.textContent = 'Löschen';
del.addEventListener('click', async () => { del.addEventListener('click', async () => {
await deleteService(svc.id); await deleteService(svc.id);
}); });
@@ -1334,7 +1477,7 @@ router.get('/', (req, res) => {
if (res.ok) { if (res.ok) {
await loadStatuspage(); await loadStatuspage();
} else { } else {
showToast('Service löschen fehlgeschlagen', true); showToast('Service löschen fehlgeschlagen', true);
} }
} }
@@ -1540,6 +1683,10 @@ router.get('/', (req, res) => {
await loadModules(); await loadModules();
await loadOverview(); await loadOverview();
await loadTickets(); await loadTickets();
await loadPipeline();
await loadSla();
await loadAutomations();
await loadKb();
await loadAutomodSettings(currentGuild); await loadAutomodSettings(currentGuild);
await loadLoggingSettings(currentGuild); await loadLoggingSettings(currentGuild);
await loadWelcomeSettings(currentGuild); await loadWelcomeSettings(currentGuild);
@@ -1570,6 +1717,249 @@ router.get('/', (req, res) => {
renderTickets(); renderTickets();
} }
async function loadPipeline() {
if (!currentGuild) return;
const res = await fetch('/api/tickets/pipeline?guildId=' + encodeURIComponent(currentGuild));
if (!res.ok) return;
const data = await res.json();
pipelineCache = data.pipeline || pipelineCache;
renderPipeline();
}
function renderPipeline() {
const states = [
{ key: 'neu', el: document.getElementById('pipelineNeu') },
{ key: 'in_bearbeitung', el: document.getElementById('pipelineIn') },
{ key: 'warten_auf_user', el: document.getElementById('pipelineWait') },
{ key: 'erledigt', el: document.getElementById('pipelineDone') }
];
states.forEach((s) => {
if (!s.el) return;
const list = pipelineCache[s.key] || [];
if (!list.length) {
s.el.innerHTML = '<div class=\"ticket-empty\">Keine Tickets.</div>';
return;
}
s.el.innerHTML = '';
list.forEach((t) => {
const card = document.createElement('div');
card.className = 'ticket-list-item';
card.innerHTML =
'<div class=\"ticket-item-top\"><div class=\"ticket-title\">' +
(t.topic || 'Ticket') +
'</div><div class=\"ticket-status-badge\">' +
(t.status || '') +
'</div></div>' +
'<div class=\"ticket-meta\">Erstellt: ' +
(t.createdAt ? new Date(t.createdAt).toLocaleString() : '-') +
'</div>' +
'<div class=\"ticket-meta\">User: ' +
(t.userId || '-') +
(t.claimedBy ? ' · Supporter: ' + t.claimedBy : '') +
'</div>';
const select = document.createElement('select');
select.innerHTML =
'<option value=\"neu\">Neu</option><option value=\"in_bearbeitung\">In Bearbeitung</option><option value=\"warten_auf_user\">Warten auf User</option><option value=\"erledigt\">Erledigt</option>';
select.value = ['neu', 'in_bearbeitung', 'warten_auf_user', 'erledigt'].includes(t.status) ? t.status : 'neu';
select.addEventListener('change', async (e) => {
await fetch('/api/tickets/' + t.id + '/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: e.target.value })
});
await loadPipeline();
await loadTickets();
});
select.style.marginTop = '6px';
card.appendChild(select);
s.el.appendChild(card);
});
});
}
async function loadSla() {
if (!currentGuild) return;
const days = document.getElementById('slaRange')?.value || '30';
const res = await fetch('/api/tickets/sla?guildId=' + encodeURIComponent(currentGuild) + '&days=' + days);
if (!res.ok) return;
const data = await res.json();
slaSupporters = data.supporters || [];
slaDays = data.days || [];
renderSla();
}
function renderSla() {
const supBody = document.getElementById('slaSupporterBody');
const dayBody = document.getElementById('slaDaysBody');
if (supBody) {
supBody.innerHTML = '';
if (!slaSupporters.length) supBody.innerHTML = '<tr><td colspan=\"4\" class=\"muted\">Keine Daten</td></tr>';
slaSupporters.forEach((s) => {
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' +
(s.supporterId || '-') +
'</td><td style=\"text-align:center;\">' +
(s.tickets ?? '-') +
'</td><td style=\"text-align:center;\">' +
formatDuration(s.avgTTC) +
'</td><td style=\"text-align:center;\">' +
formatDuration(s.avgTTFR) +
'</td>';
supBody.appendChild(tr);
});
}
if (dayBody) {
dayBody.innerHTML = '';
if (!slaDays.length) dayBody.innerHTML = '<tr><td colspan=\"4\" class=\"muted\">Keine Daten</td></tr>';
slaDays.forEach((d) => {
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' +
(d.date || '-') +
'</td><td style=\"text-align:center;\">' +
(d.tickets ?? '-') +
'</td><td style=\"text-align:center;\">' +
formatDuration(d.avgTTC) +
'</td><td style=\"text-align:center;\">' +
formatDuration(d.avgTTFR) +
'</td>';
dayBody.appendChild(tr);
});
}
}
async function loadAutomations() {
if (!currentGuild) return;
const res = await fetch('/api/automations?guildId=' + encodeURIComponent(currentGuild));
if (!res.ok) return;
const data = await res.json();
automationCache = data.rules || [];
renderAutomations();
}
function renderAutomations() {
const list = document.getElementById('automationList');
if (!list) return;
list.innerHTML = '';
if (!automationCache.length) {
list.innerHTML = '<div class=\"ticket-empty\">Keine Regeln.</div>';
return;
}
automationCache.forEach((r) => {
const row = document.createElement('div');
row.className = 'ticket-list-item';
row.innerHTML =
'<div class=\"ticket-item-top\"><div class=\"ticket-title\">' +
(r.name || 'Regel') +
'</div><div class=\"ticket-status-badge\">' +
(r.active ? 'aktiv' : 'inaktiv') +
'</div></div>' +
'<div class=\"ticket-meta\">Condition: ' +
JSON.stringify(r.condition || {}) +
'</div>' +
'<div class=\"ticket-meta\">Action: ' +
JSON.stringify(r.action || {}) +
'</div>';
const actions = document.createElement('div');
actions.className = 'row';
const edit = document.createElement('button');
edit.className = 'secondary-btn';
edit.textContent = 'Bearbeiten';
edit.addEventListener('click', () => fillAutomationForm(r));
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'Löschen';
del.addEventListener('click', async () => {
const res = await fetch('/api/automations/' + r.id, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild })
});
showToast(res.ok ? 'Regel gelöscht' : 'Löschen fehlgeschlagen', !res.ok);
if (res.ok) loadAutomations();
});
actions.appendChild(edit);
actions.appendChild(del);
row.appendChild(actions);
list.appendChild(row);
});
}
function fillAutomationForm(rule) {
document.getElementById('automationId').value = rule.id || '';
document.getElementById('automationName').value = rule.name || '';
document.getElementById('automationConditionType').value =
rule.condition?.category ? 'category' : rule.condition?.status ? 'status' : rule.condition?.minHours ? 'age' : 'category';
document.getElementById('automationConditionValue').value =
rule.condition?.category || rule.condition?.status || rule.condition?.minHours || '';
document.getElementById('automationActionType').value =
rule.action?.type === 'reminder' ? 'reminder' : rule.action?.type === 'flag' ? 'flag' : 'pingRole';
document.getElementById('automationActionValue').value =
rule.action?.roleId || rule.action?.message || rule.action?.status || '';
setSwitch(document.getElementById('automationActive'), rule.active !== false);
}
async function loadKb() {
if (!currentGuild) return;
const res = await fetch('/api/kb?guildId=' + encodeURIComponent(currentGuild));
if (!res.ok) return;
const data = await res.json();
kbCache = data.articles || [];
renderKb();
}
function renderKb() {
const list = document.getElementById('kbList');
if (!list) return;
list.innerHTML = '';
if (!kbCache.length) {
list.innerHTML = '<div class=\"ticket-empty\">Keine Artikel.</div>';
return;
}
kbCache.forEach((a) => {
const row = document.createElement('div');
row.className = 'ticket-list-item';
row.innerHTML =
'<div class=\"ticket-item-top\"><div class=\"ticket-title\">' +
(a.title || 'Artikel') +
'</div></div><div class=\"ticket-meta\">Keywords: ' +
(Array.isArray(a.keywords) ? a.keywords.join(', ') : '') +
'</div><div class=\"ticket-meta\">' +
(a.content || '') +
'</div>';
const actions = document.createElement('div');
actions.className = 'row';
const edit = document.createElement('button');
edit.className = 'secondary-btn';
edit.textContent = 'Bearbeiten';
edit.addEventListener('click', () => fillKbForm(a));
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'Löschen';
del.addEventListener('click', async () => {
const res = await fetch('/api/kb/' + a.id, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild })
});
showToast(res.ok ? 'Artikel gelöscht' : 'Löschen fehlgeschlagen', !res.ok);
if (res.ok) loadKb();
});
actions.appendChild(edit);
actions.appendChild(del);
row.appendChild(actions);
list.appendChild(row);
});
}
function fillKbForm(a) {
document.getElementById('kbId').value = a.id || '';
document.getElementById('kbTitle').value = a.title || '';
document.getElementById('kbKeywords').value = Array.isArray(a.keywords) ? a.keywords.join(', ') : '';
document.getElementById('kbContent').value = a.content || '';
}
function parseCategories(raw) { function parseCategories(raw) {
const lines = (raw || '').split('\\n').map((l) => l.trim()).filter(Boolean); const lines = (raw || '').split('\\n').map((l) => l.trim()).filter(Boolean);
return lines.slice(0, 5).map((line) => { return lines.slice(0, 5).map((line) => {
@@ -1726,7 +2116,7 @@ router.get('/', (req, res) => {
(s.userId || '-') + (s.userId || '-') +
'</strong></div><div class="muted">Ende: ' + '</strong></div><div class="muted">Ende: ' +
formatDate(s.endedAt || Date.now()) + formatDate(s.endedAt || Date.now()) +
' · Dauer: ' + ' · Dauer: ' +
dur + dur +
'</div>'; '</div>';
supportRecentList.appendChild(div); supportRecentList.appendChild(div);
@@ -1784,9 +2174,9 @@ router.get('/', (req, res) => {
(ev.repeatType || 'none') + (ev.repeatType || 'none') +
'</span></div><div class="ticket-meta">Start: ' + '</span></div><div class="ticket-meta">Start: ' +
formatDate(ev.startTime) + formatDate(ev.startTime) +
' · Channel: ' + ' · Channel: ' +
(ev.channelId || '-') + (ev.channelId || '-') +
' · Anmeldungen: ' + ' · Anmeldungen: ' +
(ev._count?.signups ?? 0) + (ev._count?.signups ?? 0) +
'</div>'; '</div>';
const actions = document.createElement('div'); const actions = document.createElement('div');
@@ -1999,7 +2389,7 @@ router.get('/', (req, res) => {
meta.className = 'module-meta'; meta.className = 'module-meta';
const descParts = ['Channel: ' + (set.channelId || '-')]; const descParts = ['Channel: ' + (set.channelId || '-')];
if (set.messageId) descParts.push('Message: ' + set.messageId); if (set.messageId) descParts.push('Message: ' + set.messageId);
meta.innerHTML = '<div class="module-title">' + (set.title || 'Reaction Role') + '</div><div class="module-desc">' + descParts.join(' ') + '</div>'; meta.innerHTML = '<div class="module-title">' + (set.title || 'Reaction Role') + '</div><div class="module-desc">' + descParts.join(' • ') + '</div>';
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'row'; actions.className = 'row';
const editBtn = document.createElement('button'); const editBtn = document.createElement('button');
@@ -2200,6 +2590,10 @@ router.get('/', (req, res) => {
await loadSettings(currentGuild); await loadSettings(currentGuild);
await loadOverview(); await loadOverview();
await loadTickets(); await loadTickets();
await loadPipeline();
await loadSla();
await loadAutomations();
await loadKb();
await loadModules(); await loadModules();
await loadStatuspage(); await loadStatuspage();
}); });
@@ -2214,6 +2608,102 @@ router.get('/', (req, res) => {
}); });
}); });
document.querySelectorAll('.ticket-tab-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
document.querySelectorAll('.ticket-tab-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.tab;
document.querySelectorAll('.ticket-tab').forEach((t) => t.classList.remove('active'));
const target = document.getElementById('ticketTab' + (tab ? tab.charAt(0).toUpperCase() + tab.slice(1) : ''));
if (target) target.classList.add('active');
if (tab === 'pipeline') await loadPipeline();
if (tab === 'sla') await loadSla();
if (tab === 'automations') await loadAutomations();
if (tab === 'kb') await loadKb();
});
});
const slaRange = document.getElementById('slaRange');
if (slaRange) slaRange.addEventListener('change', loadSla);
const automationForm = document.getElementById('automationForm');
if (automationForm) {
automationForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentGuild) return;
const id = document.getElementById('automationId')?.value || '';
const name = document.getElementById('automationName')?.value || '';
const condType = document.getElementById('automationConditionType')?.value;
const condVal = document.getElementById('automationConditionValue')?.value;
const actionType = document.getElementById('automationActionType')?.value;
const actionVal = document.getElementById('automationActionValue')?.value;
const active = getSwitch(document.getElementById('automationActive'));
const condition: any = {};
if (condType === 'category') condition.category = condVal;
if (condType === 'status') condition.status = condVal;
if (condType === 'age') condition.minHours = Number(condVal || 0);
const action: any = { type: actionType };
if (actionType === 'pingRole') action.roleId = actionVal;
if (actionType === 'reminder') action.message = actionVal;
if (actionType === 'flag') action.status = actionVal;
const payload = { guildId: currentGuild, name, condition, action, active };
const url = id ? '/api/automations/' + id : '/api/automations';
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
showToast(res.ok ? 'Regel gespeichert' : 'Fehler', !res.ok);
if (res.ok) {
document.getElementById('automationId').value = '';
automationForm.reset();
setSwitch(document.getElementById('automationActive'), true);
await loadAutomations();
}
});
}
const automationReset = document.getElementById('automationReset');
if (automationReset) automationReset.addEventListener('click', () => {
document.getElementById('automationId').value = '';
if (automationForm) automationForm.reset();
setSwitch(document.getElementById('automationActive'), true);
});
const addAutomation = document.getElementById('addAutomation');
if (addAutomation) addAutomation.addEventListener('click', () => {
document.getElementById('automationId').value = '';
if (automationForm) automationForm.reset();
setSwitch(document.getElementById('automationActive'), true);
});
const kbForm = document.getElementById('kbForm');
if (kbForm) {
kbForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentGuild) return;
const id = document.getElementById('kbId')?.value || '';
const title = document.getElementById('kbTitle')?.value || '';
const keywords = document.getElementById('kbKeywords')?.value || '';
const content = document.getElementById('kbContent')?.value || '';
const payload = { guildId: currentGuild, title, keywords, content };
const url = id ? '/api/kb/' + id : '/api/kb';
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
showToast(res.ok ? 'Artikel gespeichert' : 'Fehler', !res.ok);
if (res.ok) {
document.getElementById('kbId').value = '';
kbForm.reset();
await loadKb();
}
});
}
const kbReset = document.getElementById('kbReset');
if (kbReset) kbReset.addEventListener('click', () => {
document.getElementById('kbId').value = '';
if (kbForm) kbForm.reset();
});
const addKb = document.getElementById('addKb');
if (addKb) addKb.addEventListener('click', () => {
document.getElementById('kbId').value = '';
if (kbForm) kbForm.reset();
});
document.getElementById('logoutBtn').addEventListener('click', () => window.location.href = BASE_AUTH + '/logout'); document.getElementById('logoutBtn').addEventListener('click', () => window.location.href = BASE_AUTH + '/logout');
[automodToggle, badWordToggle, linkFilterToggle, spamFilterToggle, capsFilterToggle, logJoinLeave, logMsgEdit, logMsgDelete, logAutomod, logTickets, logMusic, dynamicVoiceToggle, supportLoginAuto].forEach((el) => { [automodToggle, badWordToggle, linkFilterToggle, spamFilterToggle, capsFilterToggle, logJoinLeave, logMsgEdit, logMsgDelete, logAutomod, logTickets, logMusic, dynamicVoiceToggle, supportLoginAuto].forEach((el) => {
@@ -2392,3 +2882,4 @@ router.get('/settings', (_req, res) => {
export default router; export default router;