Files
projektuebersicht/public/js/admin-users.js
T
erikandClaude Opus 5.5 b7e0bd0d92 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>
2026-10-29 14:16:52 +01:00

768 lines
38 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.
// -----------------------------
// Benutzerverwaltung (Abschnitt „Benutzerverwaltung" des Bereichs #admin)
// -----------------------------
// Laedt und rendert die Benutzerliste ueber die admin-only Endpunkte aus
// server.js (GET/POST/PATCH/DELETE /admin/users, POST
// /admin/users/:id/reset-password) und stellt die Dialoge bereit: neuen
// Benutzer anlegen, bestehenden bearbeiten (inkl. Wochenstunden und
// Abwesenheiten), Passwort zuruecksetzen, aktivieren/deaktivieren und
// endgueltig loeschen.
//
// Das war bis zum Umbau eine EIGENE SEITE (public/admin-users.html) mit
// eigenem Seiteneinstieg, eigener Anmeldepruefung und einer nachgebauten,
// abgespeckten Kopfzeile statt der Seitenleiste. Jetzt ist es ein Abschnitt
// des Bereichs „Admin" wie jeder andere: renderAdminUsers() wird von
// showAdminSection() gerufen, sobald der Abschnitt sichtbar wird, und die
// Seitenleiste bleibt stehen. Eine eigene Anmeldepruefung braucht es hier
// nicht mehr — der Bereich ist ohnehin nur fuer Administratoren erreichbar
// (showTab() in js/tabs-auth.js), und die eigentliche Sicherheitsgrenze sind
// die requireRole('admin')-Endpunkte auf dem Server.
(function(){
// usersCache: zuletzt vom Server geladene Benutzerliste (für die Modale,
// damit sie nicht erneut nachgeladen werden muss).
let usersCache = [];
// editingUserId: id des im Bearbeiten-Modal geöffneten Benutzers.
let editingUserId = null;
// personIdManuallyEdited: verhindert, dass eine manuelle Eingabe im
// Person-ID-Feld des Neu-Anlegen-Modals von der Auto-Vorschau überschrieben wird.
let personIdManuallyEdited = false;
// fmtCapacity: Wochenstunden in deutscher Schreibweise, ohne überflüssige
// Nachkommastelle (40 statt 40,0 — aber 32,5 bleibt 32,5).
// Parameter: h (Zahl). Rückgabe: String.
function fmtCapacity(h){
const n = Number(h) || 0;
return (Number.isInteger(n) ? String(n) : n.toFixed(1).replace('.', ',')) + ' h';
}
// initialsManuallyEdited: gleiches Muster für das Kürzel-Feld.
let initialsManuallyEdited = false;
// emailManuallyEdited: wie oben, für den E-Mail-Vorschlag (emailVorschlag()).
let emailManuallyEdited = false;
// usernameManuallyEdited: wie oben, für den Benutzernamen ("Vorname Nachname").
let usernameManuallyEdited = false;
// editUserAbsencesCache: zuletzt geladene Abwesenheiten des im
// Bearbeiten-Modal geöffneten Benutzers (für Rendern/Löschen ohne erneuten
// Request pro Aktion).
let editUserAbsencesCache = [];
// renderDepartmentOptions: befüllt ein <select> mit einer <option> pro
// Eintrag in DEPARTMENTS (js/globals.js) — bewusst ohne Leer-Option, die
// Abteilung ist ein Pflichtfeld.
function renderDepartmentOptions(selectId){
const sel = document.getElementById(selectId);
if(!sel) return;
sel.innerHTML = Object.keys(DEPARTMENTS).map(key =>
`<option value="${esc(key)}">${esc(DEPARTMENTS[key].label)}</option>`).join('');
}
// renderRoleCheckboxes: befüllt einen Container mit einer Checkbox pro
// Eintrag in ROLES (js/globals.js) — die fachlichen Rollen
// (Planung/Projektleitung/Bauüberwachung), nicht zu verwechseln mit der
// Systemrolle (Mitglied/Administrator) im "Rolle"-Feld.
function renderRoleCheckboxes(containerId){
const el = document.getElementById(containerId);
if(!el) return;
el.innerHTML = ROLES.map(r =>
`<label><input type="checkbox" value="${esc(r)}"> ${esc(r)}</label>`).join('');
}
// checkedRoleValues: liest die angehakten Fachrollen-Checkboxen eines
// Containers als String-Array aus.
function checkedRoleValues(containerId){
return Array.from(document.querySelectorAll('#' + containerId + ' input[type="checkbox"]:checked')).map(cb => cb.value);
}
// systemRollen: die Rollen aus „Rollen & Rechte“ (GET /rollen) für die
// Auswahl „Rolle“ und die Anzeige in der Tabelle.
let systemRollen = [];
function ladeSystemRollen(){
fetch(baseUrl() + '/rollen')
.then(r => r.json())
.then(j => {
if(!j || !j.ok) return;
systemRollen = j.rollen || [];
['newUserRole', 'editUserRole'].forEach(id => {
const sel = document.getElementById(id);
if(!sel) return;
const wert = sel.value;
sel.innerHTML = systemRollen.map(r => `<option value="${esc(r.key)}">${esc(r.name)}${nurWandtafelRolle(r.key) ? ' (nur Wandtafel)' : ''}</option>`).join('');
if(wert && systemRollen.some(r => r.key === wert)) sel.value = wert;
});
loadAndRenderUsers();
})
.catch(err => console.error('Rollen laden fehlgeschlagen:', err));
}
// nurWandtafelRolle: Rolle ohne „Anwendung nutzen“ — sieht nur die
// Wandtafel und trägt keine Fachrolle (früher das Kiosk-Konto).
function nurWandtafelRolle(key){
const r = systemRollen.find(x => x.key === key);
return !!r && !r.fest && !(r.rechte || []).includes('app');
}
function rollenName(key){
const r = systemRollen.find(x => x.key === key);
return r ? r.name : systemRolleText(key);
}
// onUserRoleChange: wird beim Wechsel der Systemrolle in einem der beiden
// Formulare aufgerufen (which = 'new'|'edit'). Ein Kiosk-Konto kann keine
// Fachrolle haben (server.js weist das sonst mit 400 zurück) — die
// Checkboxen werden deshalb schon hier abgehakt entfernt und gesperrt,
// statt den Fehler erst nach dem Absenden zu zeigen.
window.onUserRoleChange = function(which){
const istKiosk = nurWandtafelRolle(document.getElementById(which + 'UserRole').value);
const hint = document.getElementById(which + 'UserRoleHint');
if(hint) hint.style.display = istKiosk ? '' : 'none';
document.querySelectorAll('#' + which + 'UserRoleCheckboxes input[type="checkbox"]').forEach(cb => {
cb.disabled = istKiosk;
if(istKiosk) cb.checked = false;
});
};
// formulareVorbereitet: Abteilungs-Auswahl und Rollen-Ankreuzfelder muessen
// nur einmal aufgebaut werden; renderAdminUsers() laeuft aber bei jedem
// Wechsel in den Abschnitt.
let formulareVorbereitet = false;
// renderAdminUsers: Einstiegspunkt des Abschnitts, von showAdminSection()
// gerufen. Baut beim ersten Mal die Auswahlfelder der Dialoge auf und laedt
// danach die Benutzerliste. Eine Rollenpruefung findet hier nicht mehr
// statt — der Bereich „Admin" ist fuer Nicht-Administratoren gar nicht
// erreichbar (showTab() leitet sie um) und die Endpunkte sind serverseitig
// abgesichert. Keine Parameter, keine Rueckgabe.
window.renderAdminUsers = function(){
ladeSystemRollen();
if(!formulareVorbereitet){
renderDepartmentOptions('newUserDepartment');
renderDepartmentOptions('editUserDepartment');
renderRoleCheckboxes('newUserRoleCheckboxes');
renderRoleCheckboxes('editUserRoleCheckboxes');
formulareVorbereitet = true;
}
loadAndRenderUsers();
};
// apiFetch: kleiner fetch-Wrapper für die /admin/users*-Endpunkte. Liefert
// immer das geparste JSON zurück (auch bei Fehlerstatus), damit die
// Aufrufer serverseitige Fehlermeldungen ({ok:false, error}) anzeigen können.
function apiFetch(path, options){
return fetch(baseUrl() + path, Object.assign({
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}, options || {})).then(r => r.json().then(data => ({ status: r.status, ok: r.ok, data })));
}
// loadAndRenderUsers: lädt die Benutzerliste und rendert die Tabelle.
function loadAndRenderUsers(){
apiFetch('/admin/users').then(res => {
if(!res.ok || !res.data || !res.data.ok){
document.getElementById('usersTableBody').innerHTML =
'<tr><td colspan="9">Fehler beim Laden: ' + esc((res.data && res.data.error) || ('HTTP ' + res.status)) + '</td></tr>';
return;
}
usersCache = res.data.users || [];
renderUsersTable();
}).catch(err => {
document.getElementById('usersTableBody').innerHTML =
'<tr><td colspan="9">Verbindung zum Server fehlgeschlagen: ' + esc(err.message) + '</td></tr>';
});
}
// renderUsersTable: baut die Tabellenzeilen aus usersCache auf.
function renderUsersTable(){
const body = document.getElementById('usersTableBody');
if(usersCache.length === 0){
body.innerHTML = '<tr><td colspan="9">Keine Benutzer vorhanden.</td></tr>';
return;
}
body.innerHTML = usersCache.map(u => {
const statusParts = [];
statusParts.push(u.active ? '<span class="badge-active">Aktiv</span>' : '<span class="badge-inactive">Deaktiviert</span>');
if(u.mustChangePassword) statusParts.push('<span class="badge-warn">Passwortwechsel erforderlich</span>');
const deptLabel = (DEPARTMENTS[u.department] && DEPARTMENTS[u.department].label) || esc(u.department || '');
return `<tr>
<td>${esc(u.username)}</td>
<td>${esc(u.displayName)}</td>
<td>${esc(u.email || '')}</td>
<td>${esc(rollenName(u.role))}</td>
<td>${deptLabel}</td>
<td>${esc(u.initials || '')}</td>
<td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td>
<td>${esc(u.personId || '')}</td>
<td>${statusParts.join('<br>')}</td>
<td class="actions-cell">
<span class="user-aktionen">
<button class="secondary small" onclick="openEditUserModal(${u.id})">Bearbeiten</button>
<button type="button" class="user-kebab" aria-haspopup="menu" aria-expanded="false"
aria-label="Weitere Aktionen für ${esc(u.displayName || u.username)}"
onclick="openUserActionMenu(event, ${u.id})">${PUNKTE_SVG}</button>
</span>
</td>
</tr>`;
}).join('');
}
// escJs: escaped einen String für die Einbettung in ein einfach gequotetes
// JS-Literal innerhalb eines onclick-Attributs. Das doppelte
// Anführungszeichen MUSS mit maskiert werden: Der Handler steht in einem
// doppelt gequoteten HTML-Attribut, und ein Benutzername wie
// `test" onmouseover="…` brach es sonst auf. Dasselbe gilt für spitze
// Klammern, die das Attribut vorzeitig beenden könnten.
function escJs(s){
return String(s || '')
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// showGeneratedPassword: zeigt ein neu generiertes Passwort einmalig und gut
// sichtbar in einer Box oben auf der Seite an (zusätzlich zum alert(), da es
// sonst nirgends gespeichert/angezeigt wird).
function showGeneratedPassword(username, password){
const box = document.getElementById('generatedPasswordBox');
box.innerHTML = 'Neues Passwort für <b>' + esc(username) + '</b>: <b>' + esc(password) + '</b> ' +
'(bitte jetzt notieren/weitergeben — wird nicht erneut angezeigt).';
box.style.display = '';
alert('Neues Passwort für ' + username + ':\n\n' + password + '\n\nBitte jetzt notieren, es wird nicht erneut angezeigt.');
}
// ---------- Neuer-Benutzer-Modal ----------
window.openNewUserModal = function(){
document.getElementById('newUserUsername').value = '';
document.getElementById('newUserFirstName').value = '';
document.getElementById('newUserLastName').value = '';
document.getElementById('newUserEmail').value = '';
document.getElementById('newUserRole').value = systemRollen.some(r => r.key === 'member') ? 'member' : ((systemRollen.find(r => !r.fest) || {}).key || '');
onUserRoleChange('new');
document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT;
document.getElementById('newUserInitials').value = '';
document.getElementById('newUserPersonId').value = '';
// Sonst übernahm das nächste Konto unbemerkt die Wochenstunden des
// zuvor abgebrochenen Dialogs (Teilzeit ohne Absicht).
document.getElementById('newUserCapacity').value = '';
document.querySelectorAll('#newUserRoleCheckboxes input[type="checkbox"]').forEach(cb => { cb.checked = false; });
document.getElementById('newUserPassword').value = '';
document.getElementById('newUserPassword').style.display = 'none';
document.querySelector('input[name="newUserPwMode"][value="generate"]').checked = true;
document.getElementById('newUserError').textContent = '';
personIdManuallyEdited = false;
initialsManuallyEdited = false;
emailManuallyEdited = false;
usernameManuallyEdited = false;
document.getElementById('newUserModal').style.display = 'flex';
document.getElementById('newUserFirstName').focus();
};
window.closeNewUserModal = function(){
document.getElementById('newUserModal').style.display = 'none';
};
// onNewUserNameInput: schlägt beim Tippen von Vor-/Nachname E-Mail,
// Benutzername (= Anfang der E-Mail, z. B. m.koerner), Person-ID
// (personIdForName()) und Kürzel vor (js/utils.js) — jedes Feld nur,
// solange es nicht bereits manuell bearbeitet wurde.
window.onNewUserNameInput = function(){
const vorname = document.getElementById('newUserFirstName').value;
const nachname = document.getElementById('newUserLastName').value;
const name = (vorname.trim() + ' ' + nachname.trim()).trim();
if(!initialsManuallyEdited){
document.getElementById('newUserInitials').value = name.trim() ? initialsForName(name) : '';
}
personIdAusKuerzel();
// Firmen-Adresse nach dem Schema v.nachname@rauschenberg-ing.de
// (emailVorschlag(), js/utils.js) — wie Person-ID und Kürzel nur,
// solange das Feld nicht von Hand geändert wurde.
if(!emailManuallyEdited){
document.getElementById('newUserEmail').value = emailVorschlag(vorname, nachname);
}
benutzernameAusEmail();
};
// personIdAusKuerzel: Person-ID aus dem Kürzel ("MK" → "p_mk") — dasselbe
// Schema, das alle Bestandskonten tragen (ihre IDs stammen aus der Zeit,
// als die Personenliste über das Kürzel geführt wurde) und an dem ihre
// Projektzuordnungen hängen. Vorher kam die ID aus dem vollen Namen
// ("p_miriam_k_rner"), sodass neue und alte Konten verschieden aussahen.
// Ist die ID schon vergeben, wird eine Ziffer angehängt (p_mk2, p_mk3 …),
// denn person_id ist eindeutig. Folgt dem Kürzel, solange die ID nicht
// von Hand geändert wurde.
function personIdAusKuerzel(){
if(personIdManuallyEdited) return;
const kuerzel = document.getElementById('newUserInitials').value.trim();
const feld = document.getElementById('newUserPersonId');
if(!kuerzel){ feld.value = ''; return; }
const basis = personIdForName(kuerzel);
const vergeben = new Set(usersCache.map(u => u.personId).filter(Boolean));
let id = basis, n = 2;
while(vergeben.has(id)) id = basis + (n++);
feld.value = id;
}
// benutzernameAusEmail: Benutzername = Anfang der E-Mail-Adresse. Folgt
// der Adresse auch dann, wenn sie von Hand geändert wird (z. B. bei einem
// Doppelnamen) — solange der Benutzername selbst nicht von Hand geändert
// wurde.
function benutzernameAusEmail(){
if(usernameManuallyEdited) return;
const adresse = document.getElementById('newUserEmail').value.trim();
document.getElementById('newUserUsername').value = adresse.includes('@') ? adresse.slice(0, adresse.indexOf('@')) : adresse;
}
window.onNewUserPwModeChange = function(){
const manual = document.querySelector('input[name="newUserPwMode"]:checked').value === 'manual';
document.getElementById('newUserPassword').style.display = manual ? '' : 'none';
};
window.saveNewUser = function(){
const username = document.getElementById('newUserUsername').value.trim();
// Mit großem Anfangsbuchstaben gespeichert.
const firstName = grossAnfang(document.getElementById('newUserFirstName').value);
const lastName = grossAnfang(document.getElementById('newUserLastName').value);
const email = document.getElementById('newUserEmail').value.trim();
const role = document.getElementById('newUserRole').value;
const department = document.getElementById('newUserDepartment').value;
const initials = document.getElementById('newUserInitials').value.trim();
const capacityHours = document.getElementById('newUserCapacity').value.trim();
const personId = document.getElementById('newUserPersonId').value.trim();
const roles = checkedRoleValues('newUserRoleCheckboxes');
const pwMode = document.querySelector('input[name="newUserPwMode"]:checked').value;
const errEl = document.getElementById('newUserError');
errEl.textContent = '';
if(!firstName || !lastName){
errEl.textContent = 'Vor- und Nachname sind erforderlich.';
return;
}
if(!username){
errEl.textContent = 'Benutzername ist erforderlich.';
return;
}
if(!department){
errEl.textContent = 'Abteilung ist erforderlich.';
return;
}
// Ohne Fachrolle ist der Benutzer in der ganzen Anwendung unsichtbar:
// peopleByRole() (js/utils.js) gruppiert ausschließlich über die Rollen —
// das Konto erschiene weder als Board-Spalte noch in der Auslastung noch
// im PDF-Export, ohne dass es dafür irgendeinen Hinweis gäbe. Für ein
// Kiosk-Konto gilt das nicht — es soll gerade NICHT in Boards/Auslastung
// auftauchen, server.js weist eine gesetzte Fachrolle dort sogar zurück.
if(!nurWandtafelRolle(role) && (!roles || roles.length === 0)){
errEl.textContent = 'Bitte mindestens eine Rolle wählen (Planung, Projektleitung oder Bauüberwachung) — sonst erscheint der Benutzer in keinem Projektboard.';
return;
}
// Ohne Personen-Kennung lässt sich das Konto keiner Person in den
// Sitzungen zuordnen (Aufgaben im Dashboard, Zuordnungen im Board).
if(!personId){
errEl.textContent = 'Bitte eine Personen-Kennung vergeben — ohne sie kann der Benutzer keiner Sitzung und keinen Aufgaben zugeordnet werden.';
return;
}
const body = { username, firstName, lastName, email, role, personId, department, initials, capacityHours, roles };
if(pwMode === 'manual'){
const password = document.getElementById('newUserPassword').value;
if(password.length < 8){
errEl.textContent = 'Manuelles Passwort muss mindestens 8 Zeichen haben.';
return;
}
body.password = password;
} else {
body.generatePassword = true;
}
apiFetch('/admin/users', { method: 'POST', body: JSON.stringify(body) }).then(res => {
if(!res.ok || !res.data || !res.data.ok){
errEl.textContent = (res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')');
return;
}
closeNewUserModal();
loadAndRenderUsers();
// Der Server hat das Konto in den Teilnehmerkreis der aktuellen
// Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis
// zum nächsten Seitenaufruf in Projekten und im Assistenten.
if(res.data.sitzungGeaendert && typeof load === 'function') load(true);
if(res.data.generatedPassword){
showGeneratedPassword(username, res.data.generatedPassword);
}
}).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; });
};
// ---------- Benutzer-bearbeiten-Modal ----------
window.openEditUserModal = function(id){
const u = usersCache.find(x => x.id === id);
if(!u) return;
editingUserId = id;
document.getElementById('editUserUsernameLabel').textContent = 'Benutzername: ' + u.username + ' (nicht änderbar)';
// Konten von vor der Umstellung tragen evtl. noch keinen Vor-/Nachnamen:
// dann aus dem Anzeigenamen geteilt (erstes Wort / Rest).
const teile = String(u.displayName || '').trim().split(/\s+/).filter(Boolean);
document.getElementById('editUserFirstName').value = u.firstName || u.lastName ? (u.firstName || '') : (teile[0] || '');
document.getElementById('editUserLastName').value = u.firstName || u.lastName ? (u.lastName || '') : teile.slice(1).join(' ');
document.getElementById('editUserEmail').value = u.email || '';
onEditUserNameInput();
document.getElementById('editUserRole').value = u.role;
document.getElementById('editUserDepartment').value = u.department || DEFAULT_DEPARTMENT;
document.getElementById('editUserInitials').value = u.initials || '';
document.getElementById('editUserCapacity').value = u.capacityHours ? u.capacityHours : '';
document.getElementById('editUserPersonId').value = u.personId || '';
const userRoles = u.roles || [];
document.querySelectorAll('#editUserRoleCheckboxes input[type="checkbox"]').forEach(cb => {
cb.checked = userRoles.includes(cb.value);
});
onUserRoleChange('edit');
document.getElementById('editUserError').textContent = '';
document.getElementById('editUserAbsenceError').textContent = '';
document.getElementById('editUserAbsenceStart').value = '';
document.getElementById('editUserAbsenceEnd').value = '';
document.getElementById('editUserAbsenceNote').value = '';
editUserAbsencesCache = [];
renderEditUserAbsences();
loadEditUserAbsences(id);
document.getElementById('editUserModal').style.display = 'flex';
};
window.closeEditUserModal = function(){
document.getElementById('editUserModal').style.display = 'none';
editingUserId = null;
editUserAbsencesCache = [];
};
window.saveEditUser = function(){
if(editingUserId === null) return;
const errEl = document.getElementById('editUserError');
errEl.textContent = '';
const department = document.getElementById('editUserDepartment').value;
if(!department){
errEl.textContent = 'Abteilung ist erforderlich.';
return;
}
const body = {
firstName: grossAnfang(document.getElementById('editUserFirstName').value),
lastName: grossAnfang(document.getElementById('editUserLastName').value),
email: document.getElementById('editUserEmail').value.trim(),
role: document.getElementById('editUserRole').value,
department,
initials: document.getElementById('editUserInitials').value.trim(),
capacityHours: document.getElementById('editUserCapacity').value.trim(),
personId: document.getElementById('editUserPersonId').value.trim(),
roles: checkedRoleValues('editUserRoleCheckboxes'),
};
apiFetch('/admin/users/' + editingUserId, { method: 'PATCH', body: JSON.stringify(body) }).then(res => {
if(!res.ok || !res.data || !res.data.ok){
errEl.textContent = (res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')');
return;
}
closeEditUserModal();
loadAndRenderUsers();
// Der Server hat das Konto in den Teilnehmerkreis der aktuellen
// Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis
// zum nächsten Seitenaufruf in Projekten und im Assistenten.
if(res.data.sitzungGeaendert && typeof load === 'function') load(true);
}).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; });
};
// ---------- Abwesenheiten (Bearbeiten-Modal) ----------
// GET /admin/users liefert bewusst keine Abwesenheiten mit (server.js), daher
// werden sie hier separat über GET /admin/users/:id/absences nachgeladen und
// Änderungen über die bereits bestehenden requireAuth-Endpunkte
// POST/DELETE /users/:id/absences vorgenommen.
// loadEditUserAbsences: lädt die Abwesenheiten des gerade bearbeiteten
// Benutzers und rendert die Liste im Bearbeiten-Modal.
function loadEditUserAbsences(id){
apiFetch('/admin/users/' + id + '/absences').then(res => {
if(editingUserId !== id) return; // Modal wurde inzwischen gewechselt/geschlossen
if(!res.ok || !res.data || !res.data.ok){
document.getElementById('editUserAbsenceError').textContent =
(res.data && res.data.error) || ('Fehler beim Laden (HTTP ' + res.status + ')');
return;
}
editUserAbsencesCache = res.data.absences || [];
renderEditUserAbsences();
}).catch(err => {
document.getElementById('editUserAbsenceError').textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message;
});
}
// renderEditUserAbsences: baut die Chip-Liste der Abwesenheiten aus
// editUserAbsencesCache auf (gleiche Markup-/CSS-Struktur wie die
// Abwesenheitsliste im Projektbeteiligte-Tab: .absence-list/.absence-chip).
function renderEditUserAbsences(){
const el = document.getElementById('editUserAbsenceList');
if(editUserAbsencesCache.length === 0){
el.innerHTML = '<span class="hint">Keine Abwesenheiten erfasst.</span>';
return;
}
el.innerHTML = editUserAbsencesCache.map(a => `<div class="absence-chip">
<span>${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))}</span>
${a.note ? `<small>${esc(a.note)}</small>` : ''}
<button class="danger small" onclick="deleteEditUserAbsence('${escJs(a.id)}')">×</button>
</div>`).join('');
}
// addEditUserAbsence: legt über POST /users/:id/absences eine neue
// Abwesenheit für den gerade bearbeiteten Benutzer an und lädt die Liste neu.
window.addEditUserAbsence = function(){
if(editingUserId === null) return;
const errEl = document.getElementById('editUserAbsenceError');
errEl.textContent = '';
const start = document.getElementById('editUserAbsenceStart').value;
const end = document.getElementById('editUserAbsenceEnd').value;
const type = document.getElementById('editUserAbsenceType').value;
const note = document.getElementById('editUserAbsenceNote').value.trim();
if(!start || !end){
errEl.textContent = 'Start- und Enddatum erforderlich.';
return;
}
apiFetch('/users/' + editingUserId + '/absences', { method: 'POST', body: JSON.stringify({ start, end, type, note }) }).then(res => {
if(!res.ok || !res.data || !res.data.ok){
errEl.textContent = (res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')');
return;
}
document.getElementById('editUserAbsenceStart').value = '';
document.getElementById('editUserAbsenceEnd').value = '';
document.getElementById('editUserAbsenceNote').value = '';
loadEditUserAbsences(editingUserId);
}).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; });
};
// deleteEditUserAbsence: löscht eine Abwesenheit über DELETE
// /users/:id/absences/:absenceId und lädt die Liste neu.
window.deleteEditUserAbsence = function(absenceId){
if(editingUserId === null) return;
const errEl = document.getElementById('editUserAbsenceError');
errEl.textContent = '';
apiFetch('/users/' + editingUserId + '/absences/' + absenceId, { method: 'DELETE' }).then(res => {
if(!res.ok || !res.data || !res.data.ok){
errEl.textContent = (res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')');
return;
}
loadEditUserAbsences(editingUserId);
}).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; });
};
// ---------- Zeilen-Aktionen (Passwort-Reset, Aktivieren/Deaktivieren, Löschen) ----------
window.resetUserPassword = function(id, username, ohneRueckfrage){
if(!ohneRueckfrage && !confirm('Passwort für "' + username + '" wirklich zurücksetzen? Bisherige Sitzungen dieses Benutzers werden beendet.')) return;
apiFetch('/admin/users/' + id + '/reset-password', { method: 'POST' }).then(res => {
if(!res.ok || !res.data || !res.data.ok){
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
return;
}
showGeneratedPassword(username, res.data.generatedPassword);
loadAndRenderUsers();
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
};
window.toggleUserActive = function(id, currentlyActive, ohneRueckfrage){
const goingActive = !currentlyActive;
if(!goingActive && !ohneRueckfrage && !confirm('Benutzer wirklich deaktivieren? Bestehende Sitzungen werden sofort beendet.')) return;
apiFetch('/admin/users/' + id, { method: 'PATCH', body: JSON.stringify({ active: goingActive }) }).then(res => {
if(!res.ok || !res.data || !res.data.ok){
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
return;
}
loadAndRenderUsers();
// Der Server hat das Konto in den Teilnehmerkreis der aktuellen
// Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis
// zum nächsten Seitenaufruf in Projekten und im Assistenten.
if(res.data.sitzungGeaendert && typeof load === 'function') load(true);
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
};
window.hardDeleteUser = function(id, username, ohneRueckfrage){
if(!ohneRueckfrage && !confirm('Benutzer "' + username + '" wirklich ENDGÜLTIG löschen? Die Person wird auch aus allen Projekten und Aufgaben der aktuellen Sitzung entfernt. Das kann nicht rückgängig gemacht werden.')) return;
apiFetch('/admin/users/' + id + '?hard=1', { method: 'DELETE' }).then(res => {
if(!res.ok || !res.data || !res.data.ok){
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
return;
}
loadAndRenderUsers();
// Der Server hat die Person aus der aktuellen Sitzung entfernt (Projekt-
// zuordnungen, Aufgaben, Teilnehmerkreis). Neu laden, damit Projekte
// und Auslastung in diesem Browser sofort ohne sie dastehen und das
// nächste Speichern nicht auf einem veralteten Stand aufsetzt.
if(typeof load === 'function') load(true);
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
};
// ---------- Drei-Punkte-Menü der Zeilen ----------
// „Bearbeiten" bleibt als Knopf in der Zeile, die selteneren Aktionen
// liegen hinter den drei Punkten (Eriks Wahl „C" aus dem Vergleich der
// Menü-Varianten). Was nicht rückgängig zu machen ist — Passwort
// zurücksetzen, Deaktivieren, Endgültig löschen — fragt direkt im Menü
// nach, statt ein Browser-Dialogfenster zu öffnen.
//
// Das Menü hängt an <body> und steht `position: fixed`: Die Tabelle liegt
// in einem quer scrollbaren Rahmen, der ein darin verankertes Menü
// abschneiden würde. Reicht der Platz unter dem Knopf nicht, klappt es nach
// oben auf. Scrollen oder Fenstergröße ändern schließt es.
const PUNKTE_SVG = '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.2" cy="8" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12.8" cy="8" r="1.4"/></svg>';
const MENU_IC = {
schluessel: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5.5" cy="10.5" r="3"/><path d="M7.6 8.4L13.5 2.5M11 5l1.8 1.8M12.5 3.5l1.5 1.5"/></svg>',
aus: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M4 12L12 4"/></svg>',
an: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M5.5 8.2l1.8 1.8 3.4-3.6"/></svg>',
muell: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4.5h11M6 4.5V3h4v1.5M4 4.5l.7 8.5h6.6l.7-8.5"/></svg>',
};
// offenesMenu: { el, knopf, zeile, u } solange ein Menü offen ist.
let offenesMenu = null;
function menuEintrag(ic, text, aktion, opts){
opts = opts || {};
return `<button type="button" role="menuitem" class="user-menu-item${opts.gefahr ? ' is-danger' : ''}"
data-aktion="${aktion}" ${opts.gesperrt ? 'aria-disabled="true"' : ''}>
${MENU_IC[ic]}<span class="user-menu-text"><span>${esc(text)}</span>${opts.unter ? `<span class="user-menu-sub">${esc(opts.unter)}</span>` : ''}</span>
</button>`;
}
function menuInhalt(u){
const isSelf = currentUser && currentUser.id === u.id;
let h = menuEintrag('schluessel', 'Passwort zurücksetzen', 'passwort');
h += u.active
? menuEintrag('aus', 'Deaktivieren', 'deaktivieren', { gesperrt: isSelf, unter: isSelf ? 'Eigenes Konto' : '' })
: menuEintrag('an', 'Aktivieren', 'aktivieren');
if(!u.active){
h += '<hr class="user-menu-trenner">'
+ menuEintrag('muell', 'Endgültig löschen', 'loeschen', { gefahr: true, gesperrt: isSelf });
}
return h;
}
const RUECKFRAGEN = {
passwort: u => ({ frage: 'Passwort von <b>' + esc(u.displayName || u.username) + '</b> zurücksetzen? Alle Sitzungen werden abgemeldet.', knopf: 'Zurücksetzen' }),
deaktivieren: u => ({ frage: '<b>' + esc(u.displayName || u.username) + '</b> deaktivieren? Alle Sitzungen werden sofort abgemeldet.', knopf: 'Deaktivieren' }),
loeschen: u => ({ frage: '<b>' + esc(u.displayName || u.username) + '</b> endgültig löschen? Die Person wird auch aus allen Projekten und Aufgaben der aktuellen Sitzung entfernt. Das lässt sich nicht rückgängig machen.', knopf: 'Löschen' }),
};
function menuFokusziele(){
return offenesMenu ? Array.from(offenesMenu.el.querySelectorAll('button:not([aria-disabled="true"])')) : [];
}
function menuPositionieren(){
if(!offenesMenu) return;
const { el, knopf } = offenesMenu;
const r = knopf.getBoundingClientRect();
const m = el.getBoundingClientRect();
const nachOben = (window.innerHeight - r.bottom) < m.height + 12 && r.top > m.height + 12;
el.classList.toggle('nach-oben', nachOben);
el.style.top = (nachOben ? r.top - m.height - 6 : r.bottom + 6) + 'px';
el.style.left = Math.max(8, Math.min(r.right - m.width, window.innerWidth - m.width - 8)) + 'px';
}
function schliesseUserMenu(fokusZurueck){
if(!offenesMenu) return;
const { el, knopf, zeile } = offenesMenu;
offenesMenu = null;
el.remove();
knopf.setAttribute('aria-expanded', 'false');
if(zeile) zeile.classList.remove('is-menu-open');
if(fokusZurueck && document.contains(knopf)) knopf.focus();
}
function zeigeMenuEintraege(){
offenesMenu.el.innerHTML = menuInhalt(offenesMenu.u);
menuPositionieren();
}
function fuehreAus(aktion, u){
schliesseUserMenu(true);
if(aktion === 'passwort') resetUserPassword(u.id, u.username, true);
else if(aktion === 'deaktivieren') toggleUserActive(u.id, true, true);
else if(aktion === 'aktivieren') toggleUserActive(u.id, false, true);
else if(aktion === 'loeschen') hardDeleteUser(u.id, u.username, true);
}
// openUserActionMenu: öffnet (bzw. schließt bei erneutem Klick) das Menü
// einer Zeile. Per Tastatur geöffnet (event.detail === 0) landet der Fokus
// gleich auf dem ersten Eintrag.
window.openUserActionMenu = function(event, id){
event.stopPropagation();
const knopf = event.currentTarget;
if(offenesMenu && offenesMenu.knopf === knopf){ schliesseUserMenu(true); return; }
schliesseUserMenu(false);
const u = usersCache.find(x => x.id === id);
if(!u) return;
const el = document.createElement('div');
el.className = 'user-menu';
el.setAttribute('role', 'menu');
document.body.appendChild(el);
const zeile = knopf.closest('tr');
offenesMenu = { el, knopf, zeile, u };
knopf.setAttribute('aria-expanded', 'true');
if(zeile) zeile.classList.add('is-menu-open');
zeigeMenuEintraege();
el.addEventListener('click', e => {
e.stopPropagation();
const b = e.target.closest('button');
if(!b || b.getAttribute('aria-disabled') === 'true') return;
if(b.dataset.zurueck){ zeigeMenuEintraege(); const z = menuFokusziele(); if(z[0]) z[0].focus(); return; }
if(b.dataset.ausfuehren){ fuehreAus(b.dataset.ausfuehren, u); return; }
const aktion = b.dataset.aktion;
if(RUECKFRAGEN[aktion]){
const r = RUECKFRAGEN[aktion](u);
el.innerHTML = `<div class="user-menu-confirm"><p>${r.frage}</p><div class="user-menu-confirm-row">
<button type="button" class="secondary small" data-zurueck="1">Abbrechen</button>
<button type="button" class="danger small" data-ausfuehren="${aktion}">${esc(r.knopf)}</button>
</div></div>`;
menuPositionieren();
el.querySelector('[data-ausfuehren]').focus();
return;
}
fuehreAus(aktion, u);
});
if(event.detail === 0){ const z = menuFokusziele(); if(z[0]) z[0].focus(); }
};
document.addEventListener('click', () => schliesseUserMenu(false));
document.addEventListener('keydown', e => {
if(!offenesMenu) return;
const ziele = menuFokusziele();
const i = ziele.indexOf(document.activeElement);
if(e.key === 'Escape'){ e.preventDefault(); schliesseUserMenu(true); }
else if(e.key === 'ArrowDown'){ e.preventDefault(); if(ziele.length) ziele[(i + 1) % ziele.length].focus(); }
else if(e.key === 'ArrowUp'){ e.preventDefault(); if(ziele.length) ziele[(i - 1 + ziele.length) % ziele.length].focus(); }
else if(e.key === 'Tab'){ schliesseUserMenu(false); }
});
window.addEventListener('resize', () => schliesseUserMenu(false));
window.addEventListener('scroll', () => schliesseUserMenu(false), true);
// onEditUserNameInput: Im Bearbeiten-Dialog wird eine vorhandene Adresse
// nie überschrieben; ist das E-Mail-Feld leer, steht der Vorschlag als
// Platzhalter darin und folgt Vor-/Nachname.
window.onEditUserNameInput = function(){
const feld = document.getElementById('editUserEmail');
if(!feld) return;
feld.placeholder = emailVorschlag(document.getElementById('editUserFirstName').value,
document.getElementById('editUserLastName').value) || 'v.nachname@' + EMAIL_DOMAIN;
};
// personIdManuallyEdited wird gesetzt, sobald der Nutzer das Person-ID-Feld
// selbst bearbeitet (nach dem Laden des DOM gebunden).
document.addEventListener('DOMContentLoaded', function(){
const personIdInput = document.getElementById('newUserPersonId');
if(personIdInput){
personIdInput.addEventListener('input', function(){ personIdManuallyEdited = true; });
}
const initialsInput = document.getElementById('newUserInitials');
if(initialsInput){
initialsInput.addEventListener('input', function(){ initialsManuallyEdited = true; personIdAusKuerzel(); });
}
const emailInput = document.getElementById('newUserEmail');
if(emailInput){
emailInput.addEventListener('input', function(){ emailManuallyEdited = true; benutzernameAusEmail(); });
}
const usernameInput = document.getElementById('newUserUsername');
if(usernameInput){
usernameInput.addEventListener('input', function(){ usernameManuallyEdited = true; });
}
});
})();