Kategorie-Kanaele und angeheftete Ankuendigungen (Kapitel 7.2)

Die vier Saetze aus dem Anforderungsdokument, der Reihe nach: Team-
Gruppenchat, Kategorie-Kanaele mit Zugriff fuer Owner und rechte Hand,
private 1:1-Chats OHNE diesen Zugriff, und Pin-Nachrichten an alle.

EIN KANAL IST KEINE VIERTE TABELLE, sondern eine dritte Art Raum
(`art = 'kanal'` neben 'direkt' und 'gruppe'). Damit gilt fuer ihn ohne
eine einzige neue Zeile alles, was schon da ist: Verlauf, Anhaenge,
Suche, Ungelesen-Zaehler, Live-Strom, Wegraeumen. Eine eigene Tabelle
haette all das ein zweites Mal gebraucht -- und die zweite Fassung waere
die gewesen, in der die Zugriffsregel fehlt.

Welche Zustaendigkeit, kommt aus MODI_KATEGORIEN -- derselben Liste, aus
der auch die Aufgaben ihre Kategorie nehmen. Der NAME kommt aus der
Kategorie und ist kein freies Feld: "Clipping" neben "Clipping-Team"
waeren zwei halbe Verlaeufe, und man merkt es erst, wenn jemand die
Antwort im falschen sucht. Ein eindeutiger Teilindex haelt das auch
dann fest, wenn zwei Anfragen im selben Augenblick ankommen.

DIE ZUGRIFFSREGEL STEHT IN istDrin() -- der Funktion, durch die alle
sieben lesenden und schreibenden Wege gehen. In den Routen stuende sie
in sechs davon und in der siebten nicht. Sie gilt AUSDRUECKLICH nur
fuer 'kanal': Zweier-Gespraeche bleiben zu, auch fuer DogFather (so
steht es im Dokument), und Gruppen ebenfalls -- wer eine Gruppe
aufmacht, hat sich fuer einen geschlossenen Kreis entschieden, und den
nachtraeglich still zu oeffnen waere das Gegenteil dessen, was er getan
hat. Wenn Filipe das anders will, ist es eine Zeile -- aber es waere
seine Entscheidung und muesste fuer die Beteiligten SICHTBAR sein.

istDrin() nimmt dafuer die PERSON statt ihrer Nummer und wirft bei
einer Nummer einen Fehler, statt stillschweigend "nein" zu antworten.

HOECHSTENS DREI ANKUENDIGUNGEN je Raum. Nicht eine (Regeln, Live-Plan
und Frist muessen gleichzeitig oben stehen koennen) und nicht beliebig
viele -- eine Pinnwand, die scrollt, ist ein zweiter Verlauf. Der
Aushang hat eine EIGENE Abfrage, weil der Verlauf nur 200 Zeilen
liefert: Eine Ansage von vorletzter Woche waere sonst genau dann weg,
wenn sie am laengsten oben stehen sollte. Wird die Nachricht
zurueckgenommen, faellt sie ab UND gibt den Platz frei.

DREI DINGE, DIE ERST DAS HINSEHEN GEZEIGT HAT:

  Der frisch angelegte Kanal hatte zwei Leute statt vier. Die
  Personenauswahl zeichnete nach der Rollenfolge aus bereiche.js -- wer
  dort nicht steht, wurde NICHT GEZEICHNET. Team Dogi steht dort nicht
  und darf es auch nicht (der Rollenname gehoert in keine Datei, die
  jeder herunterlaedt). Folge: DogFather konnte ueber die Auswahl
  niemandem aus seinem Team schreiben. Der Server schickt die
  Ueberschrift jetzt mit; der Browser braucht dafuer keinen
  Rollennamen. Der Kommentar, der genau davor warnte, stand die ganze
  Zeit darueber.

  Dieser Fehler war nebenbei ein Netz: Was nicht gezeichnet wird, kann
  auch nicht falsch gezeichnet werden. Deshalb ist jetzt gemessen, dass
  ein Manager und Spicy Media Team Dogi gar nicht erst geschickt
  bekommen -- und dabei fiel auf, dass Spicy Media in der EIGENEN
  Auswahl stand: Sobald es jemanden zu verbergen gibt, schreibt
  ohneVerborgene() aus "sieht alles" eine echte Liste, und darin steckt
  man selbst.

  Am Fuss jeder Nachricht stehen jetzt vier Handgriffe statt drei. Bei
  390 px -- der haeufigsten Handybreite -- stand "kopieren" 18 px ueber
  der Blase, bei 320 px 75. Behoben mit `flex-wrap: wrap` und nicht mit
  einer Schwelle: Eine Regel, die misst, bleibt beim fuenften Handgriff
  richtig; eine Zahl nicht. pruef-chat-optik misst es ab jetzt.

NACHGEZOGEN, was pruef-css-klassen an MEINEM letzten Commit fand:
teamlage.html lud kopf.js ohne wahl.js (der Sicht-Umschalter sah aus
wie aus einem anderen Programm -- kaputt war nichts, und genau deshalb
faellt es niemandem auf), und .tampel__tag stand auf 11,2 px. Beides
war schon gepusht, weil ich die Pruefung nicht laufen liess.

pruef-chat-kanaele 73 (neu) · pruef-chat 48 · pruef-chat-ausbau 64 ·
pruef-chat-anhaenge 60 · pruef-chat-optik 24 -> 26 · pruef-css-klassen
gruen · pruef-rollen 274 · pruef-modi-verborgen 78 · pruef-team-ampel 28
· pruef-start-ansicht gruen · pruef-modi-wortleck 5 ·
pruef-zwischenspeicher 21.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-10 20:18:04 +02:00
co-authored by Claude Opus 5
parent 6b2f34d105
commit d92ba76e33
32 changed files with 2001 additions and 306 deletions
+288 -16
View File
@@ -48,6 +48,19 @@
let strom = null; // die EventSource
let gewaehlt = new Set(); // im Neu-Dialog
/* KANAELE UND ANGEHEFTETES (10.09.2026, Kapitel 7.2).
`darfKanal` und `darfAnheftenHier` kommen vom SERVER, nicht aus
einem Rollenvergleich hier. Diese Datei bekommt jeder, der die
Seite oeffnet -- ein `rolle === 'admin'` darin waere eine Auskunft
ueber den Aufbau des Hauses an alle. Ein Ja/Nein ist keine. */
let angeheftet = [];
let darfAnheftenHier = false;
let darfKanal = false;
let kanalFrei = [];
let neuArt = 'gespraech'; // 'gespraech' | 'kanal'
let kanalUmbau = null; // Raumnummer, wenn die Besetzung geaendert wird
/* ---------- Kleinkram ---------------------------------------------------- */
async function hole(weg, einst) {
@@ -104,7 +117,15 @@
try {
const a = await hole('/workspace/api/chat/raeume');
if (!a.ok) return;
raeume = (await a.json()).raeume || [];
const daten = await a.json();
raeume = daten.raeume || [];
darfKanal = !!daten.darf_kanal;
kanalFrei = daten.kanal_frei || [];
/* Der Knopf erscheint nur, wenn der Server etwas zu bieten hat.
Ein Knopf, der beim Druecken "darfst du nicht" sagt, ist eine
Enttaeuschung mit Ansage. */
$('neu-art').hidden = !darfKanal;
if (!darfKanal) neuArt = 'gespraech';
} catch { return; }
raeumeZeichnen();
ungelesenMelden();
@@ -141,7 +162,12 @@
const zeichen = el('span', 'chat-raum__zeichen');
zeichen.setAttribute('aria-hidden', 'true');
if (r.art === 'gruppe') {
if (r.art === 'kanal') {
/* Das Rautenzeichen ist die Schreibweise, die jeder aus
anderen Messengern kennt -- es muss nicht erklaert werden. */
zeichen.dataset.kanal = 'ja';
zeichen.textContent = '#';
} else if (r.art === 'gruppe') {
zeichen.dataset.gruppe = 'ja';
zeichen.textContent = String(r.teilnehmer.length);
} else {
@@ -167,7 +193,12 @@
const vorschau = el('span', 'chat-raum__vorschau');
if (r.letzte) {
const wer = r.letzte.von === ich.name ? 'Du: ' : (r.art === 'gruppe' ? `${r.letzte.von}: ` : '');
/* Wer geschrieben hat, gehoert ueberall dorthin, wo mehr als
zwei Leute schreiben koennen -- in einer Gruppe wie in einem
Kanal. Hier stand `=== 'gruppe'`, und ein Kanal waere damit
die einzige Stelle ohne Absender in der Vorschau gewesen. */
const wer = r.letzte.von === ich.name ? 'Du: '
: (r.art !== 'direkt' ? `${r.letzte.von}: ` : '');
vorschau.textContent = r.letzte.zurueckgenommen
? `${wer}Nachricht zurückgenommen`
: wer + r.letzte.text;
@@ -271,6 +302,8 @@
const daten = await a.json();
offen = daten.raum;
nachrichten = daten.nachrichten;
angeheftet = daten.angeheftet || [];
darfAnheftenHier = !!daten.darf_anheften;
/* DER STAND VON VORHIN, nicht der von jetzt. `gelesenMelden()`
weiter unten setzt ihn gleich auf die letzte Nachricht -- die
Linie muss also aus dem Wert gezogen werden, der beim OEFFNEN
@@ -280,9 +313,15 @@
} catch { return; }
$('verlauf-titel').textContent = offen.name;
$('verlauf-unter').textContent = offen.art === 'gruppe'
/* Bei allem, was mehr als zwei Leute hat, stehen die Namen darunter
-- bei einem Zweier-Gespraech die Rolle des Gegenuebers. */
$('verlauf-unter').textContent = offen.art !== 'direkt'
? offen.teilnehmer.map((t) => t.name).join(' · ')
: (ROLLENKURZ[offen.teilnehmer.find((t) => t.id !== ich.id)?.rolle] || '');
/* Die Besetzung aendern kann nur, wer Kanaele fuehrt -- und nur in
einem Kanal. In einer Gruppe waere es ein Eingriff in fremde
Runden; siehe die Route dazu im Server. */
$('kanal-leute').hidden = !(darfKanal && offen.art === 'kanal');
$('schreiben').hidden = false;
$('verlauf-leer').hidden = true;
$('chat').dataset.ansicht = 'verlauf';
@@ -292,6 +331,7 @@
/* Ein frisch geoeffnetes Gespraech steht immer am Ende -- man will
das Letzte sehen, nicht das Erste. */
unten = true; neuUnten = 0;
pinsZeichnen();
verlaufZeichnen();
/* Der Entwurf von vorhin, falls einer da ist. */
$('text').value = entwurfHolen(id);
@@ -306,6 +346,79 @@
raeumeZeichnen();
}
/* =======================================================================
DIE ANGEHEFTETEN (10.09.2026, Kapitel 7.2)
Sie stehen UEBER dem Verlauf und rollen nicht mit. Eine Ankuendigung,
die man erst suchen muss, ist keine.
Jeder Aushang fuehrt zu seiner Stelle im Verlauf. Ohne das waere er
ein Bild von einer Nachricht: Man liest den Anfang und kann nicht
nachsehen, was darauf geantwortet wurde.
======================================================================= */
function pinNadel() {
const raum = 'http://www.w3.org/2000/svg';
const s = document.createElementNS(raum, 'svg');
s.setAttribute('viewBox', '0 0 24 24');
s.setAttribute('aria-hidden', 'true');
s.setAttribute('class', 'chat-pin__nadel');
const pfad = document.createElementNS(raum, 'path');
pfad.setAttribute('d', 'M9 3h6l-1 5 3 3v2h-5v6l-1 2-1-2v-6H5v-2l3-3z');
pfad.setAttribute('fill', 'currentColor');
s.append(pfad);
return s;
}
function pinsZeichnen() {
const ziel = $('pins');
ziel.textContent = '';
ziel.hidden = !angeheftet.length;
if (!angeheftet.length) return;
for (const a of angeheftet) {
const kasten = el('div', 'chat-pin');
const auf = el('button', 'chat-pin__auf');
auf.type = 'button';
auf.title = 'Zur Nachricht springen';
const kopf = el('span', 'chat-pin__kopf');
kopf.append(pinNadel());
kopf.append(el('span', 'chat-pin__von', a.von));
if (a.heftete) kopf.append(el('span', 'chat-pin__wer', `angeheftet von ${a.heftete}`));
auf.append(kopf);
auf.append(el('span', 'chat-pin__text', a.text || 'Ohne Text'));
auf.addEventListener('click', () => zurStelle(a.id));
kasten.append(auf);
if (darfAnheftenHier) {
const los = el('button', 'chat-pin__los', 'lösen');
los.type = 'button';
los.setAttribute('aria-label', `„${(a.text || '').slice(0, 40)}“ lösen`);
los.addEventListener('click', () => anheften(a.id, false));
kasten.append(los);
}
ziel.append(kasten);
}
}
async function anheften(id, an) {
try {
const a = await hole(`/workspace/api/chat/nachrichten/${id}/anheften`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ an }),
});
if (!a.ok) {
melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.');
return;
}
/* Neu holen statt hier nachzurechnen: Der Server kennt die Grenze
von drei, das Abfallen zurueckgenommener Nachrichten und die
Reihenfolge. Zwei Rechnungen darueber liefen irgendwann
auseinander. */
if (offen) await raumOeffnen(offen.id, { sanft: true });
} catch { /* umgeleitet */ }
}
function verlaufZeichnen() {
const ziel = $('verlauf');
/* NUR DIE NACHRICHTEN WEG — NICHT DER GANZE KASTEN.
@@ -396,8 +509,10 @@
zeile.append(z);
}
if (n.angeheftet) zeile.dataset.angeheftet = 'ja';
const blase = el('div', 'chat-nachricht__blase');
if (!gleicheFolge && !n.selbst && offen.art === 'gruppe') {
if (!gleicheFolge && !n.selbst && offen.art !== 'direkt') {
blase.append(el('p', 'chat-nachricht__von', n.von));
}
@@ -456,6 +571,18 @@
weg.addEventListener('click', () => zuruecknehmen(n.id));
fuss.append(weg);
}
/* ANHEFTEN steht an derselben Stelle wie "antworten" -- dieselbe
Sorte Handgriff, dieselbe Zeile. Nur fuer die, die es duerfen,
und nie an einer zurueckgenommenen oder noch unterwegs
befindlichen Nachricht (`n.id > 0`): Die haette noch keine
Nummer, unter der man sie anheften koennte. */
if (darfAnheftenHier && !n.zurueckgenommen && n.id > 0) {
const pin = el('button', 'chat-nachricht__pin-knopf',
n.angeheftet ? 'lösen' : 'anheften');
pin.type = 'button';
pin.addEventListener('click', () => anheften(n.id, !n.angeheftet));
fuss.append(pin);
}
/* KOPIEREN. Eine Adresse, ein Code, eine Uhrzeit -- man will sie
weitergeben, nicht abtippen. Der Knopf bestaetigt sich selbst
und faellt danach zurueck; eine Meldung anderswo waere ein
@@ -869,6 +996,15 @@
raeumeLaden();
});
/* ANGEHEFTET ODER GELOEST -- alle im Raum sehen es sofort.
Betroffen ist nur, wer denselben Raum offen hat; sonst genuegt
die Liste. */
strom.addEventListener('pin', (e) => {
let n;
try { n = JSON.parse(e.data); } catch { return; }
if (offen && n.raum_id === offen.id) raumOeffnen(offen.id, { sanft: true });
});
/* Bei einem Abriss verbindet EventSource von selbst neu. Wir holen
danach den Verlauf nach -- sonst fehlte, was während der Pause
geschrieben wurde. */
@@ -880,10 +1016,10 @@
/* ---------- Neues Gespräch ----------------------------------------------- */
async function partnerLaden() {
async function partnerLaden(vorbelegt = []) {
const ziel = $('partner');
ziel.textContent = '';
gewaehlt = new Set();
gewaehlt = new Set(vorbelegt);
$('gruppenname-zeile').hidden = true;
$('neu-los').disabled = true;
$('neu-fehler').textContent = '';
@@ -906,17 +1042,13 @@
fehlte 'spicy' -- und wer nicht in der Schleife vorkommt, taucht
in der Gesprächsliste gar nicht auf. Kein Fehler, nur eine
Person, die es fuer die Anderen nicht gibt. */
for (const rolle of window.Bereiche.ROLLENFOLGE) {
const leute = personen.filter((p) => p.rolle === rolle);
if (!leute.length) continue;
function gruppeBauen(schluessel, ueberschrift, leute) {
const gruppe = el('div', 'chat-wahl__gruppe');
gruppe.dataset.rolle = rolle;
gruppe.dataset.rolle = schluessel;
const titel = el('p', 'chat-wahl__titel');
const punkt = el('span', 'chat-wahl__punkt');
punkt.setAttribute('aria-hidden', 'true');
titel.append(punkt, document.createTextNode(
window.Bereiche.ROLLEN_GRUPPE[rolle]));
titel.append(punkt, document.createTextNode(ueberschrift));
gruppe.append(titel);
const liste = el('div', 'chat-wahl__liste');
@@ -926,7 +1058,8 @@
b.type = 'button';
b.dataset.id = String(p.id);
b.dataset.rolle = p.rolle;
b.setAttribute('aria-pressed', 'false');
if (gewaehlt.has(p.id)) { b.dataset.an = 'ja'; }
b.setAttribute('aria-pressed', gewaehlt.has(p.id) ? 'true' : 'false');
const z = el('span', 'chat-wahl__zeichen', initialen(p.name));
z.setAttribute('aria-hidden', 'true');
@@ -943,10 +1076,89 @@
gruppe.append(liste);
ziel.append(gruppe);
}
const gezeichnet = new Set();
for (const rolle of window.Bereiche.ROLLENFOLGE) {
const leute = personen.filter((p) => p.rolle === rolle);
gezeichnet.add(rolle);
if (!leute.length) continue;
gruppeBauen(rolle, window.Bereiche.ROLLEN_GRUPPE[rolle], leute);
}
/* UND DANN ALLE UEBRIGEN (10.09.2026).
Genau die Lücke, vor der der Kommentar oben warnt -- sie war
offen. `ROLLENFOLGE` hat fünf Einträge; wer eine sechste Rolle
hat, wurde von der Schleife darüber nie erfasst und verschwand
lautlos aus der Auswahl. Der Server schickte ihn mit, der
Browser zeichnete ihn nicht, und niemand konnte sehen, dass
etwas fehlt.
Aufgefallen ist es beim HINSEHEN, nicht beim Lesen: Ein frisch
angelegter Kanal hatte hinterher zwei Leute statt vier.
Die Überschrift kommt vom Server -- so kann hier auch eine Rolle
stehen, deren Namen diese Datei gar nicht kennen darf. Nach
Überschrift gruppiert und nicht nach Rolle: Wer einen Menschen
sucht, sucht ihn in einer Liste, nicht in einer Rangordnung. */
const uebrig = personen.filter((p) => !gezeichnet.has(p.rolle));
const nachTitel = new Map();
for (const p of uebrig) {
const t = p.gruppe || 'Weitere';
if (!nachTitel.has(t)) nachTitel.set(t, []);
nachTitel.get(t).push(p);
}
for (const [titel, leute] of nachTitel) gruppeBauen(leute[0].rolle, titel, leute);
wahlGeaendert();
}
/* Welche Zustaendigkeiten noch keinen Kanal haben. Die Liste kommt vom
Server; steht nichts darin, gibt es alle schon. */
function kanalWahlFuellen() {
const feld = $('kanal-kategorie');
feld.textContent = '';
for (const k of kanalFrei) {
const o = document.createElement('option');
o.value = k.wert; o.textContent = k.name;
feld.append(o);
}
$('kanal-alle').hidden = kanalFrei.length > 0;
feld.hidden = kanalFrei.length === 0;
}
/* Umschalten zwischen "Gespräch" und "Kanal". Ein zweiter Dialog
waere dieselbe Personenwahl ein zweites Mal -- und die zweite
Fassung waere die, in der eine Regel fehlt. */
function neuArtSetzen(art) {
neuArt = art;
for (const b of document.querySelectorAll('#neu-art [data-art]')) {
const an = b.dataset.art === art;
b.dataset.an = an ? 'ja' : '';
b.setAttribute('aria-pressed', an ? 'true' : 'false');
}
$('kanal-zeile').hidden = art !== 'kanal' || !!kanalUmbau;
$('neu-titel').textContent = kanalUmbau ? 'Wer ist im Kanal?'
: art === 'kanal' ? 'Neuer Kanal' : 'Neues Gespräch';
$('neu-unter').textContent = kanalUmbau
? 'Antippen, um jemanden hinein- oder herauszunehmen. Die Leitung bleibt immer dabei.'
: art === 'kanal'
? 'Ein Kanal je Zuständigkeit. Der Name kommt aus der Kategorie – so gibt es ihn nur einmal.'
: 'Eine Person antippen, um ihr zu schreiben. Mehrere auswählen ergibt eine Gruppe.';
wahlGeaendert();
}
function wahlGeaendert() {
const n = gewaehlt.size;
if (neuArt === 'kanal') {
/* Ein Kanal braucht keinen Namen (er kommt aus der Kategorie) und
auch niemanden -- man legt ihn an und fuellt ihn spaeter. Nur
eine Kategorie muss es geben, sonst gibt es nichts anzulegen. */
$('gruppenname-zeile').hidden = true;
$('neu-los').disabled = !kanalUmbau && !$('kanal-kategorie').value;
$('neu-los').textContent = kanalUmbau ? 'Übernehmen' : 'Kanal anlegen';
return;
}
/* Ab zwei ist es eine Gruppe -- und die braucht einen Namen. */
$('gruppenname-zeile').hidden = n < 2;
$('neu-los').disabled = n === 0;
@@ -956,8 +1168,48 @@
async function neuAbschicken(e) {
e.preventDefault();
const ids = [...gewaehlt];
if (!ids.length) return;
const knopf = $('neu-los');
/* KANAL: anlegen oder umbesetzen. Steht zuerst, weil hier auch eine
LEERE Auswahl erlaubt ist -- ein Kanal ohne Leute ist ein
gueltiger Zwischenstand, ein Gespraech ohne Gegenueber nicht. */
if (neuArt === 'kanal') {
knopf.disabled = true;
try {
if (kanalUmbau) {
const a = await hole(`/workspace/api/chat/kanal/${kanalUmbau}/mitglieder`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mit: ids }),
});
if (!a.ok) {
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
return;
}
const raumId = kanalUmbau;
$('neu-dialog').close();
await raeumeLaden();
await raumOeffnen(raumId, { sanft: true });
return;
}
const kategorie = $('kanal-kategorie').value;
if (!kategorie) { $('neu-fehler').textContent = 'Wähle eine Kategorie.'; return; }
const a = await hole('/workspace/api/chat/kanal', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kategorie, mit: ids }),
});
if (!a.ok) {
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
return;
}
const raumId = (await a.json()).raum_id;
$('neu-dialog').close();
await raeumeLaden();
await raumOeffnen(raumId);
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
return;
}
if (!ids.length) return;
knopf.disabled = true;
try {
@@ -1194,9 +1446,29 @@
$('suche').addEventListener('input', () => { raeumeZeichnen(); nachrichtenSuchen(); });
$('neu-oeffnen').addEventListener('click', async () => {
kanalUmbau = null;
kanalWahlFuellen();
neuArtSetzen('gespraech');
await partnerLaden();
$('neu-dialog').showModal();
});
for (const b of document.querySelectorAll('#neu-art [data-art]')) {
b.addEventListener('click', () => neuArtSetzen(b.dataset.art));
}
$('kanal-kategorie').addEventListener('change', wahlGeaendert);
/* WER IST IM KANAL. Derselbe Dialog, vorbelegt mit denen, die schon
drin sind -- so sieht man beim Oeffnen den Stand und muss ihn nicht
erst aus der Kopfzeile abschreiben. */
$('kanal-leute').addEventListener('click', async () => {
if (!offen || offen.art !== 'kanal') return;
kanalUmbau = offen.id;
kanalWahlFuellen();
neuArtSetzen('kanal');
await partnerLaden(offen.teilnehmer.map((t) => t.id));
$('neu-dialog').showModal();
});
$('neu-zu').addEventListener('click', () => $('neu-dialog').close());
$('neu-form').addEventListener('submit', neuAbschicken);
$('zur-liste').addEventListener('click', () => {