From bb2aee78188030c335462337f0862587653902ed Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Sat, 1 Aug 2026 09:14:49 +0200 Subject: [PATCH] Tickets: Anliegen zur Auswahl, und das Team kann uebernehmen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher sah jedes Ticket gleich aus: ein Knopf, ein Thread, und im Thread stand als Erstes die Frage, worum es ueberhaupt geht. Und wer sich darum kuemmert, stand nirgends — man sah hinterher nur, wer zufaellig auf "Schliessen" geklickt hat. Anliegen sind Kategorien, die man in der Config anlegt: Name, Emoji, eine Zeile Beschreibung, ein Einstiegstext und optional eine Rolle, die beim Oeffnen erfaehrt, dass etwas anliegt. Der Ticket-Post wird dadurch vom Knopf zum Auswahlmenue; das gewaehlte Anliegen steht im Thread-Namen und der Einstiegstext gleich im ersten Beitrag — gute Stelle fuer "was wir wissen muessen". Wer keine anlegt, merkt nichts: ohne Eintrag bleibt es beim einen Knopf. Wer nur eine Sorte Tickets hat, soll nichts einrichten muessen. Uebernehmen ist ein Knopf im Thread, sichtbar fuers Team (wer aufraeumen darf, darf auch uebernehmen). Danach steht im Thread, wer sich kuemmert, und der Knopf wird zu "Freigeben". Die Bedingung "nur wenn noch niemand dran ist" steckt im UPDATE und nicht davor — zwei gleichzeitige Klicks wuerden sonst beide gewinnen. Beim Schliessen landen Anliegen und Bearbeiter im Transcript und im Mod-Log-Eintrag. Die Erstellung ist dabei aus client.js nach tickets.js gewandert. Knopf und Menue gehen jetzt denselben Weg, und in client.js sind drei Importzeilen weggefallen, die nur noch dort standen. Geprueft: alsEmoji gegen 20 Eingaben (Unicode, Hautfarbe, ZWJ-Ketten, Server-Emoji, Murks) und das fertige Panel gegen discord.js selbst — ein ungueltiges Emoji oder ein zu langes Label laesst Discord sonst die ganze Nachricht fallen, und das faellt erst beim Senden auf. Im Browser: Liste, Bearbeiten-Formular und der leere Zustand. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- frontend/src/pages/Settings.jsx | 121 +++++++++++++++++ frontend/src/style.css | 4 + src/bot/client.js | 82 ++++------- src/bot/commands/ticket-setup.js | 26 ++-- src/bot/tickets.js | 225 ++++++++++++++++++++++++++++++- src/db.js | 58 +++++++- src/web/api.js | 55 ++++++++ 8 files changed, 496 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 98fe7d9..99c72e2 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen. | 🚨 **Raid-Schutz** | Erkennt Beitritts-Wellen und sperrt den Server vorübergehend (Prüfstufe hoch, Einladungen aus). `/raid an\|aus\|status` auch von Hand | | 📋 **Protokoll** | Gelöschte und bearbeitete Nachrichten, Joins/Leaves, Namens- und Rollen-Änderungen in einem privaten Kanal | | 📬 **Modmail** | DM an den Bot → Thread im privaten Staff-Kanal; Antworten im Thread gehen als DM zurück | -| 🎫 **Tickets** | Button öffnet einen privaten Thread (ein offenes Ticket pro User). Schließen = **Transcript per DM** an den Ersteller + Kopie ins Mod-Log | +| 🎫 **Tickets** | Button öffnet einen privaten Thread (ein offenes Ticket pro User). **Anliegen** als Auswahlmenü — je eigener Name, Einstiegstext und Rolle, die benachrichtigt wird. Das Team kann ein Ticket **übernehmen**, damit klar ist, wer sich kümmert. Schließen = **Transcript per DM** an den Ersteller + Kopie ins Mod-Log, inklusive Anliegen und Bearbeiter | | 👋 **Willkommens-Karten** | Begrüßung als gerendertes Bild (Avatar mit Neon-Ring, Member-Nummer, Brand-Look), konfigurierbar; Fallback aufs Text-Embed | | 🪪 **Auto- & Sticky-Roles** | Start-Rolle für neue Member; Rollen werden bei Leave gesichert und bei Rejoin wiederhergestellt | | 🐛 **`/bug`** | Eingabefenster (was ist passiert, was war erwartet, wie nachstellen) → Gitea-Issue inkl. Screenshot-Upload; Issue geschlossen → DM an den Reporter | diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 992660a..55a5726 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -344,6 +344,8 @@ export default function Settings({ me }) { const [serverStats, setServerStats] = useState(null); const [watchdog, setWatchdog] = useState(null); const [automod, setAutomod] = useState(null); + const [anliegen, setAnliegen] = useState([]); + const [anliegenDraft, setAnliegenDraft] = useState({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' }); const [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false }); const [owner, setOwner] = useState(null); const [auditAlle, setAuditAlle] = useState(false); @@ -473,6 +475,7 @@ export default function Settings({ me }) { apiGet('/api/serverstats').then(setServerStats).catch(() => {}); apiGet('/api/watchdog').then(setWatchdog).catch(() => {}); apiGet('/api/automod').then(setAutomod).catch(() => {}); + apiGet('/api/ticket-categories').then((d) => setAnliegen(d.kategorien ?? [])).catch(() => {}); apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {}); apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {}); apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {}); @@ -1835,6 +1838,33 @@ export default function Settings({ me }) { ); + async function anliegenSpeichern() { + const body = { ...anliegenDraft }; + try { + const res = body.id + ? await apiPut(`/api/ticket-categories/${body.id}`, body) + : await apiPost('/api/ticket-categories', body); + setAnliegen(res.kategorien ?? []); + setAnliegenDraft({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' }); + setFeedback(body.id ? 'Anliegen gespeichert.' : 'Anliegen angelegt.'); + } catch (e) { + setFeedback(e.body?.error ?? 'Speichern fehlgeschlagen.'); + } + setTimeout(() => setFeedback(null), 3000); + } + + async function anliegenLoeschen(id, name) { + if (!window.confirm(`„${name}" wirklich entfernen? Offene Tickets bleiben bestehen.`)) return; + try { + const res = await apiDelete(`/api/ticket-categories/${id}`); + setAnliegen(res.kategorien ?? []); + if (anliegenDraft.id === id) setAnliegenDraft({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' }); + } catch { + setFeedback('Entfernen fehlgeschlagen.'); + setTimeout(() => setFeedback(null), 3000); + } + } + /** AutoMod-Regel an- oder abschalten */ async function automodSchalten(id, aktiv) { try { @@ -1849,6 +1879,97 @@ export default function Settings({ me }) { const tabSupport = ( <> +
+

// Ticket-Anliegen

+

+ Ohne Anliegen hat der Ticket-Post einen Knopf und jedes Ticket sieht gleich aus. + Mit Anliegen wird daraus ein Auswahlmenü: der Titel steht im Thread-Namen, + der Einstiegstext gleich im ersten Beitrag, und die hinterlegte Rolle erfährt + sofort, dass etwas anliegt. Nach dem Ändern /ticket-setup neu + ausführen — der alte Post behält sonst sein altes Menü. +

+ + {anliegen.length === 0 && ( +

Noch keine Anliegen — es bleibt beim einfachen Knopf.

+ )} + + {anliegen.map((k) => ( +
+
+ {k.emoji} + {k.name} + {k.ping_role_id && ( + + pingt {roleItems.find((r) => r.id === k.ping_role_id)?.label ?? 'Rolle'} + + )} + + +
+ {(k.beschreibung || k.intro) && ( +
+ {k.beschreibung && {k.beschreibung}} + {k.intro && Einstiegstext hinterlegt} +
+ )} +
+ ))} + +

+ {anliegenDraft.id ? 'Anliegen bearbeiten' : 'Anliegen hinzufügen'} +

+
+ + setAnliegenDraft({ ...anliegenDraft, name: e.target.value })} /> +
+
+ + setAnliegenDraft({ ...anliegenDraft, emoji: e.target.value })} /> +
+
+ + setAnliegenDraft({ ...anliegenDraft, beschreibung: e.target.value })} /> +
+
+ + setAnliegenDraft({ ...anliegenDraft, ping_role_id: v })} + empty="— kein Ping —" + /> +
+
+ +