Projektleiter-Rolle, Papierkorb, neue Login-Seite und Benutzerverwaltung

- Neue Rolle "Projektleiter": Projekte anlegen und löschen nur noch für
  Projektleiter und Administratoren (serverseitig in /save-week geprüft)
- Papierkorb: gelöschte Projekte 30 Tage durch Admins wiederherstellbar
- Benutzerverwaltung: Aktionen im Drei-Punkte-Menü mit Rückfrage im Menü,
  getrennte Felder für Vor- und Nachname, E-Mail-Vorschlag
  v.nachname@rauschenberg-ing.de, Benutzername = Anfang der E-Mail,
  Person-ID aus dem Kürzel
- Endgültiges Löschen eines Benutzers entfernt ihn auch aus den Projekten
  und Aufgaben der aktuellen Sitzung; Dateien bleiben als
  "Gelöschter Benutzer"
- Login-Seite neu (Design "Kalenderwoche") mit Logo als Wasserzeichen;
  Anmeldung ohne Groß-/Kleinschreibung, auch mit voller E-Mail-Adresse

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-22 09:25:33 +02:00
1 parent 93505e0e48
commit f4fb3c550b
13 files changed
+1214 -116

No files matched your search

+93 -39
View File
@@ -34,12 +34,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Projektübersicht</title>
<link rel="stylesheet" href="/styles.css?v=64">
<link rel="stylesheet" href="/styles.css?v=67">
<link rel="stylesheet" href="/plan-styles.css?v=19">
<!-- Fredoka/Nunito Sans: nur für die Wandtafel (js/wand/wand.js), siehe ihren
Dateikopf — fällt ohne Internetzugang automatisch auf die System-Schrift
zurück (font-family-Stack in styles.css), bricht also nichts offline. -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600;700&family=Nunito+Sans:wght@400;600;700;800&display=swap">
Dateikopf; Outfit: nur für die Kalenderwoche der Login-Seite. Alle fallen
ohne Internetzugang automatisch auf die System-Schrift zurück
(font-family-Stack in styles.css), brechen also nichts offline. -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600;700&family=Outfit:wght@300;500&family=Nunito+Sans:wght@400;600;700;800&display=swap">
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/pdfmake.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/vfs_fonts.js"></script>
</head>
@@ -186,7 +187,7 @@
js/pdf.js. Damit fällt die einzige gewerkeübergreifende
Sitzungsübersicht als PDF weg; die einzelnen Gewerke-Berichte
im Wochenbericht bleiben die Ablage. -->
<button class="menu-item sidebar-subitem" onclick="openNewProjWizard()">+ Neues Projekt</button>
<button class="menu-item sidebar-subitem nur-projektleitung" onclick="openNewProjWizard()">+ Neues Projekt</button>
<button class="menu-item sidebar-subitem" onclick="showTab('wand')">Wandtafel</button>
</div>
@@ -212,6 +213,7 @@
<button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button>
<button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button>
<button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb &amp; Sicherheit</button>
<button class="menu-item sidebar-subitem" data-admin-section="papierkorb" onclick="showAdminSection('papierkorb')">Papierkorb</button>
<button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank &amp; Sitzungen</button>
</div>
</div>
@@ -239,7 +241,7 @@
zusätzlich stehen, weil er dort dieselbe Maske öffnet. -->
<div class="bericht-titelzeile">
<div class="section-title" style="margin:0">Projekte</div>
<button type="button" class="secondary small" onclick="openNewProjWizard()">+ Projekt</button>
<button type="button" class="secondary small nur-projektleitung" onclick="openNewProjWizard()">+ Projekt</button>
</div>
<!-- Die Farbe der Kreise richtet sich nach der ROLLE (siehe
.board-avatar.role-* in styles.css), die Füllung nach der Art der
@@ -715,7 +717,7 @@
<thead>
<tr>
<th>Benutzername</th>
<th>Anzeigename</th>
<th>Name</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Abteilung</th>
@@ -731,6 +733,15 @@
</div>
</section>
<section class="settings-card settings-card-wide" id="adminPapierkorbCard" data-admin-section="papierkorb" style="display:none">
<h3 class="settings-card-title">Papierkorb <span class="settings-scope">nur Administratoren</span></h3>
<p class="hint">Aus der Projektliste gelöschte Projekte — samt Terminplan, Beteiligten und
Leistungsphasen. Sie bleiben 30 Tage hier und lassen sich in die aktuelle Sitzung
zurückholen; danach werden sie endgültig entfernt. Dateien und Protokolle hängen an der
Projektnummer und sind nach dem Wiederherstellen ebenfalls wieder da.</p>
<div id="adminPapierkorbList"><p class="hint">Wird geladen…</p></div>
</section>
<section class="settings-card" id="adminDatenbankCard" data-admin-section="datenbank" style="display:none">
<h3 class="settings-card-title">Datenbank &amp; Sitzungen <span class="settings-scope">nur Administratoren</span></h3>
@@ -1536,22 +1547,30 @@
<div class="modal-bg" id="newUserModal" style="display:none">
<div class="modal">
<h2>Neuer Benutzer</h2>
<div class="form-row">
<label>Benutzername</label>
<input type="text" id="newUserUsername" placeholder="z. B. mmustermann" autocomplete="off">
<div class="form-row-pair">
<div class="form-row">
<label for="newUserFirstName">Vorname</label>
<input type="text" id="newUserFirstName" placeholder="z. B. Max" autocomplete="off" oninput="onNewUserNameInput()">
</div>
<div class="form-row">
<label for="newUserLastName">Nachname</label>
<input type="text" id="newUserLastName" placeholder="z. B. Mustermann" autocomplete="off" oninput="onNewUserNameInput()">
</div>
</div>
<div class="form-row">
<label>Anzeigename</label>
<input type="text" id="newUserDisplayName" placeholder="z. B. Max Mustermann" oninput="onNewUserDisplayNameInput()">
<label for="newUserEmail">E-Mail</label>
<input type="email" id="newUserEmail" placeholder="v.nachname@rauschenberg-ing.de">
<span class="hint" style="margin:4px 0 0">Der Anfang der Adresse ist zugleich der Benutzername für die Anmeldung.</span>
</div>
<div class="form-row">
<label>E-Mail</label>
<input type="email" id="newUserEmail" placeholder="max.mustermann@beispiel.de">
<label for="newUserUsername">Benutzername</label>
<input type="text" id="newUserUsername" placeholder="Anfang der E-Mail, z. B. m.mustermann" autocomplete="off">
</div>
<div class="form-row">
<label>Rolle</label>
<select id="newUserRole" onchange="onUserRoleChange('new')">
<option value="member">Mitglied</option>
<option value="projektleiter">Projektleiter (darf Projekte anlegen und löschen)</option>
<option value="admin">Administrator</option>
<option value="kiosk">Kiosk (Wandtafel-Konto)</option>
</select>
@@ -1563,7 +1582,7 @@
</div>
<div class="form-row">
<label>Kürzel</label>
<input type="text" id="newUserInitials" placeholder="wird aus dem Anzeigenamen vorgeschlagen" maxlength="4">
<input type="text" id="newUserInitials" placeholder="wird aus dem Namen vorgeschlagen" maxlength="4">
<p class="hint">Kurzform fürs Projektboard (2-4 Zeichen), wird vorgeschlagen und kann angepasst werden.</p>
</div>
<div class="form-row">
@@ -1573,7 +1592,7 @@
</div>
<div class="form-row">
<label>Person-ID</label>
<input type="text" id="newUserPersonId" placeholder="wird aus dem Anzeigenamen vorgeschlagen">
<input type="text" id="newUserPersonId" placeholder="wird aus dem Kürzel gebildet, z. B. p_mm">
<p class="hint">Verknüpft den Account mit einem Eintrag in der Projektbeteiligten-Liste (personIdForName). Bei Kollisionen anpassen.</p>
</div>
<div class="form-row">
@@ -1601,18 +1620,25 @@
<div class="modal">
<h2>Benutzer bearbeiten</h2>
<p class="hint" id="editUserUsernameLabel" style="margin-bottom:10px"></p>
<div class="form-row">
<label>Anzeigename</label>
<input type="text" id="editUserDisplayName">
<div class="form-row-pair">
<div class="form-row">
<label for="editUserFirstName">Vorname</label>
<input type="text" id="editUserFirstName" oninput="onEditUserNameInput()">
</div>
<div class="form-row">
<label for="editUserLastName">Nachname</label>
<input type="text" id="editUserLastName" oninput="onEditUserNameInput()">
</div>
</div>
<div class="form-row">
<label>E-Mail</label>
<input type="email" id="editUserEmail">
<input type="email" id="editUserEmail" placeholder="v.nachname@rauschenberg-ing.de">
</div>
<div class="form-row">
<label>Rolle</label>
<select id="editUserRole" onchange="onUserRoleChange('edit')">
<option value="member">Mitglied</option>
<option value="projektleiter">Projektleiter (darf Projekte anlegen und löschen)</option>
<option value="admin">Administrator</option>
<option value="kiosk">Kiosk (Wandtafel-Konto)</option>
</select>
@@ -1748,41 +1774,69 @@
</div>
</div>
<!-- Login-Overlay: blockiert die gesamte App ohne gültige Session (siehe js/auth.js) -->
<div id="loginOverlay" style="display:none;" class="modal-bg">
<div class="modal">
<h2>Anmelden</h2>
<form id="loginForm">
<div class="form-row"><label>Benutzername</label><input type="text" id="loginUsername" autocomplete="username" required></div>
<div class="form-row"><label>Passwort</label><input type="password" id="loginPassword" autocomplete="current-password" required></div>
<div id="loginError" style="color:#c00;"></div>
<div class="toolbar"><button type="submit">Anmelden</button></div>
</form>
</div>
<!-- Login-Seite: blockiert die gesamte App ohne gültige Session (siehe
js/auth.js). Design "Kalenderwoche" (Eriks Wahl B aus dem Vergleich der
Login-Entwürfe): links Logo und Formular, rechts die laufende
Kalenderwoche als große Zahl mit ihren Arbeitstagen — die App denkt in
Sitzungen je KW. Gefüllt von renderLoginWoche(). Das Logo liegt als
blasses Wasserzeichen über die ganze Seite (Variante E aus dem
Logo-Vergleich), ein kleines Logo gibt es bewusst nicht. -->
<div id="loginOverlay" class="login-seite" style="display:none;">
<div class="login-wz-rahmen" aria-hidden="true"><img class="login-wasserzeichen" src="/logo.png" alt=""></div>
<main class="login-raster" aria-label="Anmeldung bei der Projektübersicht">
<section class="login-links">
<h1 class="login-titel">Projektübersicht</h1>
<p class="login-unter">Melde dich mit dem Anfang deiner E-Mail-Adresse an.</p>
<form id="loginForm" class="login-form" novalidate>
<div class="login-feld">
<label for="loginUsername">Benutzername</label>
<input type="text" id="loginUsername" name="username" autocomplete="username" placeholder="z. B. m.mustermann" spellcheck="false" autocapitalize="off">
</div>
<div class="login-feld">
<label for="loginPassword">Passwort</label>
<div class="login-pw">
<input type="password" id="loginPassword" name="password" autocomplete="current-password">
<button type="button" id="loginPwToggle" class="login-pw-toggle" aria-pressed="false" aria-controls="loginPassword">Anzeigen</button>
</div>
<p id="loginCaps" class="login-caps" hidden>Die Feststelltaste ist aktiv.</p>
</div>
<p id="loginError" class="login-fehler" role="alert" hidden></p>
<button type="submit" id="loginSubmit" class="login-btn"><span class="login-spinner" aria-hidden="true"></span><span class="login-btn-text">Anmelden</span></button>
</form>
<p class="login-hilfe">Passwort vergessen? Ein Administrator setzt es dir zurück.</p>
</section>
<section class="login-woche" aria-label="Laufende Kalenderwoche">
<span class="login-kw-label">Kalenderwoche</span>
<span class="login-kw-zahl" id="loginKwZahl"></span>
<span class="login-kw-jahr" id="loginKwJahr"></span>
<ol class="login-tage" id="loginTage"></ol>
<p class="login-datum"><span id="loginDatum"></span>. Die Sitzung dieser Woche ist geöffnet.</p>
</section>
</main>
</div>
<script src="/js/globals.js?v=7"></script>
<script src="/js/utils.js?v=18"></script>
<script src="/js/utils.js?v=19"></script>
<script src="/js/icons.js?v=2"></script>
<script src="/js/ui.js?v=9"></script>
<script src="/js/date-picker.js?v=5"></script>
<script src="/js/server.js?v=6"></script>
<script src="/js/auth.js?v=4"></script>
<script src="/js/auth.js?v=6"></script>
<script src="/js/store.js?v=10"></script>
<script src="/js/weeks.js?v=11"></script>
<script src="/js/board/board.js?v=30"></script>
<script src="/js/board/board.js?v=31"></script>
<script src="/js/wochenbericht/wochenbericht.js?v=20"></script>
<script src="/js/people/people.js?v=7"></script>
<script src="/js/planung/planung.js?v=1"></script>
<script src="/js/planchecker/planchecker.js?v=1"></script>
<script src="/js/projects/projects.js?v=7"></script>
<script src="/js/projects/projects.js?v=8"></script>
<script src="/js/load/load.js?v=18"></script>
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
der Neues-Projekt-Dialog dort die Statusauswahl mit AUFTRAGSSTATUS füllt. -->
<script src="/js/auftrag.js?v=1"></script>
<!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und
load.js (faktorFormat), steht deshalb dahinter. -->
<script src="/js/projekt-wizard.js?v=1"></script>
<script src="/js/projekt-wizard.js?v=2"></script>
<script src="/js/dashboard/dashboard.js?v=5"></script>
<!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/
stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek()
@@ -1791,9 +1845,9 @@
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
<script src="/js/wand/wand.js?v=12"></script>
<script src="/js/settings/settings.js?v=9"></script>
<script src="/js/admin/admin.js?v=11"></script>
<script src="/js/admin-users.js?v=4"></script>
<script src="/js/settings/settings.js?v=10"></script>
<script src="/js/admin/admin.js?v=12"></script>
<script src="/js/admin-users.js?v=7"></script>
<script src="/js/gantt.js?v=7"></script>
<!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in
project-plan.html; seit der Projektbereich ein Bereich dieser Seite ist,
+254 -33
View File
@@ -37,6 +37,10 @@
// 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).
@@ -142,27 +146,24 @@
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 toggleLabel = u.active ? 'Deaktivieren' : 'Aktivieren';
const isSelf = currentUser && currentUser.id === u.id;
const deleteBtn = (!u.active)
? `<button class="danger small" ${isSelf ? 'disabled' : ''} onclick="hardDeleteUser(${u.id}, '${escJs(u.username)}')">Endgültig löschen</button>`
: '';
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>${u.role === 'admin' ? 'Administrator' : u.role === 'kiosk' ? 'Kiosk' : 'Mitglied'}</td>
<td>${esc(systemRolleText(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">
<button class="secondary small" onclick="openEditUserModal(${u.id})">Bearbeiten</button>
<button class="secondary small" onclick="resetUserPassword(${u.id}, '${escJs(u.username)}')">Passwort zurücksetzen</button>
<button class="secondary small" ${isSelf ? 'disabled title="Sie können sich nicht selbst deaktivieren."' : ''} onclick="toggleUserActive(${u.id}, ${u.active})">${toggleLabel}</button>
${deleteBtn}
<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('');
@@ -198,7 +199,8 @@
window.openNewUserModal = function(){
document.getElementById('newUserUsername').value = '';
document.getElementById('newUserDisplayName').value = '';
document.getElementById('newUserFirstName').value = '';
document.getElementById('newUserLastName').value = '';
document.getElementById('newUserEmail').value = '';
document.getElementById('newUserRole').value = 'member';
document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT;
@@ -211,27 +213,66 @@
document.getElementById('newUserError').textContent = '';
personIdManuallyEdited = false;
initialsManuallyEdited = false;
emailManuallyEdited = false;
usernameManuallyEdited = false;
document.getElementById('newUserModal').style.display = 'flex';
document.getElementById('newUserUsername').focus();
document.getElementById('newUserFirstName').focus();
};
window.closeNewUserModal = function(){
document.getElementById('newUserModal').style.display = 'none';
};
// onNewUserDisplayNameInput: schlägt beim Tippen des Anzeigenamens eine
// Person-ID über personIdForName() vor (js/utils.js) — nur solange das Feld
// nicht bereits manuell bearbeitet wurde (Kollisionsvermeidung laut Plan).
window.onNewUserDisplayNameInput = function(){
const name = document.getElementById('newUserDisplayName').value;
if(!personIdManuallyEdited){
document.getElementById('newUserPersonId').value = name.trim() ? personIdForName(name) : '';
}
// 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';
@@ -239,7 +280,9 @@
window.saveNewUser = function(){
const username = document.getElementById('newUserUsername').value.trim();
const displayName = document.getElementById('newUserDisplayName').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;
@@ -251,8 +294,12 @@
const errEl = document.getElementById('newUserError');
errEl.textContent = '';
if(!username || !displayName){
errEl.textContent = 'Benutzername und Anzeigename sind erforderlich.';
if(!firstName || !lastName){
errEl.textContent = 'Vor- und Nachname sind erforderlich.';
return;
}
if(!username){
errEl.textContent = 'Benutzername ist erforderlich.';
return;
}
if(!department){
@@ -275,7 +322,7 @@
errEl.textContent = 'Bitte eine Personen-Kennung vergeben — ohne sie kann der Benutzer keiner Sitzung und keinen Aufgaben zugeordnet werden.';
return;
}
const body = { username, displayName, email, role, personId, department, initials, capacityHours, roles };
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){
@@ -307,8 +354,13 @@
if(!u) return;
editingUserId = id;
document.getElementById('editUserUsernameLabel').textContent = 'Benutzername: ' + u.username + ' (nicht änderbar)';
document.getElementById('editUserDisplayName').value = u.displayName || '';
// 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 || '';
@@ -346,7 +398,8 @@
return;
}
const body = {
displayName: document.getElementById('editUserDisplayName').value.trim(),
firstName: grossAnfang(document.getElementById('editUserFirstName').value),
lastName: grossAnfang(document.getElementById('editUserLastName').value),
email: document.getElementById('editUserEmail').value.trim(),
role: document.getElementById('editUserRole').value,
department,
@@ -447,8 +500,8 @@
// ---------- Zeilen-Aktionen (Passwort-Reset, Aktivieren/Deaktivieren, Löschen) ----------
window.resetUserPassword = function(id, username){
if(!confirm('Passwort für "' + username + '" wirklich zurücksetzen? Bisherige Sitzungen dieses Benutzers werden beendet.')) return;
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 + ')'));
@@ -459,9 +512,9 @@
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
};
window.toggleUserActive = function(id, currentlyActive){
window.toggleUserActive = function(id, currentlyActive, ohneRueckfrage){
const goingActive = !currentlyActive;
if(!goingActive && !confirm('Benutzer wirklich deaktivieren? Bestehende Sitzungen werden sofort beendet.')) return;
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 + ')'));
@@ -471,17 +524,177 @@
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
};
window.hardDeleteUser = function(id, username){
if(!confirm('Benutzer "' + username + '" wirklich ENDGÜLTIG löschen? Das kann nicht rückgängig gemacht werden.')) return;
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(){
@@ -491,7 +704,15 @@
}
const initialsInput = document.getElementById('newUserInitials');
if(initialsInput){
initialsInput.addEventListener('input', function(){ initialsManuallyEdited = true; });
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; });
}
});
+74
View File
@@ -61,6 +61,7 @@ const ADMIN_SECTIONS = [
{ 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' },
];
@@ -136,6 +137,9 @@ function showAdminSection(key){
// aendert sich auch ausserhalb dieses Browsers, und die uebrigen Masken
// rechnen mit ihr (Wochenstunden -> Auslastung).
if(key === 'benutzer' && typeof renderAdminUsers === 'function') renderAdminUsers();
// Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso
// frisch holen.
if(key === 'papierkorb') loadAdminPapierkorb();
try {
history.replaceState(null, '', location.pathname + location.search + '#' + key);
@@ -931,3 +935,73 @@ function loadHolidaysIntoState(){
})
.catch(err => console.error('Feiertage neu laden fehlgeschlagen:', err));
}
// -----------------------------
// Papierkorb gelöschter Projekte
// -----------------------------
// Ein aus der Projektliste gelöschtes Projekt legt der Server beim Speichern
// der Sitzung als Schnappschuss in deleted_projects ab (server.js,
// /save-week). Hier lässt es sich 30 Tage lang in die aktuelle Sitzung
// zurückholen oder vorzeitig endgültig entfernen.
// loadAdminPapierkorb: holt die Liste (GET /admin/deleted-projects) und
// rendert sie in #adminPapierkorbList.
async function loadAdminPapierkorb(){
const el = document.getElementById('adminPapierkorbList');
if(!el) return;
try {
const r = await fetch(baseUrl() + '/admin/deleted-projects');
const data = await r.json().catch(() => ({}));
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
if(!data.projekte.length){
el.innerHTML = '<p class="hint">Der Papierkorb ist leer.</p>';
return;
}
const tagMs = 24 * 60 * 60 * 1000;
el.innerHTML = data.projekte.map(p => {
const bez = ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() || '(ohne Bezeichnung)';
const am = new Date(p.deletedAt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' });
const rest = Math.max(0, Math.ceil((Date.parse(p.endgueltigAm) - Date.now()) / tagMs));
return `<div class="template-admin-row">
<span class="template-admin-title">${esc(bez)}
<span class="hint" style="margin:0"> · gelöscht ${esc(am)}${p.deletedBy ? ' von ' + esc(p.deletedBy) : ''} · aus ${esc(p.weekLabel)}</span>
</span>
<span class="sitzung-chip">noch ${rest} ${rest === 1 ? 'Tag' : 'Tage'}</span>
<button class="secondary small" onclick="adminProjektWiederherstellen('${esc(p.id)}')">Wiederherstellen</button>
<button class="danger small" onclick="adminProjektEndgueltigLoeschen('${esc(p.id)}')" title="Sofort endgültig löschen">×</button>
</div>`;
}).join('');
} catch(err){
el.innerHTML = '<p class="hint">Papierkorb konnte nicht geladen werden: ' + esc(err.message) + '</p>';
}
}
// adminProjektWiederherstellen: holt ein Projekt in die aktuelle Sitzung
// zurück. Danach wird neu geladen, damit die Projektliste das Projekt zeigt
// und die lokale Revision der Sitzung zum Server passt (sonst meldete das
// nächste Speichern einen Konflikt).
async function adminProjektWiederherstellen(id){
try {
const r = await fetch(baseUrl() + '/admin/deleted-projects/' + encodeURIComponent(id) + '/restore', { method: 'POST' });
const data = await r.json().catch(() => ({}));
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
load();
loadAdminPapierkorb();
alert('Projekt wiederhergestellt in „' + data.weekLabel + '".');
} catch(err){
alert('Wiederherstellen fehlgeschlagen: ' + err.message);
}
}
// adminProjektEndgueltigLoeschen: entfernt einen Papierkorb-Eintrag sofort.
async function adminProjektEndgueltigLoeschen(id){
if(!confirm('Projekt endgültig löschen? Das lässt sich nicht rückgängig machen.')) return;
try {
const r = await fetch(baseUrl() + '/admin/deleted-projects/' + encodeURIComponent(id), { method: 'DELETE' });
const data = await r.json().catch(() => ({}));
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
loadAdminPapierkorb();
} catch(err){
alert('Löschen fehlgeschlagen: ' + err.message);
}
}
+100 -7
View File
@@ -33,12 +33,30 @@ let currentUser = null;
function renderUserBar(){
const info = document.getElementById('userBarInfo');
if(info && currentUser){
const roleLabel = currentUser.role === 'admin' ? 'Administrator'
: currentUser.role === 'kiosk' ? 'Kiosk' : 'Mitglied';
const roleLabel = systemRolleText(currentUser.role);
info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')';
}
const navAdminSection = document.getElementById('navAdminSection');
if(navAdminSection) navAdminSection.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
document.querySelectorAll('.nur-projektleitung').forEach(el => { el.hidden = !darfProjektlisteAendern(); });
}
// systemRolleText: Anzeigename einer Systemrolle (users.role). Auch von der
// Benutzerverwaltung (js/admin-users.js) genutzt.
function systemRolleText(role){
return role === 'admin' ? 'Administrator'
: role === 'projektleiter' ? 'Projektleiter'
: role === 'kiosk' ? 'Kiosk' : 'Mitglied';
}
// darfProjektlisteAendern: ob das angemeldete Konto Projekte anlegen und aus
// der Projektliste löschen darf (Projektleiter und Administratoren). Reines
// UI-Gate — dieselbe Regel setzt /save-week serverseitig durch
// (darfProjektlisteAendern() in server.js), dort mit 403. Knöpfe zum
// Anlegen tragen die Klasse .nur-projektleitung und werden von
// renderUserBar() ausgeblendet.
function darfProjektlisteAendern(){
return !!currentUser && (currentUser.role === 'admin' || currentUser.role === 'projektleiter');
}
// logout: beendet die Server-Session und lädt die Seite neu (zeigt danach
@@ -48,9 +66,63 @@ async function logout(){
window.location.reload();
}
// showLoginOverlay / hideLoginOverlay: blenden das blockierende Login-Overlay ein/aus.
// showLoginOverlay / hideLoginOverlay: blenden die blockierende Login-Seite ein/aus.
function showLoginOverlay(){
renderLoginWoche();
document.getElementById('loginOverlay').style.display = 'flex';
const feld = document.getElementById('loginUsername');
if(feld && !feld.value) feld.focus();
}
// renderLoginWoche: füllt die rechte Seite der Login-Seite — laufende
// Kalenderwoche (getWeekNumber(), js/utils.js), Jahr der ISO-Woche, die
// Arbeitstage Mo-Fr mit dem heutigen Tag markiert und das Datum.
function renderLoginWoche(){
const heute = new Date();
const kw = getWeekNumber(heute);
// ISO-Jahr: das Jahr des Donnerstags dieser Woche (Ende Dezember/Anfang
// Januar weicht es vom Kalenderjahr ab, z. B. KW 01 / 2027 am 30.12.2026).
const donnerstag = new Date(heute); donnerstag.setDate(heute.getDate() + 3 - ((heute.getDay() + 6) % 7));
const zahl = document.getElementById('loginKwZahl');
if(!zahl) return;
zahl.textContent = String(kw).padStart(2, '0');
document.getElementById('loginKwJahr').textContent = donnerstag.getFullYear();
document.getElementById('loginDatum').textContent =
heute.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const montag = new Date(heute); montag.setDate(heute.getDate() - ((heute.getDay() + 6) % 7));
document.getElementById('loginTage').innerHTML = ['Mo', 'Di', 'Mi', 'Do', 'Fr'].map((n, i) => {
const d = new Date(montag); d.setDate(montag.getDate() + i);
const istHeute = d.toDateString() === heute.toDateString();
return '<li' + (istHeute ? ' class="ist-heute" aria-current="date"' : '') + '><span>' + n + '</span><b>' + d.getDate() + '</b></li>';
}).join('');
}
// verdrahteLoginHilfen: „Anzeigen" für das Passwort und der Hinweis auf
// eine aktive Feststelltaste. Einmalig.
let loginHilfenVerdrahtet = false;
function verdrahteLoginHilfen(){
if(loginHilfenVerdrahtet) return;
loginHilfenVerdrahtet = true;
const pw = document.getElementById('loginPassword');
const toggle = document.getElementById('loginPwToggle');
const caps = document.getElementById('loginCaps');
if(toggle) toggle.addEventListener('click', () => {
const sichtbar = pw.type === 'text';
pw.type = sichtbar ? 'password' : 'text';
toggle.textContent = sichtbar ? 'Anzeigen' : 'Verbergen';
toggle.setAttribute('aria-pressed', String(!sichtbar));
pw.focus();
});
const capsPruefen = e => { if(caps && e.getModifierState) caps.hidden = !e.getModifierState('CapsLock'); };
pw.addEventListener('keydown', capsPruefen);
pw.addEventListener('keyup', capsPruefen);
pw.addEventListener('blur', () => { if(caps) caps.hidden = true; });
['loginUsername', 'loginPassword'].forEach(id => {
document.getElementById(id).addEventListener('input', e => {
e.target.closest('.login-feld').classList.remove('hat-fehler');
document.getElementById('loginError').hidden = true;
});
});
}
function hideLoginOverlay(){
document.getElementById('loginOverlay').style.display = 'none';
@@ -174,24 +246,45 @@ async function checkAuthAndInit(onAuthenticated){
return;
}
showLoginOverlay();
verdrahteLoginHilfen();
const form = document.getElementById('loginForm');
const errEl = document.getElementById('loginError');
const btn = document.getElementById('loginSubmit');
const zeigeFehler = (text, feldId) => {
errEl.textContent = text;
errEl.hidden = false;
if(feldId){
const feld = document.getElementById(feldId);
feld.closest('.login-feld').classList.add('hat-fehler');
feld.focus();
}
};
form.onsubmit = async function(ev){
ev.preventDefault();
errEl.textContent = '';
const username = document.getElementById('loginUsername').value;
errEl.hidden = true;
const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value;
if(!username) return zeigeFehler('Bitte den Benutzernamen eingeben, z. B. m.mustermann.', 'loginUsername');
if(!password) return zeigeFehler('Bitte das Passwort eingeben.', 'loginPassword');
btn.disabled = true; btn.classList.add('laedt');
try {
const r = await fetch(baseUrl() + '/login', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ username, password })
});
const d = await r.json();
if(!r.ok || !d.ok){ errEl.textContent = d.error || 'Anmeldung fehlgeschlagen.'; return; }
if(!r.ok || !d.ok){
zeigeFehler(d.error || 'Anmeldung fehlgeschlagen.', 'loginPassword');
document.getElementById('loginPassword').select();
return;
}
currentUser = d.user;
document.getElementById('loginPassword').value = '';
afterAuthenticated(onAuthenticated);
} catch(err){
errEl.textContent = 'Verbindung zum Server fehlgeschlagen.';
zeigeFehler('Der Server ist nicht erreichbar. Bitte später erneut versuchen.');
} finally {
btn.disabled = false; btn.classList.remove('laedt');
}
};
}
+1 -1
View File
@@ -157,7 +157,7 @@ function renderBoardRow(p, allPeople, locked, weekLabel, opts){
// einen Ort haben — hier steht nur, was die LISTE betrifft.
html += `<div class="board-row-actions" onclick="event.stopPropagation()">
<button type="button" class="secondary small" onclick="openProjectPlan('${p.id}')" title="Projektbereich öffnen">Öffnen</button>
${locked ? '' : `<button type="button" class="danger small" onclick="delProjectFromList('${p.id}')" title="Projekt aus dieser Sitzung löschen">Löschen</button>`}
${locked || !darfProjektlisteAendern() ? '' : `<button type="button" class="danger small" onclick="delProjectFromList('${p.id}')" title="Projekt löschen (30 Tage durch Administratoren wiederherstellbar)">Löschen</button>`}
</div>`;
html += `</div>`; // board-row-main
+13 -3
View File
@@ -25,10 +25,20 @@
// delProjectFromList: löscht ein Projekt (per Bestätigungsdialog) in der
// Listen-Ansicht. Parameter `id`: Projekt-ID. Keine Rückgabe; bei gesperrter
// Sitzung abgebrochen.
// Sitzung abgebrochen. Nur für Projektleiter und Administratoren
// (darfProjektlisteAendern(), js/auth.js; serverseitig in /save-week
// durchgesetzt). Der Server legt das Projekt beim Speichern in den
// Papierkorb, aus dem es ein Administrator 30 Tage lang zurückholen kann
// (Admin → Papierkorb, js/admin/admin.js).
function delProjectFromList(id){
if(guardWeekLocked()) return;
if(!confirm("Projekt aus dieser Sitzung löschen?")) return;
if(!darfProjektlisteAendern()){
alert('Projekte löschen dürfen nur Projektleiter und Administratoren.');
return;
}
const p = currentWeek().projects.find(x => x.id === id);
const bez = p ? ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() : '';
if(!confirm('Projekt ' + (bez ? '„' + bez + '" ' : '') + 'löschen?\n\nEin Administrator kann es 30 Tage lang wiederherstellen.')) return;
const w = currentWeek();
w.projects = w.projects.filter(p=>p.id!==id);
save(); renderProjects(); renderBoard();
@@ -160,7 +170,7 @@ function renderProjects(){
</div>
<div class="project-card-footer">
<button class="secondary small" onclick="openLpModal('${p.id}')" ${locked ? 'disabled' : ''}>LP-Aufwand</button>
<button class="danger small" onclick="delProjectFromList('${p.id}')" ${locked ? 'disabled' : ''}>Löschen</button>
<button class="danger small" onclick="delProjectFromList('${p.id}')" ${locked || !darfProjektlisteAendern() ? 'disabled' : ''}>Löschen</button>
</div>
</div>`;
});
+7 -1
View File
@@ -51,9 +51,15 @@ const GEWERK_LABELS = { ELT: 'Elektrotechnik', HLS: 'Heizung · Lüftung · Sani
let wizard = null;
// openNewProjWizard: öffnet den Assistenten mit leerem Zustand.
// Keine Parameter, keine Rückgabe; gesperrte Sitzungen werden abgewiesen.
// Keine Parameter, keine Rückgabe; gesperrte Sitzungen werden abgewiesen,
// ebenso Konten ohne Rolle Projektleiter/Administrator (die Knöpfe sind für
// sie ohnehin ausgeblendet; /save-week weist das Anlegen mit 403 ab).
function openNewProjWizard(){
if(guardWeekLocked()) return;
if(!darfProjektlisteAendern()){
alert('Projekte anlegen dürfen nur Projektleiter und Administratoren.');
return;
}
wizard = {
schritt: 0,
fertig: null, // angelegtes Projekt, solange der Abschluss steht
+1 -1
View File
@@ -47,7 +47,7 @@ function renderSettingsAccount(){
+ `<div class="settings-kv-row">
<span class="settings-kv-key">E-Mail</span>
<span class="settings-email-edit">
<input type="email" id="settingsEmailInput" value="${esc(currentUser.email || '')}" placeholder="name@firma.de">
<input type="email" id="settingsEmailInput" value="${esc(currentUser.email || '')}" placeholder="${esc(emailVorschlag(currentUser.displayName) || 'v.nachname@' + EMAIL_DOMAIN)}">
<button class="secondary" onclick="submitEmailChange()">Speichern</button>
<span id="settingsEmailError" class="settings-warn" hidden></span>
</span>
+44
View File
@@ -104,6 +104,50 @@ function initialsForName(name){
return words.slice(0, 4).map(w => w[0]).join('').toUpperCase();
}
// EMAIL_DOMAIN: Domain der Firmen-Adressen, Grundlage von emailVorschlag().
const EMAIL_DOMAIN = 'rauschenberg-ing.de';
// emailVorschlag: schlägt aus einem Namen die Firmen-Adresse nach dem Schema
// „Anfangsbuchstabe Vorname . Nachname @rauschenberg-ing.de" vor, klein
// geschrieben, Umlaute umschrieben (ä→ae, ß→ss), übrige Sonderzeichen
// entfernt: "Miriam Körner" → "m.koerner@rauschenberg-ing.de". Vorname ist
// das erste, Nachname das letzte Wort. Nur ein Vorschlag — bleibt
// überschreibbar. Parameter: name (String) — oder, wenn `nachname` gegeben
// ist, Vorname und Nachname getrennt (dann zählt der ganze Nachname, z. B.
// "von Plum" → "vonplum"). Rückgabe: Adresse oder ''.
function emailVorschlag(name, nachname){
if(nachname !== undefined){
const v = String(name || '').trim(), n = String(nachname || '').trim();
if(!v || !n) return emailVorschlag((v + ' ' + n).trim());
const l = emailVorschlag(v.split(/\s+/)[0] + ' ' + n.replace(/\s+/g, ''));
return l;
}
const teil = s => String(s).toLowerCase()
.replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss')
.normalize('NFD').replace(/[\u0300-\u036f]/g,'')
.replace(/[^a-z0-9-]/g,'');
const woerter = String(name || '').trim().split(/\s+/).map(teil).filter(Boolean);
if(woerter.length === 0) return '';
const lokal = woerter.length === 1 ? woerter[0] : woerter[0][0] + '.' + woerter[woerter.length - 1];
return lokal + '@' + EMAIL_DOMAIN;
}
// benutzernameVorschlag: Benutzername eines neuen Kontos = Anfang der
// E-Mail-Adresse, also "Anfangsbuchstabe Vorname . Nachname" in der
// Schreibweise von emailVorschlag() ("Miriam Körner" → "m.koerner").
// Angemeldet wird mit genau diesem Namen. Rückgabe: String oder ''.
function benutzernameVorschlag(vorname, nachname){
const adresse = emailVorschlag(vorname, nachname);
return adresse ? adresse.slice(0, adresse.indexOf('@')) : '';
}
// grossAnfang: erster Buchstabe groß, Leerraum zusammengefasst — für Vor-
// und Nachnamen beim Speichern ("miriam" → "Miriam").
function grossAnfang(s){
s = String(s || '').trim().replace(/\s+/g, ' ');
return s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
}
// formatWeekDate: wandelt ein ISO-Datum (YYYY-MM-DD) in deutsches Format DD.MM.YYYY um
// Parameter: dateString (String). Rückgabe: formatierter String oder leer.
function formatWeekDate(dateString){
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

+183 -2
View File
@@ -4030,8 +4030,63 @@ html[data-theme="dark"] .absence-type-fortbildung { background: rgba(255,159,10,
Seiten-Huelle (.admin-page/.admin-header/.admin-body) sind mit ihr
entfallen, weil der Abschnitt jetzt in der normalen App-Huelle liegt. */
.admin-toolbar { margin-bottom: 14px; }
td.actions-cell { white-space: nowrap; }
td.actions-cell button { margin: 2px; }
td.actions-cell { white-space: nowrap; width: 1%; text-align: right; }
/* Vor- und Nachname nebeneinander (Benutzer anlegen/bearbeiten), auf
schmalen Bildschirmen untereinander. */
.form-row-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
.form-row-pair > .form-row { min-width: 0; }
@media (max-width: 480px) { .form-row-pair { grid-template-columns: 1fr; } }
/* Zeilenaktionen der Benutzerverwaltung: „Bearbeiten" + Drei-Punkte-Menü
(js/admin-users.js, openUserActionMenu()). Das Menü hängt an <body> und
steht fixed, damit der quer scrollbare Tabellenrahmen es nicht abschneidet. */
.user-aktionen { display: inline-flex; gap: 6px; align-items: center; }
.user-kebab {
width: 30px; height: 30px; padding: 0;
display: inline-grid; place-items: center;
border: 1px solid transparent; border-radius: var(--r-sm);
background: transparent; color: var(--text-muted); cursor: pointer;
transition: background var(--dur) var(--ease-out), color var(--dur) var(--ease-out), border-color var(--dur) var(--ease-out);
}
.user-kebab:hover { background: var(--surface-sunk); color: var(--text); }
.user-kebab[aria-expanded="true"] {
background: var(--accent-soft); color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
}
.user-kebab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.user-kebab svg { display: block; }
tr.is-menu-open { background: var(--surface-alt); }
.user-menu {
position: fixed; z-index: 250; min-width: 220px; max-width: 280px;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r-md); box-shadow: var(--sh-3); padding: 5px;
transform-origin: top right;
animation: user-menu-auf var(--dur) var(--ease-out);
}
.user-menu.nach-oben { transform-origin: bottom right; animation-name: user-menu-auf-oben; }
@keyframes user-menu-auf { from { opacity: 0; transform: scale(.96) translateY(-4px); } to { opacity: 1; transform: none; } }
@keyframes user-menu-auf-oben { from { opacity: 0; transform: scale(.96) translateY(4px); } to { opacity: 1; transform: none; } }
.user-menu-item {
all: unset; box-sizing: border-box;
display: flex; gap: 10px; align-items: flex-start; width: 100%;
padding: 7px 10px; border-radius: var(--r-sm);
color: var(--text); font-size: 13.5px; line-height: 1.35; cursor: pointer;
}
.user-menu-item:hover:not([aria-disabled="true"]), .user-menu-item:focus-visible { background: var(--surface-sunk); }
.user-menu-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.user-menu-ic { flex: none; width: 16px; height: 16px; margin-top: 1px; color: var(--text-muted); }
.user-menu-text { display: grid; gap: 1px; }
.user-menu-sub { font-size: 12px; color: var(--text-subtle); }
.user-menu-item.is-danger, .user-menu-item.is-danger .user-menu-ic { color: var(--red); }
.user-menu-item.is-danger:hover:not([aria-disabled="true"]),
.user-menu-item.is-danger:focus-visible { background: color-mix(in srgb, var(--red) 10%, transparent); }
.user-menu-item[aria-disabled="true"], .user-menu-item[aria-disabled="true"] .user-menu-ic { color: var(--text-subtle); cursor: not-allowed; }
.user-menu-trenner { border: 0; border-top: 1px solid var(--line-soft); margin: 5px 4px; }
.user-menu-confirm { padding: 10px 10px 8px; display: grid; gap: 10px; }
.user-menu-confirm p { margin: 0; font-size: 13px; line-height: 1.4; }
.user-menu-confirm-row { display: flex; gap: 6px; justify-content: flex-end; }
.badge-active { color: var(--green); font-weight: 600; }
.badge-inactive { color: var(--text-muted); }
.badge-warn { color: var(--amber); font-weight: 600; }
@@ -5252,3 +5307,129 @@ td.actions-cell button { margin: 2px; }
scroll-behavior: auto !important;
}
}
/* =========================================================================
LOGIN-SEITE (#loginOverlay, Design "Kalenderwoche" — Eriks Wahl B)
Links Logo und Formular, rechts die laufende Kalenderwoche als große Zahl
mit ihren Arbeitstagen (renderLoginWoche(), js/auth.js). Farben über die
App-Tokens; nur die Zahl hat eigene Tokens (Logo-Hellblau im hellen,
gedämpft im dunklen Thema). Liegt über allem (z-index 400 > .modal-bg 300).
Das Logo ist ein blasses Wasserzeichen über die ganze Seite
(.login-wasserzeichen, Variante E), kein eigenes kleines Logo.
========================================================================= */
.login-seite {
--login-zahl: #94b0cc;
--login-jahr: #116896;
--login-wz: .11;
position: fixed; inset: 0; z-index: 400;
display: none; overflow-y: auto; overflow-x: hidden;
background: var(--bg); color: var(--text);
padding: 0 20px;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) .login-seite { --login-zahl: #4f7695; --login-jahr: var(--accent); --login-wz: .07; }
}
html[data-theme="dark"] .login-seite { --login-zahl: #4f7695; --login-jahr: var(--accent); --login-wz: .07; }
/* Wasserzeichen: ganzes Zeichen sichtbar, an den Rändern angeschnitten. Die
Felder und Tageskacheln haben eigene Flächen und bleiben davor lesbar.
Der Rahmen ist genau so groß wie das Fenster und schneidet alles
Überstehende ab — sonst ragte das Logo über den Rand und machte die
Seite scrollbar. */
.login-wz-rahmen { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 0; }
.login-wasserzeichen {
position: absolute; z-index: 0; left: 50%; top: 50%;
width: min(1500px, 98vw); height: auto; transform: translate(-44%, -47%);
opacity: var(--login-wz); filter: blur(1px);
pointer-events: none; user-select: none;
}
.login-raster {
position: relative; z-index: 1;
width: 100%; max-width: 1180px; min-height: 100%; margin: 0 auto;
display: grid; grid-template-columns: minmax(0, 400px) minmax(0, 1fr);
gap: clamp(32px, 7vw, 110px); align-items: center;
padding-block: calc(48px + env(safe-area-inset-top, 0px)) calc(48px + env(safe-area-inset-bottom, 0px));
}
.login-titel { margin: 0 0 6px; font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-weight: 500; font-size: 30px; letter-spacing: -.01em; }
.login-unter { margin: 0 0 30px; color: var(--text-muted); }
.login-form { display: grid; gap: 18px; }
.login-feld { display: grid; gap: 7px; }
.login-feld label { font-size: 13.5px; font-weight: 600; color: var(--text); }
.login-feld input {
width: 100%; box-sizing: border-box; min-height: 46px; padding: 11px 13px;
font: inherit; font-size: 15px; color: var(--text);
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg);
transition: border-color var(--dur) var(--ease-out), box-shadow var(--dur) var(--ease-out);
}
.login-feld input::placeholder { color: var(--text-subtle); opacity: 1; }
.login-feld input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
.login-feld.hat-fehler input { border-color: var(--red); }
.login-pw { position: relative; }
.login-pw input { padding-right: 96px; }
.login-pw-toggle {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
font: inherit; font-size: 12.5px; font-weight: 600; color: var(--accent);
background: transparent; border: 0; border-radius: var(--r-md); padding: 7px 10px; cursor: pointer;
}
.login-pw-toggle:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.login-pw-toggle:focus-visible, .login-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.login-caps { margin: 0; font-size: 12.5px; font-weight: 600; color: var(--amber); }
.login-fehler {
margin: 0; padding: 10px 12px; border-radius: var(--r-lg); font-size: 13.5px; line-height: 1.45;
color: var(--red); background: color-mix(in srgb, var(--red) 10%, transparent);
}
.login-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
width: 100%; min-height: 48px; margin-top: 4px;
font: inherit; font-size: 15px; font-weight: 650; color: #fff;
background: var(--accent); border: 0; border-radius: var(--r-lg); cursor: pointer;
transition: filter var(--dur) var(--ease-out), transform var(--dur) var(--ease-out);
}
@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .login-btn { color: #0b1c26; } }
html[data-theme="dark"] .login-btn { color: #0b1c26; }
.login-btn:hover { filter: brightness(1.07); }
.login-btn:active { transform: scale(var(--press)); }
.login-btn:disabled { cursor: progress; filter: saturate(.7); }
.login-spinner {
display: none; width: 15px; height: 15px; border-radius: 50%;
border: 2px solid color-mix(in srgb, currentColor 35%, transparent); border-top-color: currentColor;
animation: login-dreh .7s linear infinite;
}
.login-btn.laedt .login-spinner { display: block; }
@keyframes login-dreh { to { transform: rotate(360deg); } }
.login-hilfe { margin: 18px 0 0; font-size: 13px; color: var(--text-muted); }
.login-woche { justify-self: end; text-align: right; animation: login-auf .7s var(--ease-out) both; }
@keyframes login-auf { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.login-kw-label, .login-kw-jahr, .login-kw-zahl, .login-tage b { font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.login-kw-label { display: block; font-weight: 500; font-size: 22px; color: var(--text-muted); letter-spacing: .02em; }
.login-kw-zahl {
display: block; font-weight: 300; font-size: clamp(150px, 21vw, 300px); line-height: .86;
letter-spacing: -.05em; color: var(--login-zahl); font-variant-numeric: tabular-nums;
}
.login-kw-jahr { display: block; font-weight: 500; font-size: 20px; color: var(--login-jahr); }
.login-tage { list-style: none; margin: 30px 0 0; padding: 0; display: flex; gap: 8px; justify-content: flex-end; }
.login-tage li {
display: grid; justify-items: center; gap: 2px; width: 58px; padding: 10px 0 9px;
border-radius: var(--r-lg); background: var(--surface); border: 1px solid var(--line);
}
.login-tage li span { font-size: 12px; font-weight: 600; color: var(--text-muted); }
.login-tage li b { font-weight: 500; font-size: 20px; font-variant-numeric: tabular-nums; }
.login-tage li.ist-heute { background: var(--accent); border-color: var(--accent); }
.login-tage li.ist-heute span, .login-tage li.ist-heute b { color: #fff; }
@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .login-tage li.ist-heute span, html:not([data-theme="light"]) .login-tage li.ist-heute b { color: #0b1c26; } }
html[data-theme="dark"] .login-tage li.ist-heute span, html[data-theme="dark"] .login-tage li.ist-heute b { color: #0b1c26; }
.login-datum { margin: 18px 0 0; color: var(--text-muted); font-size: 15px; }
@media (max-width: 880px) {
.login-raster { grid-template-columns: 1fr; gap: 28px; padding-block: calc(28px + env(safe-area-inset-top, 0px)) 48px; align-content: start; }
.login-woche { order: -1; justify-self: start; text-align: left; }
.login-kw-zahl { font-size: 110px; }
.login-tage { justify-content: flex-start; }
.login-tage li { width: auto; flex: 1; max-width: 58px; }
.login-wasserzeichen { width: 260vw; top: 0; transform: translate(-30%, -10%); }
}
@media (prefers-reduced-motion: reduce) { .login-woche { animation: none; } .login-spinner { animation: none; } }