VIER DINGE AUS DEN BILDSCHIRMFOTOS, jedes ein echter Fehler:
1. SPICY MEDIA SAH DOGFATHERS DATEN. Beim ersten Anlauf bekam die Rolle
dieselbe Regel wie DogFather (`1=1`) -- damit stimmte der Ueberblick
ueber Manager, Scouts und Creator, und nebenbei standen seine eigenen
Termine, Aufgaben, Dateien und Eintraege mit drin. Jetzt gibt es
ohneDogFather() als EINE Stelle dafuer: Zwei Spalten werden geprueft,
`creator_id` (um wen geht es) und `erstellt_von` (wer hat es
geschrieben) -- ein Termin, den er sich selbst anlegt, haengt nur an
der zweiten. `IS NULL OR NOT IN` und nicht bloss `NOT IN`: In SQL ist
`NULL NOT IN (...)` weder wahr noch falsch, die Zeile fiele
stillschweigend heraus.
2. "PERSOENLICHER ZUGANGSCODE · undefined" auf der Anmeldeseite. Eine
zweite Namensliste im Browser, die beim Hinzufuegen der Rolle
niemand gepflegt hat. Ein unbekannter Schluessel faellt jetzt auf
sich selbst zurueck statt auf `undefined` -- haesslich, aber
sichtbar.
3. SPICY KAM NICHT IN DIE PERSONENVERWALTUNG. Der Server liess sie
herein, das Skript warf sie wieder hinaus: zwei Listen fuer dieselbe
Frage, gepflegt wurde nur die erste.
4. DIE ANMELDEKARTE WAR ZU KLEIN FUER FUENF ROLLEN. Der Kommentar an
genau dieser Stelle warnt woertlich davor -- und ich habe getan,
wovor er warnt: eine Rolle eingefuegt und die Zahl daneben nicht
angefasst. Gemessen: Inhalt 573 px in einer 526 px hohen Tafel, die
Fusszeile stand unter dem Rahmen. Schrift kleiner half nicht (sie lag
schon auf dem Anschlag), also sind die Abstaende an neun Stellen
enger. Nachgemessen auf fuenf Groessen: passt ueberall.
DIE ZEIT WAR WIRKLICH FALSCH. Nicht nur in meinem Satz: `datum()` in
personen.js schnitt die ISO-Zeichenkette ab -- und die ist UTC. Im
Protokoll stand 03:00, wo 05:00 war. Das Tueckische daran ist, dass es
nie kaputt aussieht: Eine Uhrzeit ist immer plausibel.
PROFILBILDER WERDEN JETZT GEZEICHNET. Der Server lieferte sie seit
gestern mit, gezeichnet wurden sie nirgends -- deshalb aenderte sich
nichts. Jetzt in der Personenauswahl (Aufgaben, Termine, Dateien,
Bereiche), in der Gespraechsliste und an jeder Nachricht. Der
Anfangsbuchstabe bleibt als Unterlage LIEGEN: Faellt das Bild aus, steht
dort weiter etwas Sinnvolles.
DER CHAT: Gesichter mit Rollenfarbe, Blasen mit Richtung (die erste
einer Folge eckig, die naechsten rund -- so sieht man, wo ein Gedanke
anfaengt), das offene Gespraech mit Schiene, Ungelesenes hervorgehoben,
und das Eingabefeld in einer eigenen Leiste, die sich beim Schreiben
hebt.
ZWEI EIGENE PATZER, beide von Pruefungen gefunden:
- `o is not defined`: Mein Suchmuster hat die zwei Zeilen fuer das
Bild ans DATEIENDE gesetzt statt in die Schleife -- und in
kalender.js an einen <span> statt ans <option>. Gemeldet von
pruef-sicht, das die Browserkonsole mitliest.
- Ein Kommentar mit `bild` in schraegen Anfuehrungszeichen stand INNEN
in einer Vorlagenzeichenkette und hat sie geschlossen. Die halbe SQL
wurde zu Programmtext.
Dazu: `.chat-neu` gibt es nicht (heisst `.chat__eingabe`), und der
Rollenknopf hatte fuer den Manager keinen sichtbaren Fokus -- ein
box-shadow wird von `overflow: hidden` abgeschnitten, ein outline nicht.
Gruen: spicy (43), creator-anlegen (29), personen-liste (33), sicht (48),
chat (48), chat-optik (24), buehne (38), struktur (32), css-klassen (15),
handy (59), breiten (23), formulare (19), start-ansicht (136),
lesbarkeit (14), barrierefrei (18), tempo (8), kopf-messen (3),
glocke (26), haerte (20), manager-sicht (43), alle-wege (19),
aufgabenbrett (44), kalender (84).
Co-Authored-By: Claude Opus 5 <[email protected]>
763 lines
33 KiB
JavaScript
763 lines
33 KiB
JavaScript
/* ===================================================================
|
||
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 = new Set(['spicy', 'admin', 'manager']);
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const ROLLENNAME = { admin: 'DogFather', manager: 'Manager', scout: 'Scout', creator: 'Creator' };
|
||
|
||
/* 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.' },
|
||
{ wert: 'admin', name: 'DogFather', symbol: 'krone',
|
||
text: 'Alle Rechte, endgültig. Nur DogFather legt DogFather, Spicy Media und Manager an.' },
|
||
/* 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. */
|
||
const darf = ich?.rolle === 'admin' ? ['spicy', 'admin', 'manager', 'scout', 'creator']
|
||
: ich?.rolle === 'spicy' ? ['manager', 'creator']
|
||
: ['creator'];
|
||
for (const r of ROLLEN.filter((x) => darf.includes(x.wert))) {
|
||
if ((r.wert === 'admin' || r.wert === 'manager') && ich.rolle !== 'admin') continue;
|
||
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');
|
||
});
|
||
ziel.append(b);
|
||
}
|
||
}
|
||
|
||
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;
|
||
$('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();
|
||
}
|
||
|
||
$('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-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 = [];
|
||
|
||
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((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((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');
|
||
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((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. */
|
||
if (ich.rolle === 'admin') {
|
||
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(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. */
|
||
const ROLLEN_ABSCHNITTE = [
|
||
{ 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 = '';
|
||
|
||
const vorhanden = ROLLEN_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 Ü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);
|
||
|
||
kopf.append(h, zahl, unter, 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);
|
||
}
|
||
|
||
/* 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 } = await a.json();
|
||
betreuerListe = betreuer || [];
|
||
alle = personen;
|
||
ziel.textContent = '';
|
||
ziel.setAttribute('aria-busy', 'false');
|
||
listeZeigen();
|
||
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. */
|
||
const rolle = gewaehlteRolle();
|
||
const weg = ich.rolle === 'admin' ? '/workspace/api/verwaltung/personen'
|
||
: rolle === 'manager' ? '/workspace/api/manager-anlegen'
|
||
: '/workspace/api/creator-anlegen';
|
||
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 = (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; }
|
||
if (!LEITUNG.has(ich.rolle)) { 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. */
|
||
const nurAnlegen = ich.rolle !== 'admin';
|
||
if (nurAnlegen) {
|
||
document.body.dataset.nurAnlegen = 'ja';
|
||
$('unterzeile') && ($('unterzeile').textContent =
|
||
'Hier legst du neue Leute an. Codes, Sperren und das Protokoll bleiben bei DogFather.');
|
||
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();
|
||
})();
|
||
})();
|