/* =================================================================== 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.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; /* 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. */ o.textContent = s2.rolle && s2.rolle !== 'scout' ? `${s2.name} (${ROLLENNAME[s2.rolle] || s2.rolle})` : 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