Files
dogfather-universe/workspace/assets/js/personen.js
T
DogFatherGitandClaude Opus 5 ec712a3a32 Personen: jede Rolle einzeln auf- und zuklappbar
Wunsch Filipe: "ich will ueberall die liste zu machen koennen und nur
aufmachen wenn ich sie sehen will."

Jede der vier Rollen klappt jetzt einzeln auf und zu. Das ERSETZT den
Sammelknopf von vorhin ("5 weitere zeigen") -- zwei Mechanismen
nebeneinander, die dasselbe verstecken, waeren eine Einladung zum
Missverstaendnis. Der Knopf oben rechts macht jetzt etwas anderes: alles
auf einmal ("Alle aufklappen" / "Alle zuklappen"), damit man bei vier
Rollen nicht viermal klicken muss.

Der entscheidende Unterschied zum Ausblenden: Die UEBERSCHRIFT mit der
Anzahl bleibt IMMER stehen, auch zugeklappt. Man sieht jederzeit, DASS es
einen Manager gibt -- nur nicht, welchen. Wer eine ganze Rolle spurlos
verschwinden laesst, haelt sie irgendwann fuer leer.

Und zugeklappt zaehlt genau eine Frage: Steckt da etwas Gesperrtes drin,
das ich sehen muesste? Deshalb steht am Manager auch zugeklappt
"1 gesperrt", in Warnfarbe.

Die Ueberschrift IST der Knopf, als echtes <button> -- ein eigener
kleiner Schalter daneben waere ein zweites Ziel fuer dieselbe Absicht,
und ein kleineres. Als Knopf-Element statt div mit Klick-Zuhoerer machen
Tastatur und Vorleseprogramme es ohne Zutun richtig.

Der Zustand wird JE ROLLE gemerkt, nicht als "alles auf/zu": Wer die
Creator zuklappt und die Scouts offen laesst, findet das nach dem Laden
genau so wieder.

30 Pruefungen. Darunter weiterhin der wichtigste: Zuklappen ist eine
ANSICHT, kein Recht -- Tili kann sich anmelden und normal arbeiten,
waehrend ihre Rolle zugeklappt ist, und der Server liefert unveraendert
alle sieben Personen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-31 23:17:44 +02:00

523 lines
21 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. */
const LEITUNG = new Set(['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: 'admin', name: 'DogFather', symbol: 'krone',
text: 'Alle Rechte, endgültig. Nur DogFather legt DogFather und Manager an.' },
{ wert: 'manager', name: 'Manager', symbol: 'stern',
text: 'Dieselben Rechte wie DogFather – außer der Personenverwaltung.' },
{ 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. */
for (const r of ROLLEN) {
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 || ''; };
async function hole(pfad, optionen = {}) {
const a = await fetch(pfad, { credentials: 'same-origin', ...optionen });
if (a.status === 401) { 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;
}
const datum = (iso) => (iso ? iso.slice(0, 16).replace('T', ' ') : '—');
/* ---------- Code anzeigen -------------------------------------------- */
function zeigeCode(p) {
$('code-name').textContent = `${p.name} · ${ROLLENNAME[p.rolle] || p.rolle}`;
$('code-wert').textContent = p.code;
$('code-kasten').hidden = false;
$('code-kasten').scrollIntoView({ behavior: 'smooth', block: 'center' });
}
$('code-schliessen').addEventListener('click', () => {
$('code-kasten').hidden = true;
/* 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.rolle === 'scout' && p.betreut_anzahl) {
z.append(el('span', null, `betreut ${p.betreut_anzahl} Creator`));
}
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;
const leer = document.createElement('option');
leer.value = ''; leer.textContent = 'DogFather';
w.append(leer);
for (const s2 of betreuerListe) {
const o = document.createElement('option');
o.value = String(s2.id); o.textContent = 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);
}
const knoepfe = el('div', 'person__knoepfe');
knoepfe.append(tuKnopf('Neuer Code', async () => {
const eigen = p.id === ich.id;
const frage = eigen
? `Neuer Code für dich selbst?\n\nDeine aktuelle Sitzung wird sofort beendet und du musst dich mit dem neuen Code neu anmelden.`
: `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 {
const a = await hole('/workspace/api/verwaltung/personen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: $('f-name').value, rolle: gewaehlteRolle() }),
});
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; }
$('wer').textContent = `${ich.name} · ${ich.rolle_name || ich.rolle}`;
baueRollenwahl();
await laden();
})();
})();