[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();
@@ -43,18 +43,18 @@ router.get('/', (req, res) => {
<aside class="sidebar">
<div class="brand">Papo Control</div>
<div class="nav">
<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="#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="#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="#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="#settings" data-target="settings"><span class="icon">⚙️</span> Einstellungen</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="#admin" data-target="admin" class="admin-link hidden"><span class="icon">🛡</span> Admin</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="#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="#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="#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="#settings" data-target="settings"><span class="icon">⚙️</span> Einstellungen</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="#admin" data-target="admin" class="admin-link hidden"><span class="icon">🛡</span> Admin</a>
</div>
<div class="muted">Angemeldet als <span id="userInfo"></span></div>
<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-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-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-field { display:flex; flex-direction:column; gap:6px; }
.form-label { font-size:13px; color:var(--muted); letter-spacing:0.2px; font-weight:600; }
@@ -324,11 +327,26 @@ router.get('/', (req, res) => {
</div>
</div>
<div class="section" data-section="tickets">
<div class="card" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:space-between;">
<div>
<p class="section-title">Tickets</p>
<p class="section-sub">Übersicht, Pipeline, SLA, Automationen, Knowledge-Base.</p>
</div>
<div class="row" style="gap:8px; flex-wrap:wrap;">
<button class="secondary-btn ticket-tab-btn active" data-tab="overview">Übersicht</button>
<button class="secondary-btn ticket-tab-btn" data-tab="pipeline">Pipeline</button>
<button class="secondary-btn ticket-tab-btn" data-tab="sla">SLA</button>
<button class="secondary-btn ticket-tab-btn" data-tab="automations">Automationen</button>
<button class="secondary-btn ticket-tab-btn" data-tab="kb">Knowledge-Base</button>
</div>
</div>
<div id="ticketTabOverview" class="ticket-tab active">
<section class="card">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div>
<p class="section-title">Tickets</p>
<p class="section-sub">Links auswaehlen, Details im Modal. Plus oeffnet Panel-Erstellung.</p>
<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>
@@ -343,7 +361,7 @@ router.get('/', (req, res) => {
<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>
<button class="icon-button" id="refreshSupportStatus"></button>
</div>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap:12px;">
<div>
@@ -357,7 +375,118 @@ router.get('/', (req, res) => {
</div>
</section>
</div>
<div class="section" data-section="automod">
<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">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<div>
@@ -457,7 +586,7 @@ router.get('/', (req, res) => {
</div>
<div class="form-field">
<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 class="form-field">
@@ -480,7 +609,7 @@ router.get('/', (req, res) => {
<div class="embed-color" id="welcomePreviewColor"></div>
<div class="embed-body">
<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>
<img id="welcomePreviewImage" class="embed-image" style="display:none;" />
</div>
@@ -607,7 +736,7 @@ router.get('/', (req, res) => {
</div>
<div class="form-field">
<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>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px;">
@@ -681,7 +810,7 @@ router.get('/', (req, res) => {
<section class="card">
<div class="row" style="justify-content:space-between;">
<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>
</div>
</div>
@@ -691,7 +820,7 @@ router.get('/', (req, res) => {
<div class="row" style="justify-content:space-between;">
<div>
<p class="section-title">Logs</p>
<p class="section-sub">Neueste Einträge</p>
<p class="section-sub">Neueste Einträge</p>
</div>
</div>
<ul class="log-list" id="adminLogs"></ul>
@@ -718,13 +847,13 @@ router.get('/', (req, res) => {
</div>
</div>
<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>✏️ 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>🛡️ 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>🎵 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>👋 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 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>🎫 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>⚙️ System / Channels</span><div id="logSystem" class="switch on"></div></div>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px; margin-top:12px;">
<button id="loggingSave" type="button">Logging speichern</button>
@@ -1065,6 +1194,11 @@ router.get('/', (req, res) => {
};
let currentGuild = '${guildId}';
let ticketCache = [];
let pipelineCache = { neu: [], in_bearbeitung: [], warten_auf_user: [], erledigt: [] };
let slaSupporters = [];
let slaDays = [];
let automationCache = [];
let kbCache = [];
let selectedTicket = null;
let activeModal = null;
let automodConfigCache = {};
@@ -1092,6 +1226,15 @@ router.get('/', (req, res) => {
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() {
const autoEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'automodEnabled') ? modulesCache['automodEnabled'] : true;
const welcomeEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'welcomeEnabled') ? modulesCache['welcomeEnabled'] : true;
@@ -1276,7 +1419,7 @@ router.get('/', (req, res) => {
actions.className = 'row';
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'Löschen';
del.textContent = 'Löschen';
del.addEventListener('click', async () => {
await deleteService(svc.id);
});
@@ -1334,7 +1477,7 @@ router.get('/', (req, res) => {
if (res.ok) {
await loadStatuspage();
} else {
showToast('Service löschen fehlgeschlagen', true);
showToast('Service löschen fehlgeschlagen', true);
}
}
@@ -1540,6 +1683,10 @@ router.get('/', (req, res) => {
await loadModules();
await loadOverview();
await loadTickets();
await loadPipeline();
await loadSla();
await loadAutomations();
await loadKb();
await loadAutomodSettings(currentGuild);
await loadLoggingSettings(currentGuild);
await loadWelcomeSettings(currentGuild);
@@ -1570,6 +1717,249 @@ router.get('/', (req, res) => {
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) {
const lines = (raw || '').split('\\n').map((l) => l.trim()).filter(Boolean);
return lines.slice(0, 5).map((line) => {
@@ -1726,7 +2116,7 @@ router.get('/', (req, res) => {
(s.userId || '-') +
'</strong></div><div class="muted">Ende: ' +
formatDate(s.endedAt || Date.now()) +
' · Dauer: ' +
' · Dauer: ' +
dur +
'</div>';
supportRecentList.appendChild(div);
@@ -1784,9 +2174,9 @@ router.get('/', (req, res) => {
(ev.repeatType || 'none') +
'</span></div><div class="ticket-meta">Start: ' +
formatDate(ev.startTime) +
' · Channel: ' +
' · Channel: ' +
(ev.channelId || '-') +
' · Anmeldungen: ' +
' · Anmeldungen: ' +
(ev._count?.signups ?? 0) +
'</div>';
const actions = document.createElement('div');
@@ -1999,7 +2389,7 @@ router.get('/', (req, res) => {
meta.className = 'module-meta';
const descParts = ['Channel: ' + (set.channelId || '-')];
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');
actions.className = 'row';
const editBtn = document.createElement('button');
@@ -2200,6 +2590,10 @@ router.get('/', (req, res) => {
await loadSettings(currentGuild);
await loadOverview();
await loadTickets();
await loadPipeline();
await loadSla();
await loadAutomations();
await loadKb();
await loadModules();
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');
[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;