Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb

- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan
  oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues
  Notizfeld je Aufgabe (plan_todos.notiz)
- Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile,
  "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen
- Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang
- Glas-Darstellung, Logo als Favicon
- Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und
  Prüfskript inkl. WLAN-Wächter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-29 14:16:52 +01:00
1 parent f4fb3c550b
commit b7e0bd0d92
56 files changed
+12265 -2974

No files matched your search

+168 -90
View File
@@ -42,7 +42,7 @@
// Dass der Bereich für Nicht-Administratoren gar nicht erst auftaucht, ist
// wie überall in dieser App ein reines UI-Gate; die eigentliche Sperre sitzt
// serverseitig in requireRole('admin') auf PUT /app-settings,
// PUT /honorar-config, POST/DELETE /lph-templates, POST/DELETE /holidays,
// PUT /honorar-config, POST/PATCH/DELETE /lph-templates, POST/DELETE /holidays,
// GET /backup, POST /import und POST /delete-week.
// ADMIN_SECTIONS: die Abschnitte in der Reihenfolge der Seitenleiste. Die
@@ -52,19 +52,37 @@
// Unterpunkt in der Seitenleiste; ein neuer Abschnitt braucht damit nur einen
// Eintrag hier plus die Karte. `wide` hebt die Breitenbegrenzung des Rasters
// auf — die Benutzertabelle hat zehn Spalten und scrollte sonst quer.
// recht: welches Recht (Rollen & Rechte) den Abschnitt öffnet; bei einer
// Liste genügt eines davon.
const ADMIN_SECTIONS = [
{ key: 'benutzer', title: 'Benutzerverwaltung', wide: true },
{ key: 'honorar', title: 'Honorar' },
{ key: 'auslastung', title: 'Auslastung' },
{ key: 'termine', title: 'Termine & Sitzungen' },
{ key: 'feiertage', title: 'Feiertage' },
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen' },
{ key: 'planchecker', title: 'Plan-Checker-Merkmale' },
{ key: 'betrieb', title: 'Betrieb & Sicherheit' },
{ key: 'papierkorb', title: 'Papierkorb' },
{ key: 'datenbank', title: 'Datenbank & Sitzungen' },
{ key: 'benutzer', title: 'Benutzerverwaltung', wide: true, recht: 'a.benutzer' },
{ key: 'rollen', title: 'Rollen & Rechte', wide: true, recht: 'a.rollen' },
{ key: 'honorar', title: 'Honorar', recht: 'a.honorar' },
{ key: 'auslastung', title: 'Auslastung', recht: 'a.auslastung' },
{ key: 'termine', title: 'Termine & Sitzungen', recht: 'a.termine' },
{ key: 'feiertage', title: 'Feiertage', recht: 'a.feiertage' },
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen', recht: 'a.vorlagen' },
{ key: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' },
{ key: 'betrieb', title: 'Betrieb & Sicherheit', recht: 'a.betrieb' },
{ key: 'papierkorb', title: 'Papierkorb', recht: 'a.papierkorb' },
{ key: 'datenbank', title: 'Datenbank & Sitzungen', recht: ['a.backup', 'a.import'] },
];
// adminNavAbgleichen: blendet Unterpunkte der Seitenleiste ohne Recht aus
// (auch von renderUserBar() nach der Anmeldung gerufen).
function adminNavAbgleichen(){
ADMIN_SECTIONS.forEach(sec => {
const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]');
if(item) item.hidden = !adminAbschnittErlaubt(sec);
});
}
// adminAbschnittErlaubt: darf das Konto diesen Abschnitt sehen?
function adminAbschnittErlaubt(sec){
const r = Array.isArray(sec.recht) ? sec.recht : [sec.recht];
return r.some(x => hatRecht(x));
}
// adminSection: der gerade sichtbare Abschnitt.
let adminSection = 'benutzer';
@@ -95,13 +113,13 @@ function renderAdminArea(){
// isAdminUser: Kurzform für die Sichtbarkeit des Bereichs.
// Rückgabe: Boolean.
function isAdminUser(){
return !!(currentUser && currentUser.role === 'admin');
return hatAdminRecht();
}
// adminSectionExists: prüft, ob ein Schlüssel ein bekannter Abschnitt ist.
// Parameter: key. Rückgabe: Boolean.
function adminSectionExists(key){
return ADMIN_SECTIONS.some(x => x.key === key);
return ADMIN_SECTIONS.some(x => x.key === key && adminAbschnittErlaubt(x));
}
// showAdminSection: schaltet auf einen Abschnitt um — blendet die zugehörige
@@ -111,8 +129,9 @@ function adminSectionExists(key){
// (Klick aus einem anderen Bereich heraus), wird zuerst dorthin gewechselt.
// Parameter: key. Keine Rückgabe.
function showAdminSection(key){
if(!adminSectionExists(key)) key = ADMIN_SECTIONS[0].key;
if(!adminSectionExists(key)) key = (ADMIN_SECTIONS.find(adminAbschnittErlaubt) || ADMIN_SECTIONS[0]).key;
adminSection = key;
adminNavAbgleichen();
if(currentTab !== 'admin'){ showTab('admin'); return; }
@@ -140,6 +159,7 @@ function showAdminSection(key){
// Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso
// frisch holen.
if(key === 'papierkorb') loadAdminPapierkorb();
if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte();
try {
history.replaceState(null, '', location.pathname + location.search + '#' + key);
@@ -186,8 +206,8 @@ const APP_SETTING_FIELDS = {
// Kapazität der Person — ein volles Band ist eine volle Woche. Die feste
// Vorgabe 8 hatte zur Folge, dass ein roter Balken (ab 1,0) nur zu einem
// Achtel gefüllt war; Balken und Ampel widersprachen einander.
setReferenzHonorar: 'referenzHonorar',
setVerteilWochen: 'verteilzeitraumWochen',
// Referenzhonorar und Verteilzeitraum sind entfallen: Die Auslastung
// rechnet nur noch mit Aufgaben-Stunden (js/load/load.js).
},
termine: {
setStundenProTag: 'arbeitsstundenProTag',
@@ -213,49 +233,6 @@ function renderAppSettingFields(){
});
const autoLock = document.getElementById('setAutoLock');
if(autoLock) autoLock.checked = !!Number(APP_SETTINGS.autoLockAktiv);
renderAufwandMatrix();
}
// renderAufwandMatrix: die firmenweite Gewichtung als Tabelle Leistungsphase ×
// Rolle. „W+M" fehlt bewusst — das Aufwandsmodell kennt nur die
// Leistungsphasen (AUFWAND_LPS in js/load/load.js). Ein leeres Feld bedeutet
// „kein Eintrag"; dafür zählt die Auslastung dann den vollen Faktor 1,0.
// Keine Parameter, keine Rückgabe.
function renderAufwandMatrix(){
const wrap = document.getElementById('setAufwandMatrix');
if(!wrap) return;
const vorlage = APP_SETTINGS.aufwandVorlage || {};
const phasen = LPHS.filter(l => l !== 'W+M');
wrap.innerHTML = `<table class="settings-matrix">
<thead><tr><th>Phase</th>${ROLES.map(r => `<th>${esc(r)}</th>`).join('')}</tr></thead>
<tbody>${phasen.map(l => `<tr>
<th>${esc(l)}</th>
${ROLES.map(r => {
const v = vorlage[l] ? vorlage[l][r] : undefined;
return `<td><input type="number" min="0" max="1" step="0.1"
data-lph="${esc(l)}" data-role="${esc(r)}"
value="${v === undefined ? '' : esc(v)}" placeholder="1,0"></td>`;
}).join('')}
</tr>`).join('')}</tbody>
</table>`;
}
// collectAufwandMatrix: liest die Matrix aus den Feldern zurück. Leere Felder
// werden weggelassen (siehe renderAufwandMatrix), 0 ist ein gültiger Wert und
// bleibt erhalten. Rückgabe: Objekt LPH -> { Rolle: Faktor }.
function collectAufwandMatrix(){
const wrap = document.getElementById('setAufwandMatrix');
const out = {};
if(!wrap) return out;
wrap.querySelectorAll('input[data-lph]').forEach(inp => {
if(inp.value.trim() === '') return;
const v = parseFloat(inp.value);
if(!Number.isFinite(v)) return;
const lph = inp.dataset.lph;
(out[lph] = out[lph] || {})[inp.dataset.role] = v;
});
return out;
}
// putAppSettings: schickt einen Teil-Satz an PUT /app-settings und übernimmt
@@ -294,11 +271,9 @@ function fieldValues(gruppe){
return patch;
}
// saveAuslastungSettings: Schwellen, Skala, Vollzeit-Stunden und die
// Aufwandsvorlage in einem Zug. Keine Parameter, keine Rückgabe.
// saveAuslastungSettings: Schwellen und Vollzeit-Stunden in einem Zug. Keine Parameter, keine Rückgabe.
function saveAuslastungSettings(){
const patch = fieldValues('auslastung');
patch.aufwandVorlage = collectAufwandMatrix();
putAppSettings(patch, 'Auslastungs-Einstellungen gespeichert.');
}
@@ -365,7 +340,7 @@ function renderAdminSitzungen(){
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.id)}')">Fertig – wieder sperren</button>`;
} else if(w.locked){
chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>';
aktionen = `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.id)}')">Entsperren</button>`;
aktionen = hatRecht('s.entsperren') ? `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.id)}')">Entsperren</button>` : '';
} else {
chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>';
aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.id)}')">Öffnen</button>`;
@@ -375,7 +350,7 @@ function renderAdminSitzungen(){
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
${chip}
${aktionen}
<button class="danger small" onclick="adminDeleteWeek('${esc(w.id)}')" title="Sitzung löschen">×</button>
${hatRecht('s.loeschen') ? `<button class="danger small" onclick="adminDeleteWeek('${esc(w.id)}')" title="Sitzung löschen">×</button>` : ''}
</div>`;
}).join('');
}
@@ -609,17 +584,31 @@ function deleteHoliday(date){
}
// ---------- Abschnitt Aufgaben-Vorlagen ----------
//
// Vorlagen gibt es je Gewerk und Leistungsphase (Eriks Wunsch). Oben wählt
// man das Gewerk (Umschalter) und die Leistungsphase, darunter steht die
// Liste der Schritte: Bezeichnung, Start und Dauer direkt bearbeitbar
// (gespeichert beim Verlassen des Feldes, PATCH /lph-templates/:id),
// Reihenfolge über ↑/↓ (POST …/verschieben), Löschen über ×.
// Vorlagen ohne Gewerk (Altbestand) stehen unter jedem Gewerk mit dem
// Vermerk „alle Gewerke“.
let adminTemplates = [];
let adminTemplateGewerk = null;
// loadAdminTemplates: holt alle Vorlagen-Schritte (GET /lph-templates) und
// rendert die Liste der gerade gewählten Leistungsphase.
// Keine Parameter, keine Rückgabe.
// rendert die Liste. Keine Parameter, keine Rückgabe.
function loadAdminTemplates(){
const select = document.getElementById('adminTemplateLph');
if(select && !select.options.length){
select.innerHTML = LPHS.map(l => `<option value="${esc(l)}">${esc(l)}</option>`).join('');
}
if(!adminTemplateGewerk){
const eigen = currentUser && Object.keys(DEPARTMENTS).includes(currentUser.department) ? currentUser.department : '';
adminTemplateGewerk = eigen || Object.keys(DEPARTMENTS)[0];
}
const listEl = document.getElementById('templateAdminList');
if(listEl) listEl.innerHTML = '<span class="hint">Lade Vorlagen…</span>';
if(listEl && !adminTemplates.length) listEl.innerHTML = '<span class="hint">Lade Vorlagen…</span>';
fetch(baseUrl() + '/lph-templates')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
@@ -633,33 +622,74 @@ function loadAdminTemplates(){
});
}
// renderAdminTemplates: zeigt die Schritte der gewählten Leistungsphase in
// ihrer gepflegten Reihenfolge, je Zeile mit Versatz/Dauer und Lösch-Knopf.
// Wird auch vom onchange der Leistungsphasen-Auswahl gerufen.
// Keine Parameter, keine Rückgabe.
function renderAdminTemplates(){
const listEl = document.getElementById('templateAdminList');
if(!listEl) return;
const select = document.getElementById('adminTemplateLph');
const lph = select ? select.value : '';
const entries = adminTemplates.filter(t => t.lph === lph);
listEl.innerHTML = entries.length === 0
? '<span class="hint">Für diese Leistungsphase ist noch kein Schritt hinterlegt.</span>'
: entries.map(t => `<div class="template-admin-row">
<span class="template-admin-title">${esc(t.title)}</span>
<span class="hint" style="margin:0">Start +${esc(t.offsetDays)} Tage · Dauer ${esc(t.durationDays)} Tage</span>
<button class="danger small" onclick="deleteTemplateEntry('${esc(t.id)}')" title="Schritt aus der Vorlage löschen">×</button>
</div>`).join('');
// vorlagenAnteilText: Anzeige der Spalte „Anteil %“ — Zahl mit Komma oder
// „in a)“ / „in a) + b)“ (Siemon: in anderen Schritten enthalten).
function vorlagenAnteilText(t){
if(t.enthaltenIn) return 'in ' + t.enthaltenIn.split(',').map(x => x + ')').join(' + ');
return t.anteil ? String(t.anteil).replace('.', ',') : '';
}
// addTemplateEntry: legt einen Schritt für die gewählte Leistungsphase an
// (POST /lph-templates, Admin-only). Keine Parameter, keine Rückgabe.
// waehleAdminTemplateGewerk: Umschalter Gewerk. Parameter: g. Keine Rückgabe.
function waehleAdminTemplateGewerk(g){
adminTemplateGewerk = g;
renderAdminTemplates();
}
// renderAdminTemplates: Gewerk-Umschalter (mit Anzahl je Gewerk), die
// Anzahl je Leistungsphase in der Auswahl und die Schritte der gewählten
// Kombination. Keine Parameter, keine Rückgabe.
function renderAdminTemplates(){
const g = adminTemplateGewerk;
const gewEl = document.getElementById('adminTemplateGewerk');
if(gewEl){
gewEl.innerHTML = Object.keys(DEPARTMENTS).map(d => {
const n = adminTemplates.filter(t => t.gewerk === d).length;
return `<button type="button" class="vorlagen-gewerk${d === g ? ' is-active' : ''}" aria-pressed="${d === g}"
title="${esc(DEPARTMENTS[d].label)}" onclick="waehleAdminTemplateGewerk('${esc(d)}')">${esc(d)}<span>${n}</span></button>`;
}).join('');
}
const select = document.getElementById('adminTemplateLph');
const lph = select ? select.value : '';
if(select){
Array.from(select.options).forEach(o => {
const n = adminTemplates.filter(t => t.lph === o.value && t.gewerk === g).length;
o.textContent = o.value + (n ? ' (' + n + ')' : '');
});
}
const listEl = document.getElementById('templateAdminList');
if(!listEl) return;
const eigene = adminTemplates.filter(t => t.lph === lph && t.gewerk === g);
const alle = adminTemplates.filter(t => t.lph === lph && !t.gewerk);
const zeile = (t, i, n) => `<div class="template-admin-row vorlagen-zeile">
<input type="text" value="${esc(t.title)}" aria-label="Bezeichnung" onchange="aendereTemplateEntry('${esc(t.id)}', 'title', this.value)">
<input type="text" value="${esc(vorlagenAnteilText(t))}" placeholder="–" aria-label="Anteil in Prozent oder „in a)“" title="Zahl in Prozent oder „in a)“ = zeitgleich mit Schritt a)" onchange="aendereTemplateEntry('${esc(t.id)}', 'anteil', this.value)">
<input type="number" min="0" value="${t.durationDays ? esc(t.offsetDays) : ''}" placeholder="–" aria-label="Start in Tagen" onchange="aendereTemplateEntry('${esc(t.id)}', 'offsetDays', this.value === '' ? 0 : this.value)">
<input type="number" min="0" value="${t.durationDays ? esc(t.durationDays) : ''}" placeholder="ohne" aria-label="Dauer in Tagen, leer = ohne Termin" onchange="aendereTemplateEntry('${esc(t.id)}', 'durationDays', this.value === '' ? 0 : this.value)">
<span class="vorlagen-aktionen">
${n !== null ? `<button class="secondary small" title="nach oben" ${i === 0 ? 'disabled' : ''} onclick="verschiebeTemplateEntry('${esc(t.id)}', -1)">↑</button>
<button class="secondary small" title="nach unten" ${i === n - 1 ? 'disabled' : ''} onclick="verschiebeTemplateEntry('${esc(t.id)}', 1)">↓</button>` : '<span class="hint" style="margin:0">alle Gewerke</span>'}
<button class="danger small" onclick="deleteTemplateEntry('${esc(t.id)}')" title="Schritt aus der Vorlage löschen">×</button>
</span>
</div>`;
// Summe der Anteile: Beim Einfügen zählt das Verhältnis (der Anteil eines
// Schritts an der Summe der Phase) — die Summe ist nur zur Kontrolle da.
const summe = Math.round([...eigene, ...alle].reduce((a, t) => a + (Number(t.anteil) || 0), 0) * 100) / 100;
listEl.innerHTML = (eigene.length || alle.length)
? eigene.map((t, i) => zeile(t, i, eigene.length)).join('') + alle.map(t => zeile(t, 0, null)).join('')
+ `<div class="vorlagen-summe">Summe der Anteile in ${esc(lph)}: <b>${summe ? String(summe).replace('.', ',') + ' %' : 'keine hinterlegt'}</b>${summe ? '' : ' — Schritte ohne Anteil werden mit „Start/Dauer“ bzw. ohne Termin eingefügt.'}</div>`
: `<span class="hint">Für ${esc(g)} in ${esc(lph)} ist noch kein Schritt hinterlegt.</span>`;
}
// addTemplateEntry: legt einen Schritt für das gewählte Gewerk und die
// gewählte Leistungsphase an (POST /lph-templates). Keine Parameter.
function addTemplateEntry(){
const select = document.getElementById('adminTemplateLph');
const titleEl = document.getElementById('templateNewTitle');
const offsetEl = document.getElementById('templateNewOffset');
const durationEl = document.getElementById('templateNewDuration');
const anteilEl = document.getElementById('templateNewAnteil');
const title = titleEl ? titleEl.value.trim() : '';
if(!title){ alert('Bitte eine Bezeichnung für den Schritt eingeben.'); return; }
@@ -668,15 +698,19 @@ function addTemplateEntry(){
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({
lph: select ? select.value : '',
gewerk: adminTemplateGewerk,
title,
offsetDays: offsetEl ? parseInt(offsetEl.value, 10) || 0 : 0,
durationDays: durationEl ? parseInt(durationEl.value, 10) || 1 : 1,
// leer oder 0 = ohne Termin
durationDays: durationEl ? parseInt(durationEl.value, 10) || 0 : 0,
anteil: anteilEl ? anteilEl.value : '',
})
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.');
if(titleEl) titleEl.value = '';
if(titleEl){ titleEl.value = ''; titleEl.focus(); }
if(anteilEl) anteilEl.value = '';
loadAdminTemplates();
showAdminNote('Schritt angelegt.');
})
@@ -686,6 +720,50 @@ function addTemplateEntry(){
});
}
// aendereTemplateEntry: speichert ein geändertes Feld eines Schritts
// (PATCH /lph-templates/:id). Parameter: id, feld, wert. Keine Rückgabe.
function aendereTemplateEntry(id, feld, wert){
const body = {};
body[feld] = (feld === 'title' || feld === 'anteil') ? wert : parseInt(wert, 10);
fetch(baseUrl() + '/lph-templates/' + encodeURIComponent(id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(body)
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.');
const t = adminTemplates.find(x => x.id === id);
if(t) Object.assign(t, json.template);
renderAdminTemplates();
showAdminNote('Gespeichert.');
})
.catch(err => {
console.error('Vorlagen-Schritt ändern fehlgeschlagen:', err);
alert('Änderung konnte nicht gespeichert werden: ' + (err.message || err));
loadAdminTemplates();
});
}
// verschiebeTemplateEntry: Schritt eine Stelle nach oben/unten.
// Parameter: id, richtung (-1/1). Keine Rückgabe.
function verschiebeTemplateEntry(id, richtung){
fetch(baseUrl() + '/lph-templates/' + encodeURIComponent(id) + '/verschieben', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ richtung })
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Verschieben fehlgeschlagen.');
loadAdminTemplates();
})
.catch(err => {
console.error('Vorlagen-Schritt verschieben fehlgeschlagen:', err);
alert('Schritt konnte nicht verschoben werden: ' + (err.message || err));
});
}
// deleteTemplateEntry: löscht einen Vorlagen-Schritt (DELETE, Admin-only).
// Bereits in Projekte eingefügte Aufgaben bleiben unberührt — sie sind nach
// dem Einfügen ganz normale plan_todos ohne Verbindung zur Vorlage.
@@ -706,7 +784,7 @@ function deleteTemplateEntry(id){
// ---------- Abschnitt Plan-Checker-Merkmale ----------
//
// Dieselbe add/delete-only Liste wie die Aufgaben-Vorlagen oben, nur ohne
// Eine add/delete-only Liste wie früher die Aufgaben-Vorlagen oben, ohne
// Gruppierung (die Merkmale gelten unterschiedslos für jede Prüfung) — siehe
// plan_checker_merkmale/GET+POST+DELETE /plan-checker-merkmale, server.js.
// Die Liste selbst nutzt auch der Plan Checker (js/planchecker/planchecker.js),
+209
View File
@@ -0,0 +1,209 @@
// -----------------------------
// Admin — Rollen & Rechte
// -----------------------------
// Eriks Wahl „B“ aus dem Entwurfsvergleich: links die Rollen (Anzahl Konten,
// Anteil der Rechte), darunter „+ Neue Rolle“; rechts die gewählte Rolle mit
// einem Schalter je Recht, nach Gruppen, und „alle/keine“ je Gruppe. Über
// „Vergleich mit“ steht neben jedem Schalter, ob eine zweite Rolle das Recht
// hat (Abweichungen hervorgehoben).
//
// Daten und Katalog kommen vom Server (GET /rollen, server.js „Rollen &
// Rechte: Routen“); jede Änderung geht sofort hin (PUT /rollen/:key/rechte,
// POST/PATCH/DELETE /rollen) und die Antwort ersetzt den Stand hier. Kein
// eigenes Speichern. Ändert jemand seine eigene Rolle, lädt sich /whoami neu,
// damit Seitenleiste und Knöpfe sofort stimmen.
let rrDaten = null; // { katalog:[{gruppe, rechte:[{key,name,hinweis}]}], rollen:[…] }
let rrAuswahl = 'member'; // gewählte Rolle
let rrVergleich = ''; // Vergleichsrolle ('' = keiner)
let rrNeuOffen = false; // Formular „+ Neue Rolle“ aufgeklappt
let rrMeldung = '';
let rrMeldungTimer = null;
// renderRollenRechte: Einstieg beim Öffnen des Abschnitts (showAdminSection).
function renderRollenRechte(){
const el = document.getElementById('rollenRechte');
if(!el) return;
if(!rrDaten) el.innerHTML = '<span class="hint">Lade Rollen…</span>';
fetch(baseUrl() + '/rollen')
.then(r => r.json())
.then(j => { if(!j.ok) throw new Error(j.error || 'Laden fehlgeschlagen'); rrUebernehmen(j); })
.catch(err => { el.innerHTML = `<span class="hint">Rollen konnten nicht geladen werden: ${esc(err.message || err)}</span>`; });
}
// rrUebernehmen: neue Serverantwort übernehmen und neu zeichnen.
function rrUebernehmen(j){
rrDaten = j;
if(!j.rollen.some(r => r.key === rrAuswahl)) rrAuswahl = (j.rollen.find(r => !r.fest) || j.rollen[0] || {}).key;
if(rrVergleich && !j.rollen.some(r => r.key === rrVergleich)) rrVergleich = '';
rrZeichnen();
}
function rrMelden(text){
rrMeldung = text;
const el = document.getElementById('rrMeldung');
if(el) el.textContent = text;
clearTimeout(rrMeldungTimer);
rrMeldungTimer = setTimeout(() => { rrMeldung = ''; const e = document.getElementById('rrMeldung'); if(e) e.textContent = ''; }, 2500);
}
// rrSenden: gemeinsamer Weg aller Änderungen. Parameter: methode, pfad,
// body, okText. Rückgabe: Promise der Serverantwort (oder null bei Fehler).
function rrSenden(methode, pfad, body, okText){
return fetch(baseUrl() + pfad, {
method: methode,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: body ? JSON.stringify(body) : undefined
})
.then(r => r.json().then(j => ({ ok: r.ok, j })))
.then(({ ok, j }) => {
if(!ok || !j.ok) throw new Error((j && j.error) || 'Fehlgeschlagen');
rrUebernehmen(j);
if(okText) rrMelden(okText);
rrEigeneRechteAuffrischen();
return j;
})
.catch(err => { rrMelden(err.message || String(err)); rrZeichnen(); return null; });
}
// rrEigeneRechteAuffrischen: betrifft eine Änderung die eigene Rolle, holt
// /whoami die neuen Rechte, damit Seitenleiste und Knöpfe gleich stimmen.
function rrEigeneRechteAuffrischen(){
fetch(baseUrl() + '/whoami').then(r => r.json()).then(j => {
if(j && j.user && currentUser && j.user.id === currentUser.id){
currentUser = j.user;
if(typeof renderUserBar === 'function') renderUserBar();
}
}).catch(() => {});
}
const rrRolle = key => rrDaten && rrDaten.rollen.find(r => r.key === key);
const rrAlleRechte = () => rrDaten.katalog.flatMap(g => g.rechte.map(r => r.key));
// Aktionen (aus den Inline-Handlern)
function rrWaehlen(key){ rrAuswahl = key; rrLoeschenBestaetigen = ''; rrZeichnen(); }
function rrVergleichSetzen(key){ rrVergleich = key; rrZeichnen(); }
function rrNeuUmschalten(offen){
rrNeuOffen = offen; rrZeichnen();
if(offen){ const i = document.getElementById('rrNeuName'); if(i) i.focus(); }
}
function rrRechtSchalten(key, recht){
const r = rrRolle(key); if(!r || r.fest) return;
const an = !r.rechte.includes(recht);
rrSenden('PUT', '/rollen/' + encodeURIComponent(key) + '/rechte', { rechte: [recht], an }, 'Gespeichert');
}
function rrGruppeSchalten(key, gi){
const r = rrRolle(key); if(!r || r.fest) return;
const keys = rrDaten.katalog[gi].rechte.map(x => x.key);
const an = !keys.every(k => r.rechte.includes(k));
rrSenden('PUT', '/rollen/' + encodeURIComponent(key) + '/rechte', { rechte: keys, an }, 'Gespeichert');
}
function rrAnlegen(){
const name = (document.getElementById('rrNeuName') || {}).value || '';
const von = (document.getElementById('rrNeuVon') || {}).value || '';
if(!name.trim()){ rrMelden('Bitte einen Namen eingeben.'); return; }
rrSenden('POST', '/rollen', { name: name.trim(), vonRolle: von }, 'Rolle „' + name.trim() + '“ angelegt.')
.then(j => { if(j && j.neu){ rrAuswahl = j.neu; rrNeuOffen = false; rrZeichnen(); } });
}
function rrUmbenennen(key, name){
const r = rrRolle(key); if(!r || r.fest || !name.trim() || name.trim() === r.name) return;
rrSenden('PATCH', '/rollen/' + encodeURIComponent(key), { name: name.trim() }, 'Umbenannt.');
}
function rrKopieren(key){
const r = rrRolle(key); if(!r) return;
let name = r.name + ' (Kopie)', i = 2;
while(rrDaten.rollen.some(x => x.name.toLowerCase() === name.toLowerCase())) name = r.name + ' (Kopie ' + (i++) + ')';
rrSenden('POST', '/rollen', { name, vonRolle: key }, 'Kopie angelegt.')
.then(j => { if(j && j.neu){ rrAuswahl = j.neu; rrZeichnen(); } });
}
// rrLoeschen: zweistufig im Knopf selbst (kein confirm()) — erster Klick
// fragt nach, zweiter löscht.
let rrLoeschenBestaetigen = '';
function rrLoeschen(key){
if(rrLoeschenBestaetigen !== key){ rrLoeschenBestaetigen = key; rrZeichnen(); return; }
rrLoeschenBestaetigen = '';
rrSenden('DELETE', '/rollen/' + encodeURIComponent(key), null, 'Rolle gelöscht.');
}
// rrZeichnen: baut die ganze Maske. Fokus und Eingaben im Formular „Neue
// Rolle“ bleiben erhalten.
function rrZeichnen(){
const el = document.getElementById('rollenRechte');
if(!el || !rrDaten) return;
const fokusId = document.activeElement && document.activeElement.id;
const neuName = (document.getElementById('rrNeuName') || {}).value || '';
const alle = rrAlleRechte();
const r = rrRolle(rrAuswahl) || rrDaten.rollen[0];
const v = rrVergleich && rrVergleich !== r.key ? rrRolle(rrVergleich) : null;
const rechteVon = x => x.fest ? alle : x.rechte;
const liste = rrDaten.rollen.map(x => {
const n = rechteVon(x).length;
return `<button type="button" class="rr-rolle${x.key === r.key ? ' ist-aktiv' : ''}" onclick="rrWaehlen('${esc(x.key)}')">
<b>${esc(x.name)}</b>
<small>${x.konten} ${x.konten === 1 ? 'Konto' : 'Konten'} · ${n} von ${alle.length} Rechten${x.fest ? ' · fest' : ''}</small>
<span class="rr-balken"><i style="width:${(n / alle.length * 100).toFixed(1)}%"></i></span>
</button>`;
}).join('');
const neu = rrNeuOffen
? `<div class="rr-neu">
<input id="rrNeuName" type="text" placeholder="Name, z. B. Werkstudent" aria-label="Name der neuen Rolle"
onkeydown="if(event.key==='Enter') rrAnlegen(); if(event.key==='Escape') rrNeuUmschalten(false);">
<label>Rechte übernehmen von
<select id="rrNeuVon"><option value="">keiner (leer)</option>${rrDaten.rollen.map(x => `<option value="${esc(x.key)}">${esc(x.name)}</option>`).join('')}</select>
</label>
<div class="rr-neu-knoepfe">
<button type="button" class="small" onclick="rrAnlegen()">Anlegen</button>
<button type="button" class="secondary small" onclick="rrNeuUmschalten(false)">Abbrechen</button>
</div>
</div>`
: `<button type="button" class="rr-plus" onclick="rrNeuUmschalten(true)">+ Neue Rolle</button>`;
const gruppen = rrDaten.katalog.map((g, gi) => {
const rechteR = rechteVon(r);
const n = g.rechte.filter(x => rechteR.includes(x.key)).length;
const zeilen = g.rechte.map(x => {
const an = rechteR.includes(x.key);
const vAn = v ? rechteVon(v).includes(x.key) : null;
return `<div class="rr-zeile">
<button type="button" class="rr-schalter${an ? ' ist-an' : ''}" role="switch" aria-checked="${an}" aria-label="${esc(x.name)}"
${r.fest ? 'disabled' : `onclick="rrRechtSchalten('${esc(r.key)}','${esc(x.key)}')"`}></button>
<span class="rr-name">${esc(x.name)}${x.hinweis ? `<small>${esc(x.hinweis)}</small>` : ''}</span>
${v ? `<span class="rr-vergleich${vAn !== an ? ' ist-anders' : ''}" title="${esc(v.name)}">${vAn ? 'ja' : 'nein'}</span>` : ''}
</div>`;
}).join('');
return `<div class="rr-gruppe">
<div class="rr-gruppe-kopf"><b>${esc(g.gruppe)}</b><small>${n} von ${g.rechte.length}</small><span class="rr-luecke"></span>
${r.fest ? '' : `<button type="button" class="secondary small" onclick="rrGruppeSchalten('${esc(r.key)}', ${gi})">${n === g.rechte.length ? 'keine' : 'alle'}</button>`}
</div>
${zeilen}
</div>`;
}).join('');
const loeschText = rrLoeschenBestaetigen === r.key ? 'Wirklich löschen?' : 'Löschen';
el.innerHTML = `<div class="rr-layout">
<div class="rr-rollen">${liste}${neu}</div>
<div class="rr-detail">
<div class="rr-detail-kopf">
<input id="rrName" class="rr-titel" value="${esc(r.name)}" aria-label="Name der Rolle" ${r.fest ? 'disabled' : ''}
onchange="rrUmbenennen('${esc(r.key)}', this.value)" onkeydown="if(event.key==='Enter') this.blur()">
<label class="rr-vergleich-wahl">Vergleich mit
<select id="rrVergleich" onchange="rrVergleichSetzen(this.value)">
<option value="">keiner</option>
${rrDaten.rollen.filter(x => x.key !== r.key).map(x => `<option value="${esc(x.key)}" ${v && x.key === v.key ? 'selected' : ''}>${esc(x.name)}</option>`).join('')}
</select>
</label>
${r.fest ? '<span class="hint" style="margin:0">Administrator hat immer alle Rechte.</span>'
: `<button type="button" class="secondary small" onclick="rrKopieren('${esc(r.key)}')">Kopieren</button>
<button type="button" class="danger small" onclick="rrLoeschen('${esc(r.key)}')">${loeschText}</button>`}
<span class="rr-meldung" id="rrMeldung" role="status">${esc(rrMeldung)}</span>
</div>
<div class="rr-gruppen">${gruppen}</div>
</div>
</div>`;
const ni = document.getElementById('rrNeuName');
if(ni && neuName) ni.value = neuName;
if(fokusId){ const f = document.getElementById(fokusId); if(f) f.focus(); }
}