Files
dogfather-universe/workspace/assets/js/personen.js
T
DogFatherGitandClaude Opus 5 18a5231b70 Die Tuer ohne Klingel, der Anruf ohne Ende, die Glocke ohne Liste
Siebter Durchgang durch die Team-Dogi-Seiten aus Benutzersicht. Alles am
Code belegt, die Groessenangaben nachgerechnet.

=== DIE ANMELDUNG ===

WOHER BEKOMMT MAN EINEN CODE? Stand nirgends. Vier Kacheln, ein Feld,
ein Knopf -- wer keinen Code hatte, fand keinen einzigen Satz dazu. Fuer
die Community besonders teuer: Sie ist die einzige Rolle, die von
aussen kommt und niemanden im Haus kennt.

"BITTE KURZ WARTEN" SIND BIS ZU ZEHN MINUTEN (VERSUCHE_FENSTER_MIN).
Wer nach zwanzig Sekunden nachsieht und dieselbe Meldung liest, haelt
die Seite fuer kaputt. Jetzt steht die Zahl da.

EIN RICHTIGER CODE AN DER FALSCHEN KACHEL fiel in dieselbe Absage wie
ein erfundener -- der Server sucht nur unter der gewaehlten Rolle. Wer
aus der Community kommt und auf "Modi" tippt (das klingt ja danach),
tippt seinen richtigen Code dreimal Buchstabe fuer Buchstabe. Ab dem
zweiten Fehlversuch kommt jetzt ein Hinweis auf die Auswahl -- ohne zu
sagen, welche die richtige waere.

=== DER ANRUF: VIER FEHLER, EINE ERFAHRUNG ===

DAS FREIZEICHEN LIEF UNENDLICH. Es gab keinen Zeitgeber, der aufhoert.
Wer niemanden erreichte, sass vor einem piependen Kasten mit "02:47"
darauf, als telefoniere er laengst. Der Server schickt den Verfallszeit-
punkt sogar mit (`laeuft_bis`) -- benutzt hat ihn nie jemand.

DER KLINGELKASTEN VERSCHWAND NACH 46 SEKUNDEN, waehrend der Server 120
gibt. Wer beim Klingeln das Handy aus der Tasche holt und in Sekunde 50
hinsieht, fand nichts mehr -- kein Kasten, keine Knoepfe -- waehrend der
Anrufer noch siebzig Sekunden Freizeichen hoerte. Die 120 Sekunden sind
im Server ausdruecklich damit begruendet, dass man Zeit zum Entsperren
braucht; eine Zeile im Browser hat diese Entscheidung aufgehoben.

DAS FREIZEICHEN PIEPTE WEITER, waehrend daneben "Keine Verbindung."
stand. Ton sagte "es klingelt noch", Text sagte "vorbei".

DIE UHR ZAEHLTE AB DEM WAEHLEN. Wer 30 Sekunden klingeln liess und 12
Sekunden sprach, las im Kasten "00:42" und danach im Chat "Anruf . 12
Sekunden". Der Server rechnet es richtig; der Kasten hat es wieder
kaputtgemacht.

Dazu: Die Mikrofon-Absage verwies auf "das Schloss-Symbol neben der
Adresse" -- in der installierten App gibt es weder Adresszeile noch
Schloss. Das Haus kennt diesen Fehler und hat ihn beim Weg zur
Anruf-Probe schon behoben; hier stand er noch.

Und die Anruf-Probe: Ihr einziger Punkt ohne Handlungsanweisung war
"Antwort 503." -- auf einer Seite, die ausdruecklich fuer jemanden
gebaut ist, der nicht technisch ist. Ihr Rueckweg fuehrte ausserdem
immer in den Chat, auch wenn man von der Startseite kam.

=== DER TREFF ===

