[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

@@ -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; }
@@ -323,41 +326,167 @@ router.get('/', (req, res) => {
</section>
</div>
</div>
<div class="section" data-section="tickets">
<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>
</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 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="ticket-list-pane" id="ticketListPane"></div>
</section>
<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 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 class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap:12px;">
<div>
<p class="label">Aktiv</p>
<div id="supportActiveList" class="ticket-list-pane" style="min-height:80px;"></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">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>
<p class="label">Letzte Sessions</p>
<div id="supportRecentList" class="ticket-list-pane" style="min-height:80px;"></div>
<div class="ticket-list-pane" id="ticketListPane"></div>
</section>
<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>
</section>
</div>
<div class="section" data-section="automod">
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap:12px;">
<div>
<p class="label">Aktiv</p>
<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">
<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;