EIN EINZEILER MACHTE SECHS ERKLAERTEXTE UNSICHTBAR. bewerben.js las
`g.unter`, der Server schickt `g.text` -- also immer undefined. Damit
fehlten ALLE SECHS Gruppeneinleitungen des Fragebogens ("Kein Test mit
richtigen Antworten"), und die CSS-Regel dafuer lief ins Leere. Uebrig
blieben sechs nackte Ueberschriften ueber fuenfzehn Fragen.

DIE EINZIGE "SO LAEUFT DAS HIER"-SEITE WAR FAST UNERREICHBAR. Die
Kachel "Regeln & Hilfe" zeigte auf ein Brett, das am ersten Tag leer
ist. Die Regeln, die es wirklich gibt (Begruessung, fuenf Regeln, drei
Stufen, Mindestalter), stehen auf treff-regeln.html -- erreichbar ueber
genau einen kleinen Textlink auf einer Brettseite. Die Kachel zeigt
jetzt dorthin.

FIEL /api/treff/lage AUS, versprach die Seite einem Zuschauer
Schreibrechte, die der Server ablehnt: Die Pruefung fiel bei `lage ===
null` auf `true` durch -- von "darf auf keinem Brett" auf "darf
ueberall". Formular auf, getippt, 403.

Dazu: hilfe.js hatte eine eigene kleine Fehlertabelle fuer zwei
Kennungen und endete sonst bei "Ging nicht." -- ausgerechnet die Seite,
auf der jemand mit einem Problem sitzt, hatte die inhaltsleerste Absage
im Haus. Und der gesperrte "Neue Meldung"-Knopf erklaerte sich nur im
Maus-Tooltip; die Community kommt von TikTok, also vom Handy.

=== DIE STARTSEITE ===

DIE COMMUNITY BEKAM EIN WORT. Unter dem Titel steht bei jeder Rolle ein
Satz -- beim Community-Mitglied stand "Community". Woertlich derselbe
Mangel, den der Kommentar bei MODI_ROLLENTEXT beschreibt und fuer den
Modi behebt.

DER RING MELDETE 100 %, bevor man irgendetwas getan hat. Am ersten Tag,
ohne eine einzige Aufgabe, stand das groesste Element der Seite auf
"100 % -- HEUTE NICHTS OFFEN". Gelesen wird zuerst die Zahl, und 100 %
heisst fuer jeden "fertig", nicht "leer".

UND BEI EINER STOERUNG BLIEB "wird geladen" FUER IMMER STEHEN -- der
fruehe `return` liess den Platzhalter unberuehrt.

=== DIE ZUGANGSVERWALTUNG ===

Der Code-Kasten sagte "jetzt weitergeben" und gab die Haelfte nicht
mit, die man weitergeben muss: WO sich die Person anmeldet. DogFather
ruft den Code durchs Zimmer, der andere fragt "und wo?". Die Adresse
kommt jetzt vom Server (anmeldeAdresseFuer, abgeleitet aus derselben
Weiche), dazu ein Knopf "Code und Adresse kopieren".

Er verschwieg ausserdem den Rettungsweg: "danach nicht mehr abrufbar"
stimmt fuer DIESEN Code -- man kann jederzeit einen neuen vergeben. Wer
das nicht weiss, glaubt, er habe einen Menschen angelegt, der nie
hereinkommt. Und das Fenster liess sich wortlos schliessen, waehrend
der Code offen stand.

=== OPTIK: NACHGERECHNET, NICHT VERMUTET ===

DIE GLOCKE STAND NICHT IN DER LISTE. Sie ist ein echtes <button> und
wurde von der 44-px-Regel erfasst, waehrend ihre vier Nachbarn durch
einen staerkeren Selektor auf 36 px gehen. Zwischen 401 und 560 px --
also bei 412 px (haeufigste Android-Breite) und 430 px (iPhone Pro Max)
-- stand eine 44 px hohe runde Pille zwischen fuenf 36-px-Quadraten.
Der 400er-Block listet sie korrekt; genau dieses Band fiel durch beide
Rechnungen. Dasselbe Muster wie am 06.09.

DIE 9,6-px-REPARATUR IM KALENDER WAR SEIT WOCHEN WIRKUNGSLOS.
kalender.css wird NACH start.css geladen, beide Selektoren sind gleich
stark -- also gewann `.6rem` auf jeder Breite unter 760 px. Genau der
Wert, den start.css als behobenen Fehler protokolliert.

Und die Hebung selbst unterschritt ihre eigene Grenze: Die Regel, die
zu kleine Schrift hochziehen soll, zog `.k-pille` auf 11,2 px -- 0,3 px
unter die 11,5, die 33 Zeilen darueber aufgestellt werden.

Weitere nachgerechnete Groessen: Spaltenkopf und KW-Spalte im Kalender
(10,56 px), Kalenderwoche am Balken (9,92 px), Wecker-Knopf (30 px --
der kleinste im Haus, 14 unter der Vorgabe), Chat-Zurueck auf dem
Tablet (34x34, 40 % weniger Flaeche als noetig, und der einzige Weg
zurueck in die Liste), Emoji-Knoepfe (34 px bei 2 px Abstand -- wer
danebentippt, verschickt ein anderes Zeichen, und das ist sofort
abgeschickt).

IM WINDOWS-KONTRASTMODUS HATTE DIE STARTSEITE KEINE UEBERSCHRIFT.
`.ztitel` und der Team-Dogi-Schriftzug sind mit `background-clip: text`
gesetzt; faellt der Verlauf weg, bleibt durchsichtiger Text. Beide
haben jetzt den Rueckfall, den gate.css schon lange hat.

UND MEIN EIGENES STREIFENRASTER rechnete die Spaltenzahl aus der Breite
statt aus den Daten (`auto-fit`): Bei 390 px passten sechs, alles
darueber fiel in eine zweite, unbeschriftete Zeile. Das `overflow-x`
daneben konnte nie greifen -- ein auto-fit-Raster wird nie breiter als
sein Kasten. Der Kommentar beschrieb ein Verhalten, das es nicht gab.

GEMESSEN: pruef-meldungen 8/0, pruef-css-klassen ALLES IN ORDNUNG,
pruef-rechtetafel 19/0, alle Module laden, keine doppelten
Kachelnamen/-ziele/-farben in allen vier Rollen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 15:07:31 +02:00

1256 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ===================================================================
Personen und Zugangscodes verwalten. Nur für DogFather -- der Server
liefert allen anderen ohnehin 404 auf diese Schnittstellen.
=================================================================== */
(() => {
'use strict';
/* DogFather und Manager haben dieselben Rechte -- die Unterscheidung
macht nur die Personenverwaltung, und die prueft der Server. */
/* 'spicy' gehoert dazu (07.09.2026). Ohne diesen Eintrag schickte die
Seite Spicy Media sofort auf die Startseite zurueck -- der Server
liess sie herein, das Skript warf sie wieder hinaus. Genau der
Fehler, den Filipe gemeldet hat: "bei spicy funktioniert dieser
button nicht, wenn sie drauf druecken geht es nicht auf."
Zwei Listen fuer dieselbe Frage, eine im Server und eine hier --
und beim Hinzufuegen einer Rolle wurde nur die erste gepflegt. */
const LEITUNG = window.Bereiche.LEITUNG;
const $ = (id) => document.getElementById(id);
/* 'spicy' fehlte hier bis zum 07.09.2026. Vier Stellen lesen daraus
(Codeanzeige, Personenliste, Scout-Zeile, Manager-Zeile), und alle
vier haben den Fehlgriff mit `|| p.rolle` aufgefangen -- in der
Liste stand dann schlicht „spicy" statt „Spicy Media". Ein
Auffangwert, der halbwegs plausibel aussieht, ist der beste Weg,
eine Luecke jahrelang zu behalten. */
const ROLLENNAME = window.Bereiche.ROLLEN_NAME;
/* Reihenfolge und Erklaerung an EINER Stelle. Sie gilt fuer die
Rollenwahl im Formular und fuer alles, was sonst Rollen auflistet. */
const ROLLEN = [
{ wert: 'spicy', name: 'Spicy Media', symbol: 'chili',
text: 'Überblick über alle – ohne Automationen, ohne fremde Chats. Legt Leute an.' },
/* DER EINTRAG BLEIBT, DER KNOPF ERSCHEINT NICHT (11.09.2026).
Seit `ANLEGBAR` die Rolle `admin` nicht mehr enthaelt, filtert
die Zeile unten (`darf.includes(...)`) ihn von selbst heraus --
fuer jeden, auch fuer DogFather. Filipe: "dogfather soll man
nicht auswaehlen koennen. das ist die einzige die man nicht
auswaehlen kann bitte."
Warum er trotzdem hier steht: Diese Liste ist auch das
Nachschlagewerk fuer Name und Zeichen der Rolle. Ihn zu loeschen
hiesse, an anderer Stelle eine zweite Liste zu brauchen. Der
Text sagt jetzt, was gilt -- ein Text, der etwas anderes
verspricht als die Oberflaeche tut, ist schlimmer als keiner. */
{ wert: 'admin', name: 'DogFather', symbol: 'husky',
text: 'Alle Rechte, endgültig. Diese Rolle lässt sich nicht vergeben – auch nicht von DogFather.' },
/* Der Satz stimmte bis zum 07.09.2026 -- seither legen Manager
Creator an, und ein Text, der etwas anderes verspricht als die
Oberflaeche tut, ist schlimmer als gar keiner. */
{ wert: 'manager', name: 'Manager', symbol: 'stern',
text: 'Dieselben Rechte wie DogFather, ohne Personenverwaltung – legt aber Creator an.' },
{ wert: 'scout', name: 'Scout', symbol: 'schutz',
text: 'Eigene Pipeline und die Creator, die ihm zugeteilt sind.' },
{ wert: 'creator', name: 'Creator', symbol: 'person',
text: 'Nur der eigene Bereich, eigene Ziele und Aufgaben.' },
];
function baueRollenwahl() {
const ziel = $('f-rolle');
if (!ziel || ziel.dataset.fertig) return;
ziel.dataset.fertig = 'ja';
/* DogFather und Manager kann nur DogFather vergeben -- der Server
lehnt es ohnehin ab, aber ein Knopf, der immer scheitert, gehoert
gar nicht erst hin. */
/* WER WELCHE ROLLE VERGEBEN DARF (07.09.2026).
DogFather: alle. Spicy Media: Manager und Creator. Manager: nur
Creator. Der Server setzt dasselbe durch (drei getrennte Wege,
siehe workspace-personen.js) -- hier geht es nur darum, niemandem
einen Knopf hinzustellen, der eine Absage bringt.
Aus derselben Liste wie die Beschreibungen, damit die beiden nicht
auseinanderlaufen. */
/* KEINE EIGENE LISTE MEHR (10.09.2026).
Hier standen ZWEI, drei Zeilen auseinander, und sie widersprachen
einander: Die erste erlaubte Spicy Media `['manager','creator']`,
die zweite warf den Manager mit einem `continue` wieder hinaus,
sobald man nicht DogFather war. Uebrig blieb ein einziger Knopf.
Serverseitig war die Tuer fuer den Manager die ganze Zeit offen —
es gab nur nichts zum Draufdruecken. Genau so sieht dieser Fehler
aus: Nichts ist kaputt, nichts wird rot, es fehlt einfach.
Die Liste kommt jetzt vom Server (`darf_anlegen` aus /api/ich).
Damit kann sie nicht mehr abweichen — weder zu streng noch zu
grosszuegig. Faellt die Angabe aus (alte Fassung im Zwischen-
speicher), bleibt der Creator uebrig: das Wenigste, nicht das
Meiste. */
const darf = Array.isArray(ich?.darf_anlegen) && ich.darf_anlegen.length
? ich.darf_anlegen
: ['creator'];
for (const r of ROLLEN.filter((x) => darf.includes(x.wert))) {
ziel.append(rollenKnopf(ziel, r));
}
}
/* EIN KNOPF DER ROLLENWAHL.
Steht als eigene Funktion da, seit die Auswahl aus ZWEI Quellen
kommt: der Liste oben (fuer alle gleich) und dem, was der Server
zusaetzlich schickt (siehe rollenwahlErgaenzen). Zwei Abschriften
desselben Knopfes waeren zwei Gelegenheiten, dass der eine ein
aria-checked bekommt und der andere nicht. */
function rollenKnopf(ziel, r) {
const b = el('button', 'rollenwahl__knopf');
b.type = 'button';
b.dataset.rolle = r.wert;
b.setAttribute('role', 'radio');
b.setAttribute('aria-checked', r.wert === 'creator' ? 'true' : 'false');
if (r.wert === 'creator') b.dataset.an = 'ja';
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
s.setAttribute('viewBox', '0 0 24 24');
s.setAttribute('class', 'rollenwahl__symbol');
s.setAttribute('aria-hidden', 'true');
const u = document.createElementNS('http://www.w3.org/2000/svg', 'use');
u.setAttribute('href', '#r-' + r.symbol);
s.append(u);
const txt = el('span', 'rollenwahl__text');
txt.append(el('span', 'rollenwahl__name', r.name));
txt.append(el('span', 'rollenwahl__erklaerung', r.text));
b.append(s, txt);
b.addEventListener('click', () => {
for (const x of ziel.querySelectorAll('.rollenwahl__knopf')) {
delete x.dataset.an;
x.setAttribute('aria-checked', 'false');
}
b.dataset.an = 'ja';
b.setAttribute('aria-checked', 'true');
});
return b;
}
/* ROLLEN, DIE ERST DER SERVER NENNT (09.09.2026).
Die Liste ROLLEN oben steht in dieser Datei -- und diese Datei
bekommt jeder ausgeliefert, der die Seite oeffnet. Eine Rolle, von
der ausser der DogFather-Rolle niemand wissen soll, darf deshalb
nicht darin stehen; sie kommt aus /workspace/api/verwaltung/personen
und nur an die, die sie vergeben duerfen.
WARUM NACHTRAEGLICH UND NICHT GLEICH: baueRollenwahl() laeuft, bevor
die Personen geladen sind -- das Formular soll sofort dastehen und
nicht auf das Netz warten. Die Zusatzrolle kommt also eine Runde
spaeter dazu. Wer nichts bekommt, sieht die Auswahl wie bisher. */
function rollenwahlErgaenzen(zusatz) {
const ziel = $('f-rolle');
if (!ziel || !Array.isArray(zusatz) || !zusatz.length) return;
for (const r of zusatz) {
if (!r || !r.wert || ziel.querySelector(`[data-rolle="${r.wert}"]`)) continue;
ziel.append(rollenKnopf(ziel, r));
}
}
/* Die Rollen, die der Server mitschickt -- Auswahl beim Anlegen UND
Überschrift in der Liste. Eine Quelle für beides; zwei wären zwei
Gelegenheiten, dass eine nachgezogen wird und die andere nicht. */
let zusatzAbschnitte = [];
const gewaehlteRolle = () =>
document.querySelector('.rollenwahl__knopf[data-an="ja"]')?.dataset.rolle || 'creator';
let ich = null;
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
const melde = (t) => { $('fehler').textContent = t || ''; };
/* Solange ein frisch erzeugter Code auf dem Bildschirm steht, darf die
Seite NICHT von selbst zur Anmeldung springen.
Genau das ist am 02.09.2026 passiert: Der Code erschien, die Sitzung
war im selben Moment beendet, der nächste Aufruf lief in ein "nicht
angemeldet" -- und die Umleitung nahm den Code mit, bevor er
abgeschrieben war. Danach kam niemand mehr hinein.
Der Serverteil ist repariert (die eigene Sitzung bleibt jetzt
bestehen). Diese Sperre hier ist der zweite Riegel: Selbst wenn eine
Sitzung aus einem anderen Grund endet -- Zeitablauf, ein zweiter
Bildschirm, ein Neustart --, bleibt der Code stehen, bis er
weggeklickt wird. Ein Code, den man nicht mehr lesen kann, ist
schlimmer als gar keiner. */
let codeOffen = false;
async function hole(pfad, optionen = {}) {
const a = await fetch(pfad, { credentials: 'same-origin', ...optionen });
if (a.status === 401) {
if (codeOffen) {
melde('Deine Sitzung ist beendet. Schreib den Code oben JETZT ab – '
+ 'die Seite bleibt stehen, bis du „Verstanden" drückst.');
throw new Error('abgemeldet, Code steht noch');
}
location.assign('/workspace/'); throw new Error('abgemeldet');
}
if (a.status === 404 && pfad.includes('/verwaltung/')) {
location.assign('/workspace/start.html'); throw new Error('nicht erlaubt');
}
return a;
}
/* ORTSZEIT, NICHT UTC (07.09.2026).
Hier stand `iso.slice(0, 16).replace('T', ' ')` -- also ein Schnitt
durch die Zeichenkette. Der Server schreibt seine Zeitstempel in
UTC; im Sommer sind das ZWEI STUNDEN weniger. Im Protokoll stand
deshalb "03:00", wo tatsaechlich 05:00 war. Filipe hat es gemeldet:
"wie 3 uhr, wir haben 5 uhr."
Das Tueckische daran: Es sieht nie kaputt aus. Eine Uhrzeit ist
immer plausibel -- man merkt es nur, wenn man zufaellig gerade auf
die Uhr sieht. Und im Winter waere es nur eine Stunde, also noch
schwerer zu bemerken.
`new Date(iso)` liest den Zeitstempel richtig und gibt ihn in der
Zeit des Geraets aus. Ein Schnitt durch die Zeichenkette ist
ausschliesslich bei einem reinen DATUM erlaubt ("2026-09-07") --
dort gibt es keine Zeitzone, um die man sich irren koennte. */
const datum = (iso) => {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
const z = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} `
+ `${z(d.getHours())}:${z(d.getMinutes())}`;
};
/* ---------- Code anzeigen -------------------------------------------- */
function zeigeCode(p) {
$('code-name').textContent = `${p.name} · ${ROLLENNAME[p.rolle] || p.rolle}`;
$('code-wert').textContent = p.code;
/* DIE ADRESSE GEHOERT DAZU (19.09.2026). Sie kommt vom Server, weil
dort die Weiche steht, die Rollen auf Adressen verteilt -- eine
zweite Liste im Browser waere die, die beim naechsten Umzug
stehen bleibt. */
const adr = p.adresse || '';
const a = $('code-adresse');
a.textContent = adr.replace(/^https:\/\//, '');
a.href = adr || '#';
a.parentElement.hidden = !adr;
$('code-kasten').hidden = false;
/* Ab jetzt springt die Seite nicht mehr von selbst weg -- siehe
die Begruendung bei hole(). */
codeOffen = true;
$('code-kasten').scrollIntoView({ behavior: 'smooth', block: 'center' });
/* Den Zeiger in den Kasten setzen: Wer mit der Tastatur arbeitet,
landet sonst irgendwo in der Liste und findet den Code nicht. */
$('code-kopieren').focus();
}
/* =======================================================================
ROLLE ÄNDERN (10.09.2026)
DIE AUSWAHL WIRD NICHT NOCH EINMAL GEBAUT, sondern aus dem
Anlege-Formular gelesen. Dort stehen genau die Rollen, die der
Server dieser Person zugesteht -- einschliesslich derer, die in
keiner ausgelieferten Datei stehen duerfen und erst nachträglich
dazukommen (siehe rollenwahlErgaenzen).
Eine zweite Liste hier wäre die, in der eine Rolle fehlt oder eine
zu viel steht -- und beides fiele erst auf, wenn jemand sie
braucht. */
let rolleFuer = null;
function rolleFragen(p) {
rolleFuer = p;
$('rolle-name').textContent = `${p.name} · ${ROLLENNAME[p.rolle] || p.rolle}`;
const ziel = $('rolle-wahl');
ziel.textContent = '';
const vorhanden = [...document.querySelectorAll('#f-rolle .rollenwahl__knopf')]
.map((b) => ({ wert: b.dataset.rolle, name: b.querySelector('.rollenwahl__name')?.textContent || b.dataset.rolle }))
.filter((r) => r.wert && r.wert !== p.rolle);
if (!vorhanden.length) {
ziel.append(el('p', 'chat__leer', 'Es gibt keine andere Rolle, die du vergeben kannst.'));
}
for (const r of vorhanden) {
const b = el('button', 'rollenwahl__knopf');
b.type = 'button';
b.dataset.rolle = r.wert;
b.append(el('span', 'rollenwahl__name', r.name));
b.addEventListener('click', () => {
for (const x of ziel.querySelectorAll('.rollenwahl__knopf')) delete x.dataset.an;
b.dataset.an = 'ja';
});
ziel.append(b);
}
$('rolle-kasten').hidden = false;
$('rolle-kasten').scrollIntoView({ behavior: 'smooth', block: 'center' });
}
$('rolle-abbrechen').addEventListener('click', () => {
$('rolle-kasten').hidden = true;
rolleFuer = null;
});
$('rolle-los').addEventListener('click', async () => {
if (!rolleFuer) return;
const gewaehlt = $('rolle-wahl').querySelector('.rollenwahl__knopf[data-an="ja"]')?.dataset.rolle;
if (!gewaehlt) { melde('Wähle zuerst eine Rolle.'); return; }
/* Die Rückfrage nennt beide Rollen im Klartext. "Rolle ändern?"
allein sagt nicht, WAS sich ändert -- und es ist der Handgriff,
der jemandem den Zugang zu einer ganzen Seite nimmt oder gibt. */
const name = $('rolle-wahl').querySelector('.rollenwahl__knopf[data-an="ja"] .rollenwahl__name')?.textContent || gewaehlt;
if (!confirm(`${rolleFuer.name} wird von "${ROLLENNAME[rolleFuer.rolle] || rolleFuer.rolle}" zu "${name}".\n\n`
+ 'Der Zugangscode bleibt derselbe. Offene Sitzungen werden beendet.')) return;
const a = await hole(`/workspace/api/verwaltung/personen/${rolleFuer.id}/rolle`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rolle: gewaehlt }),
});
if (!a.ok) { melde(sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.')); return; }
$('rolle-kasten').hidden = true;
rolleFuer = null;
await laden();
});
$('code-schliessen').addEventListener('click', () => {
$('code-kasten').hidden = true;
codeOffen = false;
/* Den Code auch aus dem Dokument entfernen, damit er nicht in einer
Bildschirmaufnahme oder im Verlauf hängen bleibt. */
$('code-wert').textContent = '';
});
/* CODE UND ADRESSE ZUSAMMEN -- der Knopf, den man wirklich braucht.
Wer nur den Code kopiert, schickt eine Zeichenfolge ohne Tuer. */
$('code-beides').addEventListener('click', async () => {
const adr = $('code-adresse').href;
const text = `Dein Zugang zu Team Dogi
Adresse: ${adr}
Code: `
+ `${$('code-wert').textContent}
Der Code gilt einmal fuer die `
+ `Anmeldung. Danach legst du dir dort ein Passwort fest.`;
try {
await navigator.clipboard.writeText(text);
$('code-beides').textContent = 'Kopiert ✓';
setTimeout(() => { $('code-beides').textContent = 'Code und Adresse kopieren'; }, 1800);
} catch {
melde('Kopieren ging nicht — bitte von Hand markieren.');
}
});
/* DAS FENSTER SCHLIESSEN, WAEHREND DER CODE OFFEN STEHT.
Die Seite war gegen das Wegspringen schon geschuetzt (siehe
hole()) -- gegen den Menschen, der den Reiter zumacht, aber nicht.
Ein Code, der in dem Moment verschwindet, ist ein Mensch, der nie
hereinkommt, und niemand merkt es bis zur ersten Nachfrage.
Der Browser zeigt dazu seinen eigenen Text; man kann ihn nicht
bestimmen. Das ist hier auch nicht noetig: Es geht nur darum, dass
ueberhaupt jemand fragt. */
window.addEventListener('beforeunload', (e) => {
if (!codeOffen) return;
e.preventDefault();
e.returnValue = '';
});
$('code-kopieren').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText($('code-wert').textContent);
$('code-kopieren').textContent = 'Kopiert ✓';
setTimeout(() => { $('code-kopieren').textContent = 'Kopieren'; }, 1800);
} catch {
melde('Kopieren ging nicht — bitte von Hand markieren.');
}
});
/* ---------- Liste ----------------------------------------------------- */
let betreuerListe = [];
/** Ein oder zwei Buchstaben aus einem Namen.
*
* Zwei nur bei einem echten zweiten Wort: Aus "Spicy Media" wird SM,
* aus "BananaStift" ein B -- Binnenversalien als zweiten Namen zu
* lesen ergäbe "BS" für eine Person, die einen Namen hat. */
function initialen(name) {
const teile = String(name || '').trim().split(/[\s._-]+/).filter(Boolean);
if (!teile.length) return '?';
if (teile.length === 1) return teile[0].slice(0, 1).toUpperCase();
return (teile[0][0] + teile[teile.length - 1][0]).toUpperCase();
}
function zeile(p) {
const k = el('div', 'person');
k.dataset.aktiv = p.aktiv ? 'ja' : 'nein';
const links = el('div');
links.append(el('div', 'person__name', p.name + (p.id === ich.id ? ' (du)' : '')));
const z = el('div', 'person__zeile');
const rolle = el('span', 'marke-rolle', ROLLENNAME[p.rolle] || p.rolle);
rolle.dataset.rolle = p.rolle;
z.append(rolle);
if (!p.aktiv) z.append(el('span', null, 'gesperrt'));
z.append(el('span', null, `letzte Anmeldung: ${datum(p.letzter_login)}`));
z.append(el('span', null, `offene Aufgaben: ${p.offene_aufgaben}`));
if (p.sitzungen) z.append(el('span', null, `${p.sitzungen} offene Sitzung(en)`));
if (p.betreut_anzahl) {
z.append(el('span', null, `betreut ${p.betreut_anzahl} Creator`));
}
/* Bei einem Manager: wie viele Scouts an ihm haengen. Ohne diese Zahl
muesste man die ganze Liste durchgehen, um zu sehen, ob eine
Zuteilung ueberhaupt angekommen ist.
Steht in DERSELBEN Zeile wie "betreut N Creator" -- ein eigenes
Element haette eine eigene Klasse gebraucht, die es im Stil nicht
gibt, und waere als nackter Text erschienen. */
if (p.rolle === 'manager' && p.scouts_anzahl) {
z.append(el('span', null,
`${p.scouts_anzahl} ${p.scouts_anzahl === 1 ? 'Scout' : 'Scouts'} zugeteilt`));
}
/* Beim Scout umgekehrt: zu wem er gehoert. */
if (p.rolle === 'scout' && p.manager_name) {
z.append(el('span', null, `gehört zu ${p.manager_name}`));
}
links.append(z);
/* Zuständigkeit. Steht nur bei Creator und nur dann, wenn es
überhaupt einen Scout gibt, dem man sie geben könnte. */
if (p.rolle === 'creator' && betreuerListe.length) {
const b = el('div', 'betreuung');
const s = el('label', 'betreuung__schild', 'Betreut von');
s.htmlFor = 'betreuer-' + p.id;
const w = el('select', 'betreuung__wahl');
w.id = 'betreuer-' + p.id;
/* Der Leer-Wert heisst jetzt, was er ist. Vorher stand dort
"DogFather" -- das sah aus wie eine Zuordnung, war aber keine,
und dieselben Creator tauchten gleichzeitig im Hinweis "Creator
ohne zustaendige Person" auf. Zwei Aussagen, ein Bildschirm.
DogFather steht jetzt als echte Person in der Liste, gleichrangig
neben den Scouts. */
const leer = document.createElement('option');
leer.value = ''; leer.textContent = '— niemand —';
w.append(leer);
for (const s2 of betreuerListe) {
const o = document.createElement('option');
o.value = String(s2.id);
/* Die Rolle mit dabei: Bei gleichen oder aehnlichen Namen ist
sonst nicht zu erkennen, wen man gerade einträgt.
ABER NICHT, WENN SIE DASSELBE SAGT WIE DER NAME. Dort stand
"Dogfather (DogFather)" -- zweimal dasselbe Wort, nur einmal
anders geschrieben. Eine Klammer, die nichts hinzufuegt, ist
keine Auskunft, sondern Rauschen. Verglichen wird ohne Rücksicht
auf Gross- und Kleinschreibung und ohne Leerzeichen, sonst
faellt genau dieser Fall wieder durch. */
const rolleName = ROLLENNAME[s2.rolle] || s2.rolle || '';
const gleich = (a, b) =>
a.trim().toLowerCase().replace(/\s+/g, '') === b.trim().toLowerCase().replace(/\s+/g, '');
o.textContent = rolleName && s2.rolle !== 'scout' && !gleich(s2.name, rolleName)
? `${s2.name} (${rolleName})`
: s2.name;
w.append(o);
}
w.value = p.betreuer_id ? String(p.betreuer_id) : '';
w.addEventListener('change', async () => {
w.disabled = true; melde('');
try {
const a = await hole(`/workspace/api/verwaltung/betreuung/${p.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ betreuer_id: w.value || null }),
});
if (!a.ok) { melde(sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.')); return; }
await laden();
} catch { /* umgeleitet */ } finally { w.disabled = false; }
});
b.append(s, w);
links.append(b);
}
/* ---- Welchem Manager gehoert dieser Scout? (01.09.2026) ----
"er soll nur die Scouts sehen, die ihm zugeteilt sind."
NUR fuer DogFather sichtbar -- und das ist kein Schoenheitsgrund:
Diese Zuteilung ERWEITERT die Sicht eines Managers (auf die Leads
seiner Scouts und auf deren Creator). Duerfte er sie selbst
setzen, koennte er sich seine eigene Sichtbarkeit vergeben. Der
Server weist es ohnehin ab; hier geht es nur darum, niemandem
einen Knopf hinzustellen, der nur eine Absage bringt.
Und nur, wenn es ueberhaupt einen Manager gibt -- eine leere
Auswahl sieht aus wie ein Fehler. */
/* Manager UND DogFather -- genau wie bei "Betreut von" der Creator
(Wunsch 02.09.2026). Die beiden bewirken Verschiedenes: Bei einem
Manager entscheidet der Eintrag ueber die Sichtbarkeit, bei
DogFather haelt er nur die Zustaendigkeit fest. Beides ist eine
Antwort auf "wen frage ich?" -- und genau darum geht es in dieser
Liste. */
const managerListe = betreuerListe.filter(
(x) => x.rolle === 'manager' || x.rolle === 'admin');
if (p.rolle === 'scout' && ich.rolle === 'admin' && managerListe.length) {
const b = el('div', 'betreuung');
const s2 = el('label', 'betreuung__schild', 'Gehört zu');
s2.htmlFor = 'manager-' + p.id;
const w2 = el('select', 'betreuung__wahl');
w2.id = 'manager-' + p.id;
const leer2 = document.createElement('option');
leer2.value = '';
/* Wie bei den Creatorn: "niemand" heisst wirklich niemand.
Vorher stand hier "— direkt bei DogFather —" -- das sah aus wie
eine Zuordnung und war keine, und derselbe Fehler war bei den
Creatorn schon einmal behoben worden. DogFather steht jetzt als
echte Person in der Liste, gleichrangig neben den Managern. */
leer2.textContent = '— niemand —';
w2.append(leer2);
for (const m of managerListe) {
/* Dieselbe Regel wie bei den Creatorn: Die Rolle in Klammern nur
dann, wenn sie etwas hinzufuegt -- "Dogfather (DogFather)"
waere zweimal dasselbe Wort. */
const rn = ROLLENNAME[m.rolle] || m.rolle || '';
const gl = (a, b2) =>
a.trim().toLowerCase().replace(/\s+/g, '') === b2.trim().toLowerCase().replace(/\s+/g, '');
w2.append(new Option(gl(m.name, rn) ? m.name : `${m.name} (${rn})`, String(m.id)));
}
w2.value = p.manager_id ? String(p.manager_id) : '';
w2.addEventListener('change', async () => {
w2.disabled = true; melde('');
try {
const a = await hole(`/workspace/api/verwaltung/scout-zuteilung/${p.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ manager_id: w2.value || null }),
});
if (!a.ok) { melde(sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.')); return; }
await laden();
} catch { /* umgeleitet */ } finally { w2.disabled = false; }
});
b.append(s2, w2);
links.append(b);
}
const knoepfe = el('div', 'person__knoepfe');
/* WER NUR LIEST, BEKOMMT KEINE KNOEPFE. Der Server antwortet ihr
auf jeden davon mit 404 -- ein Knopf, der eine Absage bringt, ist
schlimmer als kein Knopf. */
if (ich.rolle === 'hand') { k.append(links, knoepfe); return k; }
/* WER NICHT DOGFATHER IST, BEKOMMT NUR, WAS ER DARF (11.09.2026).
Vorher war das eine Ja/Nein-Frage: DogFather bekam alles, alle
anderen nichts (bei Spicy Media blendete zusaetzlich das CSS die
ganze Reihe aus). Seit Filipes Wunsch -- "ich will dass die rolle
spicy und dogfather, auch die rollen wechseln koennen wenn die
personen schon drin sind" -- gibt es einen Zwischenfall: eine
Rolle mit GENAU EINEM Knopf.
Die Verzweigung hier statt einer CSS-Regel: Ein Knopf, den es
gar nicht gibt, kann auch nicht durch eine spaetere Stilaenderung
wieder auftauchen. Und "Neuer Code" und "Sperren" wuerden ihr
weiterhin 404 bringen -- die bleiben deshalb aus. */
if (ich.rolle !== 'admin') {
if (ich.darf_rollen_wechseln && p.rolle !== 'admin') {
knoepfe.append(tuKnopf('Rolle ändern', () => rolleFragen(p)));
}
k.append(links, knoepfe);
return k;
}
knoepfe.append(tuKnopf('Neuer Code', async () => {
const eigen = p.id === ich.id;
const frage = eigen
/* Der Satz stimmte bis zum 02.09.2026 -- und beschrieb genau die
Falle: Man bestätigte, der Code erschien, und im selben Moment
war die Sitzung weg. Jetzt bleibt sie bestehen, und der Text
sagt, was wirklich passiert. */
? 'Neuer Code für dich selbst?\n\n'
+ 'Der alte Code gilt danach nicht mehr, und alle ANDEREN Geräte '
+ 'werden abgemeldet.\n\n'
+ 'Hier bleibst du angemeldet – der neue Code steht danach auf dem '
+ 'Bildschirm, bis du ihn selbst wegklickst.'
: `Neuer Code für ${p.name}?\n\nDer alte Code gilt dann nicht mehr, offene Sitzungen werden beendet.`;
if (!confirm(frage)) return;
const a = await hole(`/workspace/api/verwaltung/personen/${p.id}/code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(eigen ? { auch_mich: true } : {}),
});
if (!a.ok) { melde('Neuer Code hat nicht geklappt.'); return; }
zeigeCode(await a.json());
await laden();
}));
if (p.id !== ich.id) {
knoepfe.append(tuKnopf(p.aktiv ? 'Sperren' : 'Entsperren', async () => {
if (p.aktiv && !confirm(`${p.name} sperren?\n\nOffene Sitzungen werden sofort beendet.`)) return;
const a = await hole(`/workspace/api/verwaltung/personen/${p.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aktiv: !p.aktiv }),
});
if (!a.ok) { melde(sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.')); return; }
await laden();
}));
/* Löschen steht ganz rechts, hinter dem Sperren -- der harmlosere
Weg zuerst. Und nur für DogFather: "Nur DogFather hat alle
endgültigen Rechte" heißt genau hier etwas. Ein Manager kann
weiterhin sperren, das reicht für den Alltag und ist umkehrbar. */
/* ROLLE ÄNDERN steht VOR dem Löschen -- der umkehrbare Weg
zuerst. Bis heute war Löschen und Neuanlegen der einzige Weg,
jemandem eine andere Aufgabe zu geben; genau das soll dieser
Knopf ersparen. */
/* ZWEI KNOEPFE, ZWEI BEDINGUNGEN (11.09.2026).
Hier stand EINE Zeile (`ich.rolle === 'admin'`) fuer beide.
Filipe: "ich will dass die rolle spicy und dogfather, auch die
rollen wechseln koennen wenn die personen schon drin sind."
Loeschen bleibt bei DogFather -- das hat er nicht genannt, und
"Nur DogFather hat alle endgueltigen Rechte" gilt weiter.
Die Antwort kommt vom Server (`darf_rollen_wechseln`), nicht
aus einem Rollenvergleich hier. Am selben Tag hat mich genau
das im Chat eine Stunde gekostet: zwei Stellen, eine Aussage,
und nur eine wusste Bescheid.
An DogFather selbst aendert nur DogFather -- Spicy Media sieht
seine Zeile ohnehin nicht, aber ein Knopf, der eine Absage
bringt, gehoert auch dann nicht hin. */
/* Ab hier ist `ich` DogFather -- die Verzweigung weiter oben hat
alle anderen schon verabschiedet. Beide Knoepfe gehoeren ihm. */
knoepfe.append(tuKnopf('Rolle ändern', () => rolleFragen(p)));
const weg = tuKnopf('Löschen', () => loeschenFragen(p));
weg.dataset.gefahr = 'ja';
knoepfe.append(weg);
}
k.append(links, knoepfe);
return k;
}
function tuKnopf(text, tu) {
const b = el('button', 'schritt', text);
b.type = 'button';
b.addEventListener('click', async () => {
b.disabled = true; melde('');
try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; }
});
return b;
}
/* ---------- Wer steht in der Liste ---------------------------------------
Wunsch Filipe, 31.08.2026: "ich will da nur mich und vanvan sehen,
ich will dass ich einen knopf habe wenn ich die anderen sehen will
oder nicht."
Also: DogFather immer, alle anderen auf Knopfdruck. Drei Dinge sind
dabei wichtig:
1. Das ist eine ANSICHT, kein Recht. Wer eingeklappt ist, ist nicht
weg -- er wird nur nicht gezeigt. Verwechselt man das, hält man
irgendwann jemanden für gelöscht, der noch vollen Zugang hat.
Deshalb steht auf dem Knopf IMMER, wie viele gerade fehlen.
2. Der Zustand bleibt erhalten. Ein Knopf, den man nach jedem Laden
neu drücken muss, ist keine Einstellung, sondern eine Zumutung.
3. Gesperrte Personen tauchen trotzdem auf, sobald aufgeklappt ist --
eine gesperrte Person zu verstecken wäre genau die Zeile, die man
sehen müsste.
-------------------------------------------------------------------------- */
/* ---------- Löschen ------------------------------------------------------
Die einzige Handlung hier, die sich nicht rückgängig machen lässt.
Deshalb wird sie NICHT mit einem confirm() abgetan, sondern zeigt
vorher, was daran hängt: was mitgeht, was nur seine Zuordnung
verliert, und wer danach ohne Betreuung dasteht.
Der Name muss getippt werden. Nicht als Schikane -- der Löschknopf
sitzt neben dem Sperrknopf, und die beiden sind sehr verschieden.
Wer den Namen tippt, hat die Zeile gelesen, die er trifft.
Den Knopf gibt es nur für DogFather; der Server prüft dasselbe noch
einmal und antwortet allen anderen mit 404. */
async function loeschenFragen(person) {
let v;
try {
const a = await hole(`/workspace/api/verwaltung/personen/${person.id}/loeschbar`);
if (!a.ok) { melde('Das Löschen ist hier nicht möglich.'); return; }
v = await a.json();
} catch { return; }
if (v.hindernis) { melde(v.hindernis); return; }
const zeilen = [];
const mit = Object.entries(v.mit).filter(([, n]) => n > 0);
const bleibt = Object.entries(v.bleibt).filter(([, n]) => n > 0);
zeilen.push(`„${v.person.name}" wirklich löschen?`, '');
if (mit.length) {
zeilen.push('Wird MITGELÖSCHT:');
for (const [was, n] of mit) zeilen.push(` · ${was}: ${n}`);
zeilen.push('');
}
if (bleibt.length) {
zeilen.push('BLEIBT erhalten, verliert nur die Zuordnung:');
for (const [was, n] of bleibt) zeilen.push(` · ${was}: ${n}`);
zeilen.push('');
}
if (v.verliertBetreuung.length) {
zeilen.push(`ACHTUNG: ${v.verliertBetreuung.join(', ')} `
+ `${v.verliertBetreuung.length === 1 ? 'steht' : 'stehen'} danach ohne zuständige Person da.`, '');
}
if (!mit.length && !bleibt.length && !v.verliertBetreuung.length) {
zeilen.push('An dieser Person hängt nichts.', '');
}
zeilen.push('Vorher wird automatisch eine Sicherung geschrieben.', '',
`Zum Bestätigen den Namen genau eingeben: ${v.person.name}`);
const eingabe = prompt(zeilen.join('\n'), '');
if (eingabe === null) return;
if (eingabe.trim() !== v.person.name) {
melde('Der Name stimmt nicht – es wurde nichts gelöscht.');
return;
}
try {
const a = await hole(`/workspace/api/verwaltung/personen/${person.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: eingabe.trim() }),
});
const d = await a.json().catch(() => ({}));
if (!a.ok) { melde(sagWas(d.fehler, 'Löschen hat nicht geklappt.')); return; }
/* Die Sicherung nennen. Wer gerade etwas Unumkehrbares getan hat,
soll sofort wissen, woraus es sich zurückholen lässt. */
melde(`„${v.person.name}" wurde gelöscht. Sicherung davor: ${d.sicherung}`);
await laden();
} catch { /* umgeleitet */ }
}
/* ---------- Was aufgeklappt ist ------------------------------------------
Wunsch Filipe, 31.08.2026: "ich will überall die liste zu machen
können und nur aufmachen wenn ich sie sehen will."
Also klappt JEDE Rolle einzeln auf und zu. Das ersetzt den vorherigen
Sammelknopf ("5 weitere zeigen") -- zwei Mechanismen nebeneinander,
die dasselbe verstecken, wären eine Einladung zum Missverständnis.
Die Überschrift mit der ANZAHL bleibt immer stehen, auch zugeklappt.
Das ist der entscheidende Unterschied zum Ausblenden: Man sieht
jederzeit, dass es vier Manager gibt -- man sieht nur nicht, welche.
Wer eine ganze Rolle spurlos verschwinden lässt, hält sie irgendwann
für leer.
Voreinstellung: DogFather offen, alles andere zu. */
const SPEICHER = 'dfw_personen_offen';
let alle = [];
let offen = new Set(['admin']);
try {
const g = JSON.parse(localStorage.getItem(SPEICHER) || 'null');
if (Array.isArray(g)) offen = new Set(g);
} catch { /* kaputt gespeichert = Voreinstellung */ }
function offenMerken() {
try { localStorage.setItem(SPEICHER, JSON.stringify([...offen])); } catch { /* egal */ }
}
/* Jede Rolle bekommt einen eigenen Abschnitt, in der Reihenfolge der
ZUGANGSSEITE: DogFather, Manager, Scout, Creator. Die Zusatztexte
stehen dort wörtlich genauso -- wer sich eben angemeldet hat, findet
hier dieselbe Sprache wieder.
Eine Rolle ohne Personen wird weggelassen. Eine leere Überschrift
ist kein Ordnungsmerkmal, sondern eine Lücke. */
/* SPICY MEDIA FEHLTE HIER (07.09.2026). Die Rolle gibt es seit heute
Frueh, die Personenliste kannte sie nicht -- ein Spicy-Zugang stand
damit in KEINEM Abschnitt und war in der Liste unsichtbar, obwohl
der Server ihn geliefert hat. Filipe hat es gemeldet: "bei personen
& zugangscode soll auch die kategorie spicy der rolle dogfather
angezeigt werden."
Die Reihenfolge folgt bereiche.js (Spicy Media ueber DogFather). */
const ROLLEN_ABSCHNITTE = [
{ rolle: 'spicy', name: 'Spicy Media', unter: 'Überblick & Zugänge anlegen' },
{ rolle: 'admin', name: 'DogFather', unter: 'Gesamtübersicht & Freigaben' },
{ rolle: 'manager', name: 'Manager', unter: 'Betreuung & Freigaben' },
{ rolle: 'scout', name: 'Scout', unter: 'Eigene Pipeline & Kontakte' },
{ rolle: 'creator', name: 'Creator', unter: 'Eigener Bereich & Fortschritt' },
];
function listeZeigen() {
const ziel = $('liste');
ziel.textContent = '';
/* SPICY MEDIA SIEHT ALLES AUSSER DOGFATHER (07.09.2026).
Filipe: "spicy soll genau das sehen koennen, aber nur nicht die
rolle dogfather."
Das ist dieselbe Grenze, die im ganzen Haus gilt: Die Rolle hat
den Ueberblick ueber Manager, Scouts und Creator -- DogFathers
eigene Sachen gehen sie nichts an. Hier faellt deshalb genau
dieser eine Abschnitt weg, nicht mehr und nicht weniger.
DAS IST KEINE SICHERUNG, sondern Aufraeumen: Der Server liefert
ihr die Liste ohnehin nur, wenn sie sie sehen darf. Eine
ausgeblendete Zeile hat noch nie etwas geschuetzt. */
/* DER ABSCHNITT DOGFATHER WIRD NICHT MEHR AUSGEBLENDET (07.09.2026,
zweite Praezisierung). Filipe: "die rolle spicy soll auch die
rolle dogfather sehen, aber nur dogfather und nicht vanvan."
Hier stand ein Filter, der den ganzen Abschnitt wegnahm. Jetzt
entscheidet der SERVER, wer darin steht -- er liefert Spicy Media
nur den ersten Admin-Zugang. Das ist auch die richtige Stelle
dafuer: Eine ausgeblendete Zeile hat noch nie etwas geschuetzt,
und zwei Regeln fuer dieselbe Frage laufen auseinander.
`filter((a) => a.drin.length)` erledigt den Rest von selbst: Ist
niemand darin, gibt es den Abschnitt auch nicht. */
/* ABSCHNITTE, DIE DER SERVER MITBRINGT (10.09.2026).
Die Liste oben kennt fünf Rollen. Wer eine sechste hat, wurde bis
eben NICHT GEZEICHNET -- kein Fehler, keine Lücke, kein Hinweis.
VanVan verschwand genau in dem Moment aus der Personenliste, in
dem sie die rechte Hand wurde; Filipe hat es im Bildschirmfoto
geschickt ("ich muss alle kategorien da sehen").
Die Überschriften stehen NICHT hier. Diese Datei bekommt jeder,
der die Seite öffnet, und die Namen der verborgenen Rollen
gehören dort nicht hinein -- sie kommen mit der Liste vom
Server, an die, die sie sehen dürfen. Dieselbe Lösung wie bei der
Personenauswahl im Chat.
DAS AUFFANGBECKEN DARUNTER ist der eigentliche Fortschritt: Käme
morgen eine siebte Rolle und niemand dächte an diese Stelle,
stünden ihre Leute trotzdem auf der Seite -- unter ihrem
Rollennamen, sichtbar, statt lautlos zu fehlen. Ein Abschnitt mit
einer unschönen Überschrift ist tausendmal besser als ein Mensch,
den es auf dem Bildschirm nicht gibt. */
const abschnitte = [...ROLLEN_ABSCHNITTE];
for (const r of zusatzAbschnitte) {
if (!r || !r.wert || abschnitte.some((a) => a.rolle === r.wert)) continue;
abschnitte.push({ rolle: r.wert, name: r.name || r.wert, unter: r.text || '' });
}
for (const p of alle) {
if (abschnitte.some((a) => a.rolle === p.rolle)) continue;
abschnitte.push({ rolle: p.rolle, name: p.rolle, unter: '' });
}
const vorhanden = abschnitte
.map((a) => ({ ...a, drin: alle.filter((p) => p.rolle === a.rolle) }))
.filter((a) => a.drin.length);
for (const abschnitt of vorhanden) {
const auf = offen.has(abschnitt.rolle);
const block = document.createElement('section');
block.className = 'gruppe';
/* DIE ROLLE STEHT AM ABSCHNITT (09.09.2026).
Ohne sie sehen alle fünf Zeilen gleich aus, und die Farben, die
das Haus für die Rollen ohnehin führt (Anmeldeseite, Marken,
Bereiche), hören ausgerechnet in der Personenliste auf. Das
Merkmal ist die einzige Zeile Skript, die dafür nötig ist --
alles Weitere macht das CSS. */
block.dataset.rolle = abschnitt.rolle;
/* UND DER ZUSTAND, an derselben Stelle. Das CSS fragte ihn bisher
ueber `:has(> .gruppe__kopf[aria-expanded="true"])` ab -- das
geht, ist aber ein zweiter Weg zu einer Auskunft, die hier
ohnehin vorliegt. Ein Merkmal, drei Regeln, kein Suchen. */
block.dataset.auf = auf ? 'ja' : 'nein';
/* Die Überschrift IST der Knopf. Ein eigener kleiner Schalter
daneben wäre ein zweites Ziel für dieselbe Absicht -- und ein
kleineres. Als <button> statt <div> mit Klick-Zuhörer, damit
Tastatur und Vorleseprogramme es ohne Zutun richtig machen. */
const kopf = document.createElement('button');
kopf.type = 'button';
kopf.className = 'gruppe__kopf gruppe__kopf--klappbar';
kopf.setAttribute('aria-expanded', auf ? 'true' : 'false');
const h = document.createElement('h2');
h.className = 'gruppe__name';
h.textContent = abschnitt.name;
const zahl = document.createElement('span');
zahl.className = 'gruppe__zahl';
zahl.textContent = String(abschnitt.drin.length);
const unter = document.createElement('span');
unter.className = 'gruppe__unter';
/* Zugeklappt zählt der Zusatztext weniger als die Frage "ist da
etwas Gesperrtes drin, das ich sehen müsste?". */
const gesperrt = abschnitt.drin.filter((p) => !p.aktiv).length;
unter.textContent = gesperrt
? `${abschnitt.unter} · ${gesperrt} gesperrt`
: abschnitt.unter;
if (gesperrt) unter.dataset.warn = 'ja';
const pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
pfeil.setAttribute('viewBox', '0 0 24 24');
pfeil.setAttribute('aria-hidden', 'true');
pfeil.setAttribute('class', 'klapp-pfeil');
const pfad = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pfad.setAttribute('d', 'm6 9.5 6 6 6-6');
pfeil.append(pfad);
/* WER DRINSTEHT — SICHTBAR, OHNE AUFZUKLAPPEN.
Zugeklappt sagte die Zeile bisher nur, WIE VIELE es sind. Wer
wissen wollte, ob Patrick dabei ist, musste aufklappen. Vier
Namenszeichen beantworten das im Vorbeigehen; ab dem fünften
steht "+n" dahinter, damit die Zeile nicht wächst.
`aria-hidden`, weil es eine Wiederholung ist: Die Anzahl steht
als Zahl daneben, die Namen stehen beim Aufklappen vollständig
da. Ein Vorleseprogramm würde hier sonst Buchstabenpaare
vorlesen, die niemandem etwas sagen. Für die Maus steht der
volle Name im title. */
const leute = document.createElement('span');
leute.className = 'gruppe__leute';
leute.setAttribute('aria-hidden', 'true');
const ZEIGEN = 4;
for (const p of abschnitt.drin.slice(0, ZEIGEN)) {
const z = document.createElement('span');
z.className = 'gruppe__kopf-zeichen';
z.textContent = initialen(p.name);
z.title = p.aktiv ? p.name : `${p.name} (gesperrt)`;
if (!p.aktiv) z.dataset.gesperrt = 'ja';
leute.append(z);
}
if (abschnitt.drin.length > ZEIGEN) {
const mehr = document.createElement('span');
mehr.className = 'gruppe__kopf-zeichen';
mehr.dataset.mehr = 'ja';
mehr.textContent = `+${abschnitt.drin.length - ZEIGEN}`;
mehr.title = abschnitt.drin.slice(ZEIGEN).map((p) => p.name).join(', ');
leute.append(mehr);
}
kopf.append(h, zahl, unter, leute, pfeil);
kopf.addEventListener('click', () => {
if (offen.has(abschnitt.rolle)) offen.delete(abschnitt.rolle);
else offen.add(abschnitt.rolle);
offenMerken();
listeZeigen();
});
block.append(kopf);
if (auf) for (const p of abschnitt.drin) block.append(zeile(p));
ziel.append(block);
}
/* DIE NAMENSSPALTE WIRD GEMESSEN (09.09.2026, screen3).
Filipe: "die sollen schön untereinander sein und nicht so
durcheinander."
Die Koepfe stehen seit heute in vier Spalten (siehe personen.css).
Damit Zahl und Zusatztext ueber alle Abschnitte hinweg fluchten,
muss die erste Spalte ueberall gleich breit sein -- und zwar so
breit wie der LAENGSTE Rollenname.
Diese Breite wird hier am gezeichneten Text abgenommen, nicht
ausgerechnet. Eine feste Angabe (`11ch` fuer "Spicy Media") waere
auf drei Arten falsch: Sie haengt an der Schriftart, `ch` misst
die Ziffer Null und nicht Grossbuchstaben mit Sperrung, und sie
gilt nur, bis jemand eine sechste Rolle mit laengerem Namen
anlegt. Gemessen stimmt sie immer.
Erst zuruecksetzen, dann messen: Steht die Vorgabe noch, fuellt
jeder Name seine Spalte aus, und die Messung liefert die eigene
Vorgabe von eben zurueck. (`justify-self: start` im CSS haelt die
Felder zusaetzlich auf Inhaltsbreite -- Guertel und Hosentraeger,
weil dieser Fehler von aussen wie ein Zufall aussaehe.) */
ziel.style.removeProperty('--namen-spalte');
const namen = [...ziel.querySelectorAll('.gruppe__name')];
if (namen.length) {
const breit = Math.max(...namen.map((n) => n.getBoundingClientRect().width));
if (breit > 0) ziel.style.setProperty('--namen-spalte', Math.ceil(breit) + 'px');
}
/* Ein Knopf für alle auf einmal -- bei vier Rollen viermal klicken zu
müssen, nur um einmal alles zu sehen, wäre unnötige Arbeit. */
const schalter = $('rest-schalter');
schalter.hidden = vorhanden.length < 2;
const alleOffen = vorhanden.every((a) => offen.has(a.rolle));
$('rest-schalter-text').textContent = alleOffen ? 'Alle zuklappen' : 'Alle aufklappen';
schalter.setAttribute('aria-expanded', alleOffen ? 'true' : 'false');
schalter.onclick = () => {
if (alleOffen) offen.clear();
else for (const a of vorhanden) offen.add(a.rolle);
offenMerken();
listeZeigen();
};
}
async function laden() {
const ziel = $('liste');
try {
const a = await hole('/workspace/api/verwaltung/personen');
if (!a.ok) { melde('Personen konnten nicht geladen werden.'); return; }
const { personen, betreuer, zusatzrollen } = await a.json();
betreuerListe = betreuer || [];
zusatzAbschnitte = Array.isArray(zusatzrollen) ? zusatzrollen : [];
rollenwahlErgaenzen(zusatzrollen);
alle = personen;
ziel.textContent = '';
ziel.setAttribute('aria-busy', 'false');
listeZeigen();
/* DAS PROTOKOLL BLEIBT BEI DOGFATHER. Fuer Spicy Media antwortet
es mit 404 -- und `hole()` versteht ein 404 unter
`/verwaltung/` als "nicht erlaubt" und schickt zur Startseite
zurueck. Genau so hat der Aufruf sie von der Personenseite
geworfen, kaum dass die Liste da war: Die Seite baute sich auf
und sprang im naechsten Atemzug weg. Gemessen -- auf
personen.html standen die Kategorien der STARTSEITE.
Eine Abfrage, von der man weiss, dass sie 404 gibt, stellt man
nicht. */
if (ich.rolle === 'admin') await protokollLaden();
} catch { /* umgeleitet */ }
}
async function protokollLaden() {
try {
const a = await hole('/workspace/api/verwaltung/protokoll?anzahl=25');
if (!a.ok) return;
const { eintraege } = await a.json();
const ziel = $('protokoll');
ziel.textContent = '';
for (const e of eintraege) {
const z = el('div', 'protokoll-zeile');
z.append(el('span', 'protokoll-zeit', datum(e.zeitpunkt)));
z.append(el('span', null, e.aktion));
z.append(el('span', null, [e.wer, e.detail].filter(Boolean).join(' · ')));
z.append(el('span', 'protokoll-ip', e.ip || ''));
ziel.append(z);
}
/* Zugeklappt stehen nur die neuesten Zeilen da. Gemeinsamer
Helfer aus kopf.js -- die Automationen-Seite nutzt denselben. */
window.klappe?.(ziel, $('protokoll-schalter'), $('protokoll-schalter-text'));
} catch { /* umgeleitet */ }
}
/* ---------- Anlegen ---------------------------------------------------- */
const umschalten = (auf) => {
$('neu').hidden = !auf; $('neu-oeffnen').hidden = auf;
if (auf) $('f-name').focus();
};
$('neu-oeffnen').addEventListener('click', () => umschalten(true));
$('neu-abbrechen').addEventListener('click', () => {
$('neu').reset(); $('neu-fehler').textContent = ''; umschalten(false);
});
$('neu').addEventListener('submit', async (e) => {
e.preventDefault();
$('neu-fehler').textContent = '';
const knopf = $('neu-speichern');
knopf.disabled = true;
try {
/* DREI TUEREN, EINE AUSWAHL (07.09.2026).
DogFather geht durch die Verwaltungstuer und kann jede Rolle
vergeben. Manager und Spicy Media haben dort nichts zu suchen --
fuer sie gibt es zwei enge Tueren, die je genau eine Rolle
anlegen und die Rolle gar nicht erst als Feld annehmen.
Die Zuordnung steht hier und nicht in einer Abfrage im Server:
Der Server prueft ohnehin, WER durch welche Tuer darf. Diese
Zeilen sorgen nur dafuer, dass niemand gegen eine verschlossene
laeuft. */
/* EINE TUER JE ROLLE, UND ZWAR AUSGESCHRIEBEN (10.09.2026).
Hier stand `rolle === 'manager' ? manager-anlegen :
creator-anlegen` — ein Zweig mit einem Auffangbecken. Solange
es nur zwei Rollen gab, stimmte das. Mit dem Scout waere es
still falsch geworden: Er waere im `else` gelandet, und
`creator-anlegen` legt IMMER einen Creator an. Der Knopf haette
funktioniert, die Meldung haette Erfolg gemeldet, und in der
Liste stuende ein Creator, wo ein Scout stehen sollte.
Eine Zuordnung statt einer Kette: Wer keine Tuer hat, bekommt
eine Fehlermeldung — nicht die naechstbeste Tuer. */
const rolle = gewaehlteRolle();
const TUEREN = {
manager: '/workspace/api/manager-anlegen',
scout: '/workspace/api/scout-anlegen',
creator: '/workspace/api/creator-anlegen',
};
const weg = ich.rolle === 'admin' ? '/workspace/api/verwaltung/personen' : TUEREN[rolle];
if (!weg) {
melde(`Für die Rolle „${rolle}“ gibt es hier keinen Weg.`);
return;
}
const a = await hole(weg, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ich.rolle === 'admin'
? { name: $('f-name').value, rolle }
: { name: $('f-name').value }),
});
if (!a.ok) {
$('neu-fehler').textContent = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.');
return;
}
$('neu').reset(); umschalten(false);
zeigeCode(await a.json());
await laden();
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
});
/* ---------- Start ------------------------------------------------------- */
(async () => {
try {
const a = await hole('/workspace/api/ich');
if (!a.ok) { location.assign('/workspace/'); return; }
ich = await a.json();
} catch { return; }
/* DIE RECHTE HAND KOMMT HEREIN, ABER NUR ZUM LESEN (10.09.2026).
Filipe: "ich will dass die rechte hand auch alle sieht."
`LEITUNG` aus bereiche.js kennt sie nicht und darf sie nicht
kennen -- die Rolle steht in keiner ausgelieferten Datei. Sie
wird deshalb hier daneben zugelassen, nicht dort hineingesetzt.
DAS IST KEINE SICHERUNG, sondern eine Weiche: Was sie darf,
entscheidet der Server (nurAdmin lässt sie nur an die Liste, und
nur lesend). Diese Zeile erspart ihr nur den Umweg über eine
Seite, die sie sofort wieder wegschickt. */
const nurLesen = ich.rolle === 'hand';
if (!LEITUNG.has(ich.rolle) && !nurLesen) {
location.assign('/workspace/start.html'); return;
}
window.werZeigen?.(ich);
/* ===================================================================
DIE SEITE HAT ZWEI AUSBAUSTUFEN (07.09.2026)
Filipe: "die sollen in der kategorie wo die jetzt sehen werden
auch noch das mit den hinzufuegen sehen, alles andere auf der
seite sollen die weiterhin nicht sehen."
DogFather bekommt die Seite wie bisher: Liste, Codes, Sperren,
Loeschen, Protokoll. Manager und Spicy Media bekommen genau EINEN
Teil davon -- das Anlegen.
DAS VERSTECKEN IST NICHT DIE SICHERUNG. Alles, was hier
ausgeblendet wird, haengt am Server an `nurAdmin` und antwortet
mit 404. Wuerde diese Zeile fehlen, saehen sie leere Kaesten und
Fehlermeldungen -- aber keine Daten. Das Ausblenden erspart ihnen
nur den Anblick von Dingen, die es fuer sie nicht gibt.
Und deshalb wird auch `laden()` NICHT aufgerufen: Es fragt die
Verwaltungsschnittstelle ab, und die ist zu. Eine Abfrage, von der
man weiss, dass sie 404 gibt, sollte man nicht stellen -- sie
fuellt nur das Serverlog. */
/* DRITTE AUSBAUSTUFE: NUR LESEN.
Sie steht VOR `nurAnlegen`, weil sie enger ist -- wer nur liest,
legt auch nichts an. Stünde sie danach, bekäme die rechte Hand
zuerst das Anlegeformular und danach den Hinweis, dass sie nichts
darf; sichtbar wäre beides. */
if (nurLesen) {
document.body.dataset.nurLesen = 'ja';
/* SIE LEGT NIEMANDEN AN. Das Ausblenden ist keine Sicherung --
der Server antwortet ihr mit 404 --, es erspart ihr nur ein
Formular, dessen Absenden eine Absage bringt. Beide Elemente,
nicht nur eines: Der Knopf oeffnet das Formular, und ein
verstecktes Formular mit sichtbarem Knopf ist eine Tuer ohne
Raum dahinter. */
const auf = $('neu-oeffnen'); if (auf) auf.hidden = true;
const form = $('neu'); if (form) form.hidden = true;
const zeile = $('unterzeile');
if (zeile) {
zeile.textContent = 'Du siehst hier das ganze Team. Codes, Sperren, '
+ 'Rollen und das Protokoll bleiben bei DogFather.';
zeile.hidden = false;
}
await laden();
return;
}
const nurAnlegen = ich.rolle !== 'admin';
if (nurAnlegen) {
document.body.dataset.nurAnlegen = 'ja';
/* DER ERKLÄRSATZ. Er stand hier schon, kam aber nie an: Ein
Element mit der Kennung `unterzeile` gab es auf der Seite
nicht, und das `&&` davor hat den Fehlgriff verschluckt.
Gemessen im Browser als Manager -- die Seite bestand aus
Überschrift und Formular, ohne ein Wort dazu, warum hier so
wenig steht. Jetzt gibt es das Element (personen.html), und
der Satz wird auch sichtbar gemacht. */
const zeile = $('unterzeile');
if (zeile) {
zeile.textContent =
'Hier legst du neue Leute an. Codes, Sperren und das Protokoll bleiben bei DogFather.';
zeile.hidden = false;
}
/* SPICY MEDIA BEKOMMT DIE LISTE -- ZUM LESEN (07.09.2026).
Filipe: "spicy soll genau das sehen koennen, aber nur nicht die
rolle dogfather." Bis hierher sah die Rolle auf dieser Seite
nur das Anlegen-Formular; die Liste wurde bewusst nicht
geladen, weil die Verwaltungsschnittstelle zu war. Sie ist es
weiterhin -- aber die LISTE ist seit heute offen, ohne
DogFathers Zeile (siehe workspace-personen.js).
Der Manager bekommt sie NICHT: Fuer ihn gilt unveraendert
"anlegen, nicht verwalten". Deshalb steht hier die Rolle und
nicht die Leitung. */
const liste = $('liste');
if (ich.rolle === 'spicy') {
document.body.dataset.nurLesen = 'ja';
laden();
} else if (liste) {
/* Fuer alle anderen hoert die Liste auf zu laden. Sie steht mit
aria-busy="true" in der Seite, und `laden()` wird fuer sie
nicht aufgerufen -- ohne diese Zeilen meldete die Seite einem
Vorleseprogramm dauerhaft "wird geladen", und es kam nie
etwas. Ein Ladehinweis, der nie endet, ist schlimmer als
keiner. */
liste.setAttribute('aria-busy', 'false');
liste.textContent = '';
}
baueRollenwahl();
/* Das Formular steht offen da -- es ist der einzige Grund, warum
diese Seite fuer sie ueberhaupt aufgeht. Ein Knopf, der eine
leere Seite oeffnet, waere ein Klick ohne Zweck. */
$('neu').hidden = false;
$('neu-oeffnen').hidden = true;
$('neu-abbrechen').hidden = true;
return;
}
baueRollenwahl();
await laden();
})();
})();