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
+1210 -112

No files matched your search

+99 -5
View File
@@ -423,10 +423,21 @@ on the JSON endpoints.
forces `must_change_password=1`, deletes all sessions of that user (forces forces `must_change_password=1`, deletes all sessions of that user (forces
re-login), returns the plaintext once as `generatedPassword`. re-login), returns the plaintext once as `generatedPassword`.
- `DELETE /admin/users/:id[?hard=1]` → Admin. Default is a soft-delete - `DELETE /admin/users/:id[?hard=1]` → Admin. Default is a soft-delete
(`active=0` + delete sessions); `?hard=1` permanently deletes the row, but (`active=0` + delete sessions). `?hard=1` (`loescheBenutzerEndgueltig()`,
only if the user is referenced nowhere in `project_files.uploaded_by_user_id` one transaction) permanently deletes the account **and, on Erik's request,
/ `project_file_reviewers.user_id` / `project_file_comments.user_id` (else removes the person from the projects and thereby the Auslastung — but only
409). An admin can never delete their own account (soft or hard). in the current session (latest real week) and the Planung sandbox**; older
sessions are history and stay untouched. Removed there: `project_assign`,
`plan_todo_assignees`, `plan_people`, `week_people`/`_roles`,
`attendance`, matched via `personenKennungenInWoche()` = `users.person_id`
plus every id in that week's `week_people` with the same full name
(legacy short ids like `p_ap`). Those weeks get a new `updated_at`, so a
stale browser gets a 409 instead of writing the person back. Files are no
longer a blocker: uploads/comments stay and show as "Gelöschter Benutzer"
(`COALESCE(..., 'Gelöschter Benutzer')` in the file queries), the person's
review assignments are deleted and pending files' status recomputed.
Response includes counts (`zuordnungen`, `aufgaben`, `pruefauftraege`).
An admin can never delete their own account (soft or hard).
- `GET /users` → Auth (not admin-only). Lists active users - `GET /users` → Auth (not admin-only). Lists active users
(`id, username, displayName, email, personId, department, initials, roles`) (`id, username, displayName, email, personId, department, initials, roles`)
— used both by the reviewer-picker in the file upload UI and by the — used both by the reviewer-picker in the file upload UI and by the
@@ -1176,6 +1187,39 @@ Phase eine Aufgabe in `project.plan.todos`.
180.000 € rechnerisch 172 Arbeitstage für eine Person, und der Plan lief 180.000 € rechnerisch 172 Arbeitstage für eine Person, und der Plan lief
über Jahre. über Jahre.
### Projekte anlegen/löschen — Rolle „Projektleiter" und Papierkorb
- **Projekte anlegen und aus der Projektliste löschen dürfen nur
`projektleiter` und `admin`** (`users.role`; `VALID_ROLES` enthält jetzt
`admin|member|projektleiter|kiosk`). `projektleiter` ist sonst ein
gewöhnliches Mitglied — nicht zu verwechseln mit der Fachrolle
`Projektleitung` (`user_roles`), die keine Berechtigung verleiht. Vergeben
in Admin → Benutzerverwaltung. Bearbeiten bestehender Projekte bleibt
jedem angemeldeten Konto erlaubt.
- **Serverseitig durchgesetzt**, nicht nur UI: Da `/save-week` immer die ganze
Woche schreibt, vergleicht `projektlisteAenderungen()` (`server.js`) den
eingehenden mit dem gespeicherten Stand (Abgleich per `id` oder
`projectUid`) und liefert neue und fehlende Projekte; ohne
`darfProjektlisteAendern(req.user)` → 403 vor jedem Schreiben. Die
Planung-Sandbox ist ausgenommen. Clientseitig blendet
`darfProjektlisteAendern()` (`js/auth.js`) den Löschen-Knopf in
`renderBoardRow()` aus, `renderUserBar()` alle Elemente mit der Klasse
`.nur-projektleitung` (die beiden „+ Projekt"-Knöpfe), und
`openNewProjWizard()` weist zusätzlich ab.
- **Papierkorb** (`deleted_projects`: Schnappschuss des kompletten
Projekt-Objekts als JSON, wer/wann/aus welcher Sitzung). Nach dem
erfolgreichen Speichern legt `legeInPapierkorb()` die gelöschten Projekte
dort ab. `purgeGeloeschteProjekte()` entfernt Einträge nach
`GELOESCHTE_PROJEKTE_TAGE` (30) — beim Start und stündlich.
Admin → **Papierkorb** (`loadAdminPapierkorb()`, `js/admin/admin.js`):
`GET /admin/deleted-projects`, `POST /admin/deleted-projects/:id/restore`
(setzt das Projekt ans Ende seiner Gruppe in der **jüngsten echten
Sitzung**, leert den Sitzungskommentar, wenn es aus einer älteren Sitzung
stammt; 409 bei Nummern-Kollision), `DELETE /admin/deleted-projects/:id`
(sofort endgültig) — alle `requireRole('admin')`. Dateien/Protokolle
hängen an `project_nr` und werden beim Löschen nie angefasst. Nicht in
`IMPORT_TABLES` — ein DB-Import lässt den Papierkorb unberührt.
### Auftragsstatus — von der Anfrage zum Projekt ### Auftragsstatus — von der Anfrage zum Projekt
`projects.auftragsstatus` (`anfrage|angebot|beauftragt|abgeschlossen`, default `projects.auftragsstatus` (`anfrage|angebot|beauftragt|abgeschlossen`, default
@@ -2490,6 +2534,23 @@ gleichzeitig bearbeiten.
`+ Neues Projekt`, `Wandtafel` (the former `Show Mode` entry — see `+ Neues Projekt`, `Wandtafel` (the former `Show Mode` entry — see
"Wandtafel" below). "Wandtafel" below).
### Login-Seite (`#loginOverlay`)
Full-screen page, not a dialog: design "Kalenderwoche" (Erik's pick B from
three Taste-Skill drafts). The logo is a large, pale watermark across the
whole page (`.login-wasserzeichen`, `/logo.png` converted from `logo.ico`;
Erik's pick E from a second comparison of logo placements) — deliberately
no small logo. Left: heading and form; right: the current ISO calendar week as a large
number with Mo-Fr and today highlighted (`renderLoginWoche()` in
`js/auth.js`, called by `showLoginOverlay()`) — the app thinks in weekly
sessions, so the week is what you see before logging in. Form has inline
errors (`.login-feld.hat-fehler`, `#loginError`), a loading state on the
button, "Anzeigen" for the password and a caps-lock hint
(`verdrahteLoginHilfen()`). Styles: "LOGIN-SEITE" block at the end of
`styles.css`, app tokens plus `--login-zahl`/`--login-jahr`. The number uses
the web font Outfit (same Google-Fonts link as the Wandtafel, falls back to
the system font offline). `z-index: 400`, above `.modal-bg` (300).
### Admin (tab `admin`, `js/admin/admin.js`) ### Admin (tab `admin`, `js/admin/admin.js`)
- Everything only admins may do, in one place. Split into **sections, one - Everything only admins may do, in one place. Split into **sections, one
@@ -2514,7 +2575,40 @@ gleichzeitig bearbeiten.
`renderAdminArea()` renders nothing for them. `renderAdminArea()` renders nothing for them.
- Sections: - Sections:
- **Benutzerverwaltung** — accounts, roles, weekly hours, absences - **Benutzerverwaltung** — accounts, roles, weekly hours, absences
(`js/admin-users.js`, `renderAdminUsers()`); reloaded from the server (`js/admin-users.js`, `renderAdminUsers()`). Row actions: "Bearbeiten"
stays a visible button, everything else sits behind a "⋯" menu
(`openUserActionMenu()`, Erik's pick "C" from a three-way comparison):
Passwort zurücksetzen, De-/Aktivieren, and — only for deactivated
accounts — Endgültig löschen. Irreversible actions confirm inside the
menu instead of `confirm()`; the action functions take an
`ohneRueckfrage` flag for that. The menu is appended to `<body>` with
`position: fixed` (the table's horizontally scrolling frame would clip
it) and flips upward when there is no room below.
**Vor- und Nachname** are separate fields and columns
(`users.first_name`/`last_name`, migration `migrateUsersVorNachname()`);
`display_name` stays and is always written as "Vorname Nachname" by
`POST`/`PATCH /admin/users*` — every other place still reads
`display_name`; existing accounts got Vor-/Nachname once from
`display_name` (first word / rest). **The username is the local part of
the e-mail address** (`e.dunin`) — Erik first wanted "Vorname Nachname",
then reverted to this before it ever reached the real database, so
existing usernames were never renamed. In the new-user dialog the
username follows the e-mail field (`benutzernameAusEmail()`), which
itself follows Vor-/Nachname; each field stops following once edited by
hand. **Person-IDs of new accounts come from the Kürzel** (`p_mk`,
`personIdAusKuerzel()`), with a digit appended if taken (`p_mk2`) — the
scheme every existing account and its project data already use. Before,
the dialog derived them from the full name (`p_miriam_k_rner`), which
split one person into two ids (account vs. project data).
`/login` matches `COLLATE NOCASE` and also accepts the full
address (everything from "@" is dropped); `POST /admin/users` checks
uniqueness case-insensitively (409).
The e-mail field
pre-fills `v.nachname@rauschenberg-ing.de` from Vor-/Nachname
(`emailVorschlag()`/`EMAIL_DOMAIN`, `js/utils.js`; umlauts transliterated)
while not edited by hand — same pattern as Person-ID/Kürzel; the edit
dialog and Einstellungen → Mein Konto only show it as a placeholder and
never overwrite a stored address; reloaded from the server
every time the section is entered, since the list changes outside this every time the section is entered, since the list changes outside this
browser and the other masks compute with it (weekly hours → Auslastung). browser and the other masks compute with it (weekly hours → Auslastung).
Marked `wide: true` in `ADMIN_SECTIONS`, which lifts the grid's width cap Marked `wide: true` in `ADMIN_SECTIONS`, which lifts the grid's width cap
+90 -36
View File
@@ -34,12 +34,13 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Projektübersicht</title> <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"> <link rel="stylesheet" href="/plan-styles.css?v=19">
<!-- Fredoka/Nunito Sans: nur für die Wandtafel (js/wand/wand.js), siehe ihren <!-- Fredoka/Nunito Sans: nur für die Wandtafel (js/wand/wand.js), siehe ihren
Dateikopf — fällt ohne Internetzugang automatisch auf die System-Schrift Dateikopf; Outfit: nur für die Kalenderwoche der Login-Seite. Alle fallen
zurück (font-family-Stack in styles.css), bricht also nichts offline. --> ohne Internetzugang automatisch auf die System-Schrift zurück
<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"> (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/pdfmake.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/vfs_fonts.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/vfs_fonts.js"></script>
</head> </head>
@@ -186,7 +187,7 @@
js/pdf.js. Damit fällt die einzige gewerkeübergreifende js/pdf.js. Damit fällt die einzige gewerkeübergreifende
Sitzungsübersicht als PDF weg; die einzelnen Gewerke-Berichte Sitzungsübersicht als PDF weg; die einzelnen Gewerke-Berichte
im Wochenbericht bleiben die Ablage. --> 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> <button class="menu-item sidebar-subitem" onclick="showTab('wand')">Wandtafel</button>
</div> </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="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="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="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> <button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank &amp; Sitzungen</button>
</div> </div>
</div> </div>
@@ -239,7 +241,7 @@
zusätzlich stehen, weil er dort dieselbe Maske öffnet. --> zusätzlich stehen, weil er dort dieselbe Maske öffnet. -->
<div class="bericht-titelzeile"> <div class="bericht-titelzeile">
<div class="section-title" style="margin:0">Projekte</div> <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> </div>
<!-- Die Farbe der Kreise richtet sich nach der ROLLE (siehe <!-- Die Farbe der Kreise richtet sich nach der ROLLE (siehe
.board-avatar.role-* in styles.css), die Füllung nach der Art der .board-avatar.role-* in styles.css), die Füllung nach der Art der
@@ -715,7 +717,7 @@
<thead> <thead>
<tr> <tr>
<th>Benutzername</th> <th>Benutzername</th>
<th>Anzeigename</th> <th>Name</th>
<th>E-Mail</th> <th>E-Mail</th>
<th>Rolle</th> <th>Rolle</th>
<th>Abteilung</th> <th>Abteilung</th>
@@ -731,6 +733,15 @@
</div> </div>
</section> </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"> <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> <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-bg" id="newUserModal" style="display:none">
<div class="modal"> <div class="modal">
<h2>Neuer Benutzer</h2> <h2>Neuer Benutzer</h2>
<div class="form-row-pair">
<div class="form-row"> <div class="form-row">
<label>Benutzername</label> <label for="newUserFirstName">Vorname</label>
<input type="text" id="newUserUsername" placeholder="z. B. mmustermann" autocomplete="off"> <input type="text" id="newUserFirstName" placeholder="z. B. Max" autocomplete="off" oninput="onNewUserNameInput()">
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Anzeigename</label> <label for="newUserLastName">Nachname</label>
<input type="text" id="newUserDisplayName" placeholder="z. B. Max Mustermann" oninput="onNewUserDisplayNameInput()"> <input type="text" id="newUserLastName" placeholder="z. B. Mustermann" autocomplete="off" oninput="onNewUserNameInput()">
</div>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>E-Mail</label> <label for="newUserEmail">E-Mail</label>
<input type="email" id="newUserEmail" placeholder="max.mustermann@beispiel.de"> <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 for="newUserUsername">Benutzername</label>
<input type="text" id="newUserUsername" placeholder="Anfang der E-Mail, z. B. m.mustermann" autocomplete="off">
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Rolle</label> <label>Rolle</label>
<select id="newUserRole" onchange="onUserRoleChange('new')"> <select id="newUserRole" onchange="onUserRoleChange('new')">
<option value="member">Mitglied</option> <option value="member">Mitglied</option>
<option value="projektleiter">Projektleiter (darf Projekte anlegen und löschen)</option>
<option value="admin">Administrator</option> <option value="admin">Administrator</option>
<option value="kiosk">Kiosk (Wandtafel-Konto)</option> <option value="kiosk">Kiosk (Wandtafel-Konto)</option>
</select> </select>
@@ -1563,7 +1582,7 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Kürzel</label> <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> <p class="hint">Kurzform fürs Projektboard (2-4 Zeichen), wird vorgeschlagen und kann angepasst werden.</p>
</div> </div>
<div class="form-row"> <div class="form-row">
@@ -1573,7 +1592,7 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Person-ID</label> <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> <p class="hint">Verknüpft den Account mit einem Eintrag in der Projektbeteiligten-Liste (personIdForName). Bei Kollisionen anpassen.</p>
</div> </div>
<div class="form-row"> <div class="form-row">
@@ -1601,18 +1620,25 @@
<div class="modal"> <div class="modal">
<h2>Benutzer bearbeiten</h2> <h2>Benutzer bearbeiten</h2>
<p class="hint" id="editUserUsernameLabel" style="margin-bottom:10px"></p> <p class="hint" id="editUserUsernameLabel" style="margin-bottom:10px"></p>
<div class="form-row-pair">
<div class="form-row"> <div class="form-row">
<label>Anzeigename</label> <label for="editUserFirstName">Vorname</label>
<input type="text" id="editUserDisplayName"> <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>
<div class="form-row"> <div class="form-row">
<label>E-Mail</label> <label>E-Mail</label>
<input type="email" id="editUserEmail"> <input type="email" id="editUserEmail" placeholder="v.nachname@rauschenberg-ing.de">
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Rolle</label> <label>Rolle</label>
<select id="editUserRole" onchange="onUserRoleChange('edit')"> <select id="editUserRole" onchange="onUserRoleChange('edit')">
<option value="member">Mitglied</option> <option value="member">Mitglied</option>
<option value="projektleiter">Projektleiter (darf Projekte anlegen und löschen)</option>
<option value="admin">Administrator</option> <option value="admin">Administrator</option>
<option value="kiosk">Kiosk (Wandtafel-Konto)</option> <option value="kiosk">Kiosk (Wandtafel-Konto)</option>
</select> </select>
@@ -1748,41 +1774,69 @@
</div> </div>
</div> </div>
<!-- Login-Overlay: blockiert die gesamte App ohne gültige Session (siehe js/auth.js) --> <!-- Login-Seite: blockiert die gesamte App ohne gültige Session (siehe
<div id="loginOverlay" style="display:none;" class="modal-bg"> js/auth.js). Design "Kalenderwoche" (Eriks Wahl B aus dem Vergleich der
<div class="modal"> Login-Entwürfe): links Logo und Formular, rechts die laufende
<h2>Anmelden</h2> Kalenderwoche als große Zahl mit ihren Arbeitstagen — die App denkt in
<form id="loginForm"> Sitzungen je KW. Gefüllt von renderLoginWoche(). Das Logo liegt als
<div class="form-row"><label>Benutzername</label><input type="text" id="loginUsername" autocomplete="username" required></div> blasses Wasserzeichen über die ganze Seite (Variante E aus dem
<div class="form-row"><label>Passwort</label><input type="password" id="loginPassword" autocomplete="current-password" required></div> Logo-Vergleich), ein kleines Logo gibt es bewusst nicht. -->
<div id="loginError" style="color:#c00;"></div> <div id="loginOverlay" class="login-seite" style="display:none;">
<div class="toolbar"><button type="submit">Anmelden</button></div> <div class="login-wz-rahmen" aria-hidden="true"><img class="login-wasserzeichen" src="/logo.png" alt=""></div>
</form> <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>
<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> </div>
<script src="/js/globals.js?v=7"></script> <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/icons.js?v=2"></script>
<script src="/js/ui.js?v=9"></script> <script src="/js/ui.js?v=9"></script>
<script src="/js/date-picker.js?v=5"></script> <script src="/js/date-picker.js?v=5"></script>
<script src="/js/server.js?v=6"></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/store.js?v=10"></script>
<script src="/js/weeks.js?v=11"></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/wochenbericht/wochenbericht.js?v=20"></script>
<script src="/js/people/people.js?v=7"></script> <script src="/js/people/people.js?v=7"></script>
<script src="/js/planung/planung.js?v=1"></script> <script src="/js/planung/planung.js?v=1"></script>
<script src="/js/planchecker/planchecker.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> <script src="/js/load/load.js?v=18"></script>
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil <!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
der Neues-Projekt-Dialog dort die Statusauswahl mit AUFTRAGSSTATUS füllt. --> der Neues-Projekt-Dialog dort die Statusauswahl mit AUFTRAGSSTATUS füllt. -->
<script src="/js/auftrag.js?v=1"></script> <script src="/js/auftrag.js?v=1"></script>
<!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und <!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und
load.js (faktorFormat), steht deshalb dahinter. --> 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> <script src="/js/dashboard/dashboard.js?v=5"></script>
<!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/ <!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/
stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek() stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek()
@@ -1791,9 +1845,9 @@
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
Tab-Ordner, keine neue Abhängigkeit nach vorn. --> Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
<script src="/js/wand/wand.js?v=12"></script> <script src="/js/wand/wand.js?v=12"></script>
<script src="/js/settings/settings.js?v=9"></script> <script src="/js/settings/settings.js?v=10"></script>
<script src="/js/admin/admin.js?v=11"></script> <script src="/js/admin/admin.js?v=12"></script>
<script src="/js/admin-users.js?v=4"></script> <script src="/js/admin-users.js?v=7"></script>
<script src="/js/gantt.js?v=7"></script> <script src="/js/gantt.js?v=7"></script>
<!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in <!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in
project-plan.html; seit der Projektbereich ein Bereich dieser Seite ist, project-plan.html; seit der Projektbereich ein Bereich dieser Seite ist,
+253 -32
View File
@@ -37,6 +37,10 @@
// initialsManuallyEdited: gleiches Muster für das Kürzel-Feld. // initialsManuallyEdited: gleiches Muster für das Kürzel-Feld.
let initialsManuallyEdited = false; 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 // editUserAbsencesCache: zuletzt geladene Abwesenheiten des im
// Bearbeiten-Modal geöffneten Benutzers (für Rendern/Löschen ohne erneuten // Bearbeiten-Modal geöffneten Benutzers (für Rendern/Löschen ohne erneuten
// Request pro Aktion). // Request pro Aktion).
@@ -142,27 +146,24 @@
const statusParts = []; const statusParts = [];
statusParts.push(u.active ? '<span class="badge-active">Aktiv</span>' : '<span class="badge-inactive">Deaktiviert</span>'); 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>'); 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 || ''); const deptLabel = (DEPARTMENTS[u.department] && DEPARTMENTS[u.department].label) || esc(u.department || '');
return `<tr> return `<tr>
<td>${esc(u.username)}</td> <td>${esc(u.username)}</td>
<td>${esc(u.displayName)}</td> <td>${esc(u.displayName)}</td>
<td>${esc(u.email || '')}</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>${deptLabel}</td>
<td>${esc(u.initials || '')}</td> <td>${esc(u.initials || '')}</td>
<td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td> <td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td>
<td>${esc(u.personId || '')}</td> <td>${esc(u.personId || '')}</td>
<td>${statusParts.join('<br>')}</td> <td>${statusParts.join('<br>')}</td>
<td class="actions-cell"> <td class="actions-cell">
<span class="user-aktionen">
<button class="secondary small" onclick="openEditUserModal(${u.id})">Bearbeiten</button> <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 type="button" class="user-kebab" aria-haspopup="menu" aria-expanded="false"
<button class="secondary small" ${isSelf ? 'disabled title="Sie können sich nicht selbst deaktivieren."' : ''} onclick="toggleUserActive(${u.id}, ${u.active})">${toggleLabel}</button> aria-label="Weitere Aktionen für ${esc(u.displayName || u.username)}"
${deleteBtn} onclick="openUserActionMenu(event, ${u.id})">${PUNKTE_SVG}</button>
</span>
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -198,7 +199,8 @@
window.openNewUserModal = function(){ window.openNewUserModal = function(){
document.getElementById('newUserUsername').value = ''; document.getElementById('newUserUsername').value = '';
document.getElementById('newUserDisplayName').value = ''; document.getElementById('newUserFirstName').value = '';
document.getElementById('newUserLastName').value = '';
document.getElementById('newUserEmail').value = ''; document.getElementById('newUserEmail').value = '';
document.getElementById('newUserRole').value = 'member'; document.getElementById('newUserRole').value = 'member';
document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT; document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT;
@@ -211,27 +213,66 @@
document.getElementById('newUserError').textContent = ''; document.getElementById('newUserError').textContent = '';
personIdManuallyEdited = false; personIdManuallyEdited = false;
initialsManuallyEdited = false; initialsManuallyEdited = false;
emailManuallyEdited = false;
usernameManuallyEdited = false;
document.getElementById('newUserModal').style.display = 'flex'; document.getElementById('newUserModal').style.display = 'flex';
document.getElementById('newUserUsername').focus(); document.getElementById('newUserFirstName').focus();
}; };
window.closeNewUserModal = function(){ window.closeNewUserModal = function(){
document.getElementById('newUserModal').style.display = 'none'; document.getElementById('newUserModal').style.display = 'none';
}; };
// onNewUserDisplayNameInput: schlägt beim Tippen des Anzeigenamens eine // onNewUserNameInput: schlägt beim Tippen von Vor-/Nachname E-Mail,
// Person-ID über personIdForName() vor (js/utils.js) — nur solange das Feld // Benutzername (= Anfang der E-Mail, z. B. m.koerner), Person-ID
// nicht bereits manuell bearbeitet wurde (Kollisionsvermeidung laut Plan). // (personIdForName()) und Kürzel vor (js/utils.js) — jedes Feld nur,
window.onNewUserDisplayNameInput = function(){ // solange es nicht bereits manuell bearbeitet wurde.
const name = document.getElementById('newUserDisplayName').value; window.onNewUserNameInput = function(){
if(!personIdManuallyEdited){ const vorname = document.getElementById('newUserFirstName').value;
document.getElementById('newUserPersonId').value = name.trim() ? personIdForName(name) : ''; const nachname = document.getElementById('newUserLastName').value;
} const name = (vorname.trim() + ' ' + nachname.trim()).trim();
if(!initialsManuallyEdited){ if(!initialsManuallyEdited){
document.getElementById('newUserInitials').value = name.trim() ? initialsForName(name) : ''; 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(){ window.onNewUserPwModeChange = function(){
const manual = document.querySelector('input[name="newUserPwMode"]:checked').value === 'manual'; const manual = document.querySelector('input[name="newUserPwMode"]:checked').value === 'manual';
document.getElementById('newUserPassword').style.display = manual ? '' : 'none'; document.getElementById('newUserPassword').style.display = manual ? '' : 'none';
@@ -239,7 +280,9 @@
window.saveNewUser = function(){ window.saveNewUser = function(){
const username = document.getElementById('newUserUsername').value.trim(); 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 email = document.getElementById('newUserEmail').value.trim();
const role = document.getElementById('newUserRole').value; const role = document.getElementById('newUserRole').value;
const department = document.getElementById('newUserDepartment').value; const department = document.getElementById('newUserDepartment').value;
@@ -251,8 +294,12 @@
const errEl = document.getElementById('newUserError'); const errEl = document.getElementById('newUserError');
errEl.textContent = ''; errEl.textContent = '';
if(!username || !displayName){ if(!firstName || !lastName){
errEl.textContent = 'Benutzername und Anzeigename sind erforderlich.'; errEl.textContent = 'Vor- und Nachname sind erforderlich.';
return;
}
if(!username){
errEl.textContent = 'Benutzername ist erforderlich.';
return; return;
} }
if(!department){ 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.'; errEl.textContent = 'Bitte eine Personen-Kennung vergeben — ohne sie kann der Benutzer keiner Sitzung und keinen Aufgaben zugeordnet werden.';
return; 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'){ if(pwMode === 'manual'){
const password = document.getElementById('newUserPassword').value; const password = document.getElementById('newUserPassword').value;
if(password.length < 8){ if(password.length < 8){
@@ -307,8 +354,13 @@
if(!u) return; if(!u) return;
editingUserId = id; editingUserId = id;
document.getElementById('editUserUsernameLabel').textContent = 'Benutzername: ' + u.username + ' (nicht änderbar)'; 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 || ''; document.getElementById('editUserEmail').value = u.email || '';
onEditUserNameInput();
document.getElementById('editUserRole').value = u.role; document.getElementById('editUserRole').value = u.role;
document.getElementById('editUserDepartment').value = u.department || DEFAULT_DEPARTMENT; document.getElementById('editUserDepartment').value = u.department || DEFAULT_DEPARTMENT;
document.getElementById('editUserInitials').value = u.initials || ''; document.getElementById('editUserInitials').value = u.initials || '';
@@ -346,7 +398,8 @@
return; return;
} }
const body = { 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(), email: document.getElementById('editUserEmail').value.trim(),
role: document.getElementById('editUserRole').value, role: document.getElementById('editUserRole').value,
department, department,
@@ -447,8 +500,8 @@
// ---------- Zeilen-Aktionen (Passwort-Reset, Aktivieren/Deaktivieren, Löschen) ---------- // ---------- Zeilen-Aktionen (Passwort-Reset, Aktivieren/Deaktivieren, Löschen) ----------
window.resetUserPassword = function(id, username){ window.resetUserPassword = function(id, username, ohneRueckfrage){
if(!confirm('Passwort für "' + username + '" wirklich zurücksetzen? Bisherige Sitzungen dieses Benutzers werden beendet.')) return; 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 => { apiFetch('/admin/users/' + id + '/reset-password', { method: 'POST' }).then(res => {
if(!res.ok || !res.data || !res.data.ok){ if(!res.ok || !res.data || !res.data.ok){
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')')); alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
@@ -459,9 +512,9 @@
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message)); }).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
}; };
window.toggleUserActive = function(id, currentlyActive){ window.toggleUserActive = function(id, currentlyActive, ohneRueckfrage){
const goingActive = !currentlyActive; 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 => { apiFetch('/admin/users/' + id, { method: 'PATCH', body: JSON.stringify({ active: goingActive }) }).then(res => {
if(!res.ok || !res.data || !res.data.ok){ if(!res.ok || !res.data || !res.data.ok){
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')')); alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
@@ -471,17 +524,177 @@
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message)); }).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
}; };
window.hardDeleteUser = function(id, username){ window.hardDeleteUser = function(id, username, ohneRueckfrage){
if(!confirm('Benutzer "' + username + '" wirklich ENDGÜLTIG löschen? Das kann nicht rückgängig gemacht werden.')) return; 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 => { apiFetch('/admin/users/' + id + '?hard=1', { method: 'DELETE' }).then(res => {
if(!res.ok || !res.data || !res.data.ok){ if(!res.ok || !res.data || !res.data.ok){
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')')); alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
return; return;
} }
loadAndRenderUsers(); 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)); }).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 // personIdManuallyEdited wird gesetzt, sobald der Nutzer das Person-ID-Feld
// selbst bearbeitet (nach dem Laden des DOM gebunden). // selbst bearbeitet (nach dem Laden des DOM gebunden).
document.addEventListener('DOMContentLoaded', function(){ document.addEventListener('DOMContentLoaded', function(){
@@ -491,7 +704,15 @@
} }
const initialsInput = document.getElementById('newUserInitials'); const initialsInput = document.getElementById('newUserInitials');
if(initialsInput){ 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: 'vorlagen', title: 'Aufgaben-Vorlagen' },
{ key: 'planchecker', title: 'Plan-Checker-Merkmale' }, { key: 'planchecker', title: 'Plan-Checker-Merkmale' },
{ key: 'betrieb', title: 'Betrieb & Sicherheit' }, { key: 'betrieb', title: 'Betrieb & Sicherheit' },
{ key: 'papierkorb', title: 'Papierkorb' },
{ key: 'datenbank', title: 'Datenbank & Sitzungen' }, { key: 'datenbank', title: 'Datenbank & Sitzungen' },
]; ];
@@ -136,6 +137,9 @@ function showAdminSection(key){
// aendert sich auch ausserhalb dieses Browsers, und die uebrigen Masken // aendert sich auch ausserhalb dieses Browsers, und die uebrigen Masken
// rechnen mit ihr (Wochenstunden -> Auslastung). // rechnen mit ihr (Wochenstunden -> Auslastung).
if(key === 'benutzer' && typeof renderAdminUsers === 'function') renderAdminUsers(); 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 { try {
history.replaceState(null, '', location.pathname + location.search + '#' + key); history.replaceState(null, '', location.pathname + location.search + '#' + key);
@@ -931,3 +935,73 @@ function loadHolidaysIntoState(){
}) })
.catch(err => console.error('Feiertage neu laden fehlgeschlagen:', err)); .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(){ function renderUserBar(){
const info = document.getElementById('userBarInfo'); const info = document.getElementById('userBarInfo');
if(info && currentUser){ if(info && currentUser){
const roleLabel = currentUser.role === 'admin' ? 'Administrator' const roleLabel = systemRolleText(currentUser.role);
: currentUser.role === 'kiosk' ? 'Kiosk' : 'Mitglied';
info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')'; info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')';
} }
const navAdminSection = document.getElementById('navAdminSection'); const navAdminSection = document.getElementById('navAdminSection');
if(navAdminSection) navAdminSection.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none'; 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 // logout: beendet die Server-Session und lädt die Seite neu (zeigt danach
@@ -48,9 +66,63 @@ async function logout(){
window.location.reload(); window.location.reload();
} }
// showLoginOverlay / hideLoginOverlay: blenden das blockierende Login-Overlay ein/aus. // showLoginOverlay / hideLoginOverlay: blenden die blockierende Login-Seite ein/aus.
function showLoginOverlay(){ function showLoginOverlay(){
renderLoginWoche();
document.getElementById('loginOverlay').style.display = 'flex'; 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(){ function hideLoginOverlay(){
document.getElementById('loginOverlay').style.display = 'none'; document.getElementById('loginOverlay').style.display = 'none';
@@ -174,24 +246,45 @@ async function checkAuthAndInit(onAuthenticated){
return; return;
} }
showLoginOverlay(); showLoginOverlay();
verdrahteLoginHilfen();
const form = document.getElementById('loginForm'); const form = document.getElementById('loginForm');
const errEl = document.getElementById('loginError'); 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){ form.onsubmit = async function(ev){
ev.preventDefault(); ev.preventDefault();
errEl.textContent = ''; errEl.hidden = true;
const username = document.getElementById('loginUsername').value; const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value; 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 { try {
const r = await fetch(baseUrl() + '/login', { const r = await fetch(baseUrl() + '/login', {
method:'POST', headers:{'Content-Type':'application/json'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password })
}); });
const d = await r.json(); 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; currentUser = d.user;
document.getElementById('loginPassword').value = '';
afterAuthenticated(onAuthenticated); afterAuthenticated(onAuthenticated);
} catch(err){ } 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. // einen Ort haben — hier steht nur, was die LISTE betrifft.
html += `<div class="board-row-actions" onclick="event.stopPropagation()"> html += `<div class="board-row-actions" onclick="event.stopPropagation()">
<button type="button" class="secondary small" onclick="openProjectPlan('${p.id}')" title="Projektbereich öffnen">Öffnen</button> <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>`; </div>`;
html += `</div>`; // board-row-main html += `</div>`; // board-row-main
+13 -3
View File
@@ -25,10 +25,20 @@
// delProjectFromList: löscht ein Projekt (per Bestätigungsdialog) in der // delProjectFromList: löscht ein Projekt (per Bestätigungsdialog) in der
// Listen-Ansicht. Parameter `id`: Projekt-ID. Keine Rückgabe; bei gesperrter // 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){ function delProjectFromList(id){
if(guardWeekLocked()) return; 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(); const w = currentWeek();
w.projects = w.projects.filter(p=>p.id!==id); w.projects = w.projects.filter(p=>p.id!==id);
save(); renderProjects(); renderBoard(); save(); renderProjects(); renderBoard();
@@ -160,7 +170,7 @@ function renderProjects(){
</div> </div>
<div class="project-card-footer"> <div class="project-card-footer">
<button class="secondary small" onclick="openLpModal('${p.id}')" ${locked ? 'disabled' : ''}>LP-Aufwand</button> <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>
</div>`; </div>`;
}); });
+7 -1
View File
@@ -51,9 +51,15 @@ const GEWERK_LABELS = { ELT: 'Elektrotechnik', HLS: 'Heizung · Lüftung · Sani
let wizard = null; let wizard = null;
// openNewProjWizard: öffnet den Assistenten mit leerem Zustand. // 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(){ function openNewProjWizard(){
if(guardWeekLocked()) return; if(guardWeekLocked()) return;
if(!darfProjektlisteAendern()){
alert('Projekte anlegen dürfen nur Projektleiter und Administratoren.');
return;
}
wizard = { wizard = {
schritt: 0, schritt: 0,
fertig: null, // angelegtes Projekt, solange der Abschluss steht fertig: null, // angelegtes Projekt, solange der Abschluss steht
+1 -1
View File
@@ -47,7 +47,7 @@ function renderSettingsAccount(){
+ `<div class="settings-kv-row"> + `<div class="settings-kv-row">
<span class="settings-kv-key">E-Mail</span> <span class="settings-kv-key">E-Mail</span>
<span class="settings-email-edit"> <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> <button class="secondary" onclick="submitEmailChange()">Speichern</button>
<span id="settingsEmailError" class="settings-warn" hidden></span> <span id="settingsEmailError" class="settings-warn" hidden></span>
</span> </span>
+44
View File
@@ -104,6 +104,50 @@ function initialsForName(name){
return words.slice(0, 4).map(w => w[0]).join('').toUpperCase(); 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 // formatWeekDate: wandelt ein ISO-Datum (YYYY-MM-DD) in deutsches Format DD.MM.YYYY um
// Parameter: dateString (String). Rückgabe: formatierter String oder leer. // Parameter: dateString (String). Rückgabe: formatierter String oder leer.
function formatWeekDate(dateString){ 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 Seiten-Huelle (.admin-page/.admin-header/.admin-body) sind mit ihr
entfallen, weil der Abschnitt jetzt in der normalen App-Huelle liegt. */ entfallen, weil der Abschnitt jetzt in der normalen App-Huelle liegt. */
.admin-toolbar { margin-bottom: 14px; } .admin-toolbar { margin-bottom: 14px; }
td.actions-cell { white-space: nowrap; } td.actions-cell { white-space: nowrap; width: 1%; text-align: right; }
td.actions-cell button { margin: 2px; }
/* 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-active { color: var(--green); font-weight: 600; }
.badge-inactive { color: var(--text-muted); } .badge-inactive { color: var(--text-muted); }
.badge-warn { color: var(--amber); font-weight: 600; } .badge-warn { color: var(--amber); font-weight: 600; }
@@ -5252,3 +5307,129 @@ td.actions-cell button { margin: 2px; }
scroll-behavior: auto !important; 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; } }
+345 -24
View File
@@ -283,8 +283,18 @@ const VALID_DISCIPLINE_ROLES = new Set(['Planung','Projektleitung','Bauüberwach
// "Wandtafel"): darf nur die lesenden Routen erreichen, die die Wandtafel // "Wandtafel"): darf nur die lesenden Routen erreichen, die die Wandtafel
// braucht (requireAuthOderKiosk weiter unten), überall sonst wie requireAuth // braucht (requireAuthOderKiosk weiter unten), überall sonst wie requireAuth
// abgewiesen — und darf keine Fachrolle tragen (dort geprüft, wo roles // abgewiesen — und darf keine Fachrolle tragen (dort geprüft, wo roles
// validiert wird). // validiert wird). 'projektleiter' ist ein Mitglied, das zusätzlich Projekte
const VALID_ROLES = new Set(['admin', 'member', 'kiosk']); // anlegen und aus der Projektliste löschen darf (siehe
// darfProjektlisteAendern()); sonst gilt
// für das Konto alles wie für 'member'. Nicht zu verwechseln mit der
// Fachrolle 'Projektleitung' (user_roles), die nur die Projektzuordnung
// betrifft und keine Berechtigung verleiht.
const VALID_ROLES = new Set(['admin', 'member', 'projektleiter', 'kiosk']);
// GELOESCHTE_PROJEKTE_TAGE: so lange bleibt ein aus der Projektliste
// gelöschtes Projekt im Papierkorb (deleted_projects) und ist für
// Administratoren wiederherstellbar; danach entfernt es
// purgeGeloeschteProjekte() endgültig.
const GELOESCHTE_PROJEKTE_TAGE = 30;
// VALID_AUFTRAGSSTATUS: der Weg von der Anfrage zum abgeschlossenen Auftrag // VALID_AUFTRAGSSTATUS: der Weg von der Anfrage zum abgeschlossenen Auftrag
// (projects.auftragsstatus). Die Reihenfolge ist die fachliche Abfolge und // (projects.auftragsstatus). Die Reihenfolge ist die fachliche Abfolge und
// steht genauso in AUFTRAGSSTATUS in public/js/auftrag.js — bewusst // steht genauso in AUFTRAGSSTATUS in public/js/auftrag.js — bewusst
@@ -2577,6 +2587,29 @@ function migrateUsersDepartment(){
} }
} }
// migrateUsersVorNachname: Vor- und Nachname als eigene Spalten
// (first_name/last_name). Bis dahin gab es nur den Anzeigenamen; daraus
// ließen sich Benutzername und E-Mail-Adresse nicht verlässlich ableiten (ein
// einzelnes Wort im Feld ergab z. B. nur den Nachnamen). display_name bleibt
// bestehen und wird beim Anlegen/Bearbeiten als "Vorname Nachname" gesetzt —
// alle übrigen Stellen lesen weiter display_name. Bestandskonten bekommen
// Vor-/Nachname einmalig aus display_name (erstes Wort / Rest). Benutzernamen
// bleiben unverändert: Angemeldet wird mit dem Anfang der E-Mail-Adresse
// (z. B. e.dunin), und genau so heißen die Bestandskonten bereits.
function migrateUsersVorNachname(){
const cols = db.prepare("SELECT name FROM pragma_table_info('users')").all().map(r => r.name);
if(cols.includes('first_name') && cols.includes('last_name')) return;
if(!cols.includes('first_name')) db.exec("ALTER TABLE users ADD COLUMN first_name TEXT NOT NULL DEFAULT ''");
if(!cols.includes('last_name')) db.exec("ALTER TABLE users ADD COLUMN last_name TEXT NOT NULL DEFAULT ''");
const rows = db.prepare('SELECT id, display_name FROM users').all();
const setNamen = db.prepare('UPDATE users SET first_name = ?, last_name = ? WHERE id = ?');
rows.forEach(r => {
const woerter = String(r.display_name || '').trim().split(/\s+/).filter(Boolean);
setNamen.run(woerter[0] || '', woerter.slice(1).join(' '), r.id);
});
console.log('Benutzer-Tabelle um Vor-/Nachname ergänzt (' + rows.length + ' Konten aus dem Anzeigenamen befüllt).');
}
// dedupeUserPersonIds: person_id musste bisher nur je Abteilung eindeutig // dedupeUserPersonIds: person_id musste bisher nur je Abteilung eindeutig
// sein (alte people-Tabelle); jetzt identifiziert person_id global einen // sein (alte people-Tabelle); jetzt identifiziert person_id global einen
// Benutzer, daher müssen Bestandskollisionen vor dem UNIQUE INDEX aufgelöst // Benutzer, daher müssen Bestandskollisionen vor dem UNIQUE INDEX aufgelöst
@@ -2692,6 +2725,7 @@ migrateAddProtokollPunktErstelltAm();
migrateAddProtokollPunktStatus(); migrateAddProtokollPunktStatus();
migrateAddProtokollPunktEntfaelltNaechstes(); migrateAddProtokollPunktEntfaelltNaechstes();
migrateUsersDepartment(); migrateUsersDepartment();
migrateUsersVorNachname();
ensureSandboxWeek(); ensureSandboxWeek();
ensureUserPersonIdUniqueIndex(); ensureUserPersonIdUniqueIndex();
migrateRetirePeopleTablesIntoUsers(); migrateRetirePeopleTablesIntoUsers();
@@ -2722,6 +2756,41 @@ try {
console.error('Aufräumen der Sitzungen fehlgeschlagen:', err.message); console.error('Aufräumen der Sitzungen fehlgeschlagen:', err.message);
} }
// ---------- Papierkorb gelöschter Projekte ----------
// deleted_projects: ein Schnappschuss (das vollständige Projekt-Objekt als
// JSON, wie es /load-all liefert — inkl. Terminplan, Beteiligten, LPH) je
// gelöschtem Projekt. Bewusst JSON statt relational: Der Eintrag wird nie
// abgefragt oder ausgewertet, nur 1:1 wieder in eine Sitzung eingesetzt.
// Dateien und Protokolle hängen an project_nr (nicht an der Projekt-ID) und
// werden beim Löschen gar nicht angefasst — nach dem Wiederherstellen sind
// sie deshalb ohne weiteres Zutun wieder da.
db.exec(`CREATE TABLE IF NOT EXISTS deleted_projects (
id TEXT PRIMARY KEY,
week_label TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL DEFAULT '',
project_uid TEXT NOT NULL DEFAULT '',
nr TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL,
deleted_by_user_id INTEGER NOT NULL DEFAULT 0,
deleted_by_name TEXT NOT NULL DEFAULT '',
deleted_at TEXT NOT NULL
)`);
// purgeGeloeschteProjekte: entfernt Papierkorb-Einträge, die älter als
// GELOESCHTE_PROJEKTE_TAGE sind. Läuft beim Start und stündlich mit dem
// Sitzungswechsel (siehe app.listen()).
function purgeGeloeschteProjekte(){
try {
const grenze = new Date(Date.now() - GELOESCHTE_PROJEKTE_TAGE * 24 * 60 * 60 * 1000).toISOString();
const r = db.prepare('DELETE FROM deleted_projects WHERE deleted_at < ?').run(grenze);
if(r && r.changes) console.log('Papierkorb: ' + r.changes + ' Projekt(e) nach ' + GELOESCHTE_PROJEKTE_TAGE + ' Tagen endgültig entfernt.');
} catch(err){
console.error('Aufräumen des Projekt-Papierkorbs fehlgeschlagen:', err.message);
}
}
purgeGeloeschteProjekte();
// ---------- Auth: Sessions, Middleware, Endpunkte ---------- // ---------- Auth: Sessions, Middleware, Endpunkte ----------
const SESSION_COOKIE = 'puebersicht_session'; const SESSION_COOKIE = 'puebersicht_session';
@@ -2880,7 +2949,12 @@ app.post('/login', (req, res) => {
if(loginIsBlocked(clientKey)){ if(loginIsBlocked(clientKey)){
return res.status(429).json({ ok:false, error:'Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.' }); return res.status(429).json({ ok:false, error:'Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.' });
} }
const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username); // Benutzername = Anfang der E-Mail-Adresse (z. B. e.dunin). Wer die
// ganze Adresse eintippt, wird ebenso erkannt (alles ab "@" fällt weg),
// und Groß-/Kleinschreibung spielt keine Rolle ("E.Dunin").
const eingabe = String(username).trim();
const kennung = eingabe.includes('@') ? eingabe.slice(0, eingabe.indexOf('@')) : eingabe;
const row = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(kennung);
// Auch bei unbekanntem Benutzernamen einmal hashen: Sonst verrät die // Auch bei unbekanntem Benutzernamen einmal hashen: Sonst verrät die
// Antwortzeit (scrypt läuft bewusst langsam), welche Konten es gibt. // Antwortzeit (scrypt läuft bewusst langsam), welche Konten es gibt.
const salt = row ? row.password_salt : 'unbekannt'; const salt = row ? row.password_salt : 'unbekannt';
@@ -2989,6 +3063,8 @@ function serializeUserRow(row){
id: row.id, id: row.id,
username: row.username, username: row.username,
displayName: row.display_name, displayName: row.display_name,
firstName: row.first_name || '',
lastName: row.last_name || '',
email: row.email, email: row.email,
role: row.role, role: row.role,
personId: row.person_id, personId: row.person_id,
@@ -3045,9 +3121,16 @@ app.get('/admin/users/:id/absences', requireRole('admin'), (req, res) => {
// wird 1:1 vom Client übernommen (Berechnung/Vorschau via personIdForName() // wird 1:1 vom Client übernommen (Berechnung/Vorschau via personIdForName()
// passiert clientseitig). // passiert clientseitig).
app.post('/admin/users', requireRole('admin'), (req, res) => { app.post('/admin/users', requireRole('admin'), (req, res) => {
const { username, displayName, email, role, personId, department, initials, capacityHours, roles, password, generatePassword } = req.body || {}; const { username, email, role, personId, department, initials, capacityHours, roles, password, generatePassword } = req.body || {};
const firstName = String((req.body || {}).firstName || '').trim();
const lastName = String((req.body || {}).lastName || '').trim();
// Anzeigename = "Vorname Nachname"; ältere Clients schicken nur displayName.
const displayName = (firstName || lastName) ? [firstName, lastName].filter(Boolean).join(' ') : String((req.body || {}).displayName || '').trim();
if(!username || !String(username).trim()) return res.status(400).json({ ok: false, error: 'Benutzername erforderlich.' }); if(!username || !String(username).trim()) return res.status(400).json({ ok: false, error: 'Benutzername erforderlich.' });
if(!displayName || !String(displayName).trim()) return res.status(400).json({ ok: false, error: 'Anzeigename erforderlich.' }); if(!displayName) return res.status(400).json({ ok: false, error: 'Vor- und Nachname erforderlich.' });
if(db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE').get(String(username).trim())){
return res.status(409).json({ ok: false, error: 'Benutzername bereits vergeben.' });
}
const finalRole = role ? String(role) : 'member'; const finalRole = role ? String(role) : 'member';
if(!VALID_ROLES.has(finalRole)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' }); if(!VALID_ROLES.has(finalRole)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' });
if(typeof department !== 'string' || !VALID_DEPARTMENTS.has(department)){ if(typeof department !== 'string' || !VALID_DEPARTMENTS.has(department)){
@@ -3084,9 +3167,9 @@ app.post('/admin/users', requireRole('admin'), (req, res) => {
const now = new Date().toISOString(); const now = new Date().toISOString();
try { try {
const info = db.prepare(`INSERT INTO users const info = db.prepare(`INSERT INTO users
(username, display_name, email, role, person_id, department, initials, capacity_hours, password_hash, password_salt, active, must_change_password, created_at, updated_at) (username, display_name, first_name, last_name, email, role, person_id, department, initials, capacity_hours, password_hash, password_salt, active, must_change_password, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`)
.run(String(username).trim(), String(displayName).trim(), String(email || ''), finalRole, String(personId || ''), department, String(initials || ''), capacity, hash, salt, now, now); .run(String(username).trim(), displayName, firstName, lastName, String(email || ''), finalRole, String(personId || ''), department, String(initials || ''), capacity, hash, salt, now, now);
const insRole = db.prepare('INSERT INTO user_roles (user_id, role, role_order) VALUES (?, ?, ?)'); const insRole = db.prepare('INSERT INTO user_roles (user_id, role, role_order) VALUES (?, ?, ?)');
roleList.forEach((r, idx) => insRole.run(info.lastInsertRowid, r, idx)); roleList.forEach((r, idx) => insRole.run(info.lastInsertRowid, r, idx));
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid); const row = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid);
@@ -3154,7 +3237,16 @@ app.patch('/admin/users/:id', requireRole('admin'), (req, res) => {
const fields = []; const fields = [];
const values = []; const values = [];
if(displayName !== undefined){ fields.push('display_name = ?'); values.push(String(displayName)); } const { firstName, lastName } = req.body || {};
if(firstName !== undefined || lastName !== undefined){
// Vor-/Nachname setzen auch den Anzeigenamen — er ist ihre Verbindung.
const alt = db.prepare('SELECT first_name, last_name FROM users WHERE id = ?').get(id) || {};
const vn = firstName !== undefined ? String(firstName).trim() : (alt.first_name || '');
const nn = lastName !== undefined ? String(lastName).trim() : (alt.last_name || '');
if(!vn && !nn) return res.status(400).json({ ok: false, error: 'Vor- und Nachname erforderlich.' });
fields.push('first_name = ?', 'last_name = ?', 'display_name = ?');
values.push(vn, nn, [vn, nn].filter(Boolean).join(' '));
} else if(displayName !== undefined){ fields.push('display_name = ?'); values.push(String(displayName)); }
if(email !== undefined){ fields.push('email = ?'); values.push(String(email)); } if(email !== undefined){ fields.push('email = ?'); values.push(String(email)); }
if(role !== undefined){ fields.push('role = ?'); values.push(role); } if(role !== undefined){ fields.push('role = ?'); values.push(role); }
if(active !== undefined){ fields.push('active = ?'); values.push(active ? 1 : 0); } if(active !== undefined){ fields.push('active = ?'); values.push(active ? 1 : 0); }
@@ -3215,6 +3307,84 @@ app.post('/admin/users/:id/reset-password', requireRole('admin'), (req, res) =>
// project_file_reviewers.user_id, project_file_comments.user_id — diese Tabellen // project_file_reviewers.user_id, project_file_comments.user_id — diese Tabellen
// sind schon angelegt, aber aktuell noch leer). Ein Admin kann sich selbst // sind schon angelegt, aber aktuell noch leer). Ein Admin kann sich selbst
// weder soft- noch hard-löschen. // weder soft- noch hard-löschen.
// loescheBenutzerEndgueltig: entfernt ein Konto samt seiner Verweise, in
// einer Transaktion. Auf Eriks Wunsch verschwindet die Person dabei aus den
// Projekten und damit aus der Auslastung — aber nur in der AKTUELLEN Sitzung
// (jüngste echte Woche) und in den Planung-Terminplänen. Ältere Sitzungen
// sind Historie (Verlauf im Wochenbericht) und bleiben unverändert.
// Welche Kennungen gemeint sind, bestimmt je Woche personenKennungenInWoche():
// users.person_id UND jede Kennung im Teilnehmerkreis dieser Woche mit
// demselben vollen Namen — Altbestand trägt Kurzkennungen (p_ap statt
// p_alexander_plum), an denen die Zuordnungen hängen; dieselbe Brücke
// über den Namen nutzt kapazitaetsFaktor() in public/js/load/load.js.
// Entfernt werden dort: Teilnehmerkreis
// (week_people/_roles), Anwesenheit, Projektzuordnungen (project_assign —
// daraus rechnet die Auslastung), Beteiligte des Terminplans (plan_people)
// und Aufgaben-Zuständigkeiten (plan_todo_assignees). Die betroffenen
// Wochen bekommen eine neue Revision, damit ein Browser mit altem Stand
// beim nächsten Speichern einen Konflikt bekommt und neu lädt, statt die
// Person wieder hineinzuschreiben.
// Dateien: Hochgeladene Dateien und Kommentare bleiben und erscheinen als
// „Gelöschter Benutzer" (LEFT JOIN + COALESCE in den Datei-Abfragen).
// Prüfaufträge an die Person werden entfernt und der Gesamtstatus der
// betroffenen, noch offenen Dateien neu berechnet (dieselbe Regel wie in
// POST /project-files/:id/review). Sitzungen, Rollen, Abwesenheiten und
// KI-Schlüssel fallen per ON DELETE CASCADE mit weg.
// Rückgabe: Zähler dessen, was entfernt wurde.
function personenKennungenInWoche(row, woche){
const ids = new Set();
if(row.person_id) ids.add(String(row.person_id));
const name = String(row.display_name || '').trim();
if(name){
db.prepare('SELECT person_id FROM week_people WHERE week_label = ? AND full_name = ?').all(woche, name)
.forEach(r => ids.add(r.person_id));
}
return [...ids].filter(Boolean);
}
function loescheBenutzerEndgueltig(row){
const echte = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL).map(r => r.label)
.sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b)));
const wochen = [echte[echte.length - 1], SANDBOX_WEEK_LABEL].filter(Boolean);
const zaehler = { zuordnungen: 0, aufgaben: 0, pruefauftraege: 0 };
const jetzt = new Date().toISOString();
db.exec('BEGIN');
try {
wochen.forEach(w => {
const ids = personenKennungenInWoche(row, w);
if(!ids.length) return;
ids.forEach(pid => {
zaehler.zuordnungen += db.prepare('DELETE FROM project_assign WHERE week_label = ? AND person_id = ?').run(w, pid).changes;
zaehler.aufgaben += db.prepare('DELETE FROM plan_todo_assignees WHERE week_label = ? AND person_id = ?').run(w, pid).changes;
['plan_people', 'week_people_roles', 'week_people', 'attendance'].forEach(t => {
db.prepare(`DELETE FROM ${t} WHERE week_label = ? AND person_id = ?`).run(w, pid);
});
});
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, w);
});
const dateien = db.prepare('SELECT file_id FROM project_file_reviewers WHERE user_id = ?').all(row.id).map(r => r.file_id);
zaehler.pruefauftraege = db.prepare('DELETE FROM project_file_reviewers WHERE user_id = ?').run(row.id).changes;
dateien.forEach(fileId => {
const datei = db.prepare('SELECT status FROM project_files WHERE id = ?').get(fileId);
if(!datei || datei.status !== 'pending') return;
const entscheidungen = db.prepare('SELECT decision FROM project_file_reviewers WHERE file_id = ?').all(fileId).map(r => r.decision);
let status = 'pending';
if(entscheidungen.some(d => d === 'rejected')) status = 'rejected';
else if(entscheidungen.length > 0 && entscheidungen.every(d => d === 'approved')) status = 'approved';
if(status !== 'pending') db.prepare('UPDATE project_files SET status = ?, updated_at = ? WHERE id = ?').run(status, jetzt, fileId);
});
db.prepare('DELETE FROM users WHERE id = ?').run(row.id);
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
throw err;
}
return zaehler;
}
app.delete('/admin/users/:id', requireRole('admin'), (req, res) => { app.delete('/admin/users/:id', requireRole('admin'), (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id); const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
@@ -3224,17 +3394,9 @@ app.delete('/admin/users/:id', requireRole('admin'), (req, res) => {
const hard = req.query.hard === '1'; const hard = req.query.hard === '1';
try { try {
if(hard){ if(hard){
const referenced = db.prepare(`SELECT const ergebnis = loescheBenutzerEndgueltig(row);
(SELECT COUNT(*) FROM project_files WHERE uploaded_by_user_id = ?) + console.log('Benutzer endgültig gelöscht:', row.username, ergebnis);
(SELECT COUNT(*) FROM project_file_reviewers WHERE user_id = ?) + return res.json(Object.assign({ ok: true, hard: true }, ergebnis));
(SELECT COUNT(*) FROM project_file_comments WHERE user_id = ?) AS c`)
.get(id, id, id);
if(referenced.c > 0){
return res.status(409).json({ ok: false, error: 'Benutzer wird noch referenziert und kann nicht endgültig gelöscht werden.' });
}
// user_sessions dieses Users werden durch ON DELETE CASCADE automatisch mitgelöscht.
db.prepare('DELETE FROM users WHERE id = ?').run(id);
return res.json({ ok: true, hard: true });
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
db.prepare('UPDATE users SET active = 0, updated_at = ? WHERE id = ?').run(now, id); db.prepare('UPDATE users SET active = 0, updated_at = ? WHERE id = ?').run(now, id);
@@ -3311,9 +3473,30 @@ app.post('/save-week', requireAuth, (req, res) => {
}); });
} }
} }
// Angelegte und gelöschte Projekte erkennen: /save-week schreibt immer
// die ganze Woche, beides ist also nur am Vergleich mit dem gespeicherten
// Stand zu sehen (projektlisteAenderungen()). Nur Projektleiter und
// Administratoren dürfen das (403 sonst, bevor irgendetwas geschrieben
// wird); gelöschte Projekte wandern in den Papierkorb
// (deleted_projects). Die Planung-Sandbox ist ausgenommen — ihre
// Terminpläne sind keine Projekte der Projektliste und werden dort von
// jedem angelegt und gelöscht.
const aenderung = week.label === SANDBOX_WEEK_LABEL
? { neu: [], entfernt: [] } : projektlisteAenderungen(week);
const entfernt = aenderung.entfernt;
if((aenderung.neu.length || entfernt.length) && !darfProjektlisteAendern(req.user)){
return res.status(403).json({
ok: false,
error: aenderung.neu.length
? 'Projekte anlegen dürfen nur Projektleiter und Administratoren.'
: 'Projekte löschen dürfen nur Projektleiter und Administratoren.'
});
}
// `updatedAt` zurückgeben, damit der Client seine lokale Revisions-Basis // `updatedAt` zurückgeben, damit der Client seine lokale Revisions-Basis
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save). // aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
const updatedAt = saveWeekRelational(week); const updatedAt = saveWeekRelational(week);
if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user);
console.log('Saved week:', week.label); console.log('Saved week:', week.label);
return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() }); return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
} catch(err){ } catch(err){
@@ -3322,6 +3505,142 @@ app.post('/save-week', requireAuth, (req, res) => {
} }
}); });
// darfProjektlisteAendern: Systemrollen, die Projekte anlegen und aus der
// Projektliste löschen dürfen. Dieselbe Regel steht clientseitig in
// darfProjektlisteAendern() (public/js/auth.js), dort nur als UI-Gate.
function darfProjektlisteAendern(user){
return !!user && (user.role === 'admin' || user.role === 'projektleiter');
}
// projektlisteAenderungen: vergleicht die eingehende Woche mit dem
// gespeicherten Stand. Ein Projekt gilt als dasselbe, wenn seine ID ODER
// seine projectUid übereinstimmt. Rückgabe:
// neu — eingehende Projekte ohne Gegenstück (angelegt)
// entfernt — vollständige gespeicherte Projekt-Objekte (aus loadWeeks())
// ohne Gegenstück (gelöscht); der teure loadWeeks()-Aufruf läuft
// nur, wenn die günstige Vorprüfung überhaupt eines findet.
// Neue Sitzungen legt nur der Server selbst an (ensureCurrentWeekRollover(),
// ohne /save-week) — eine über /save-week neu eingehende Woche mit Projekten
// zählt deshalb ebenfalls als Anlegen.
function projektlisteAenderungen(week){
const label = String(week.label);
const gespeichert = db.prepare('SELECT project_id, project_uid FROM projects WHERE week_label = ?').all(label);
const eingehend = (Array.isArray(week.projects) ? week.projects : []).filter(Boolean);
const altIds = new Set(gespeichert.map(r => r.project_id));
const altUids = new Set(gespeichert.map(r => r.project_uid).filter(Boolean));
const neu = eingehend.filter(p => !altIds.has(String(p.id || '')) && !(p.projectUid && altUids.has(String(p.projectUid))));
const ids = new Set(eingehend.map(p => String(p.id || '')).filter(Boolean));
const uids = new Set(eingehend.map(p => String(p.projectUid || '')).filter(Boolean));
const fehlend = gespeichert.filter(r => !ids.has(r.project_id) && !(r.project_uid && uids.has(r.project_uid)));
if(!fehlend.length) return { neu, entfernt: [] };
const w = loadWeeks().find(x => x.label === label);
const fehlendIds = new Set(fehlend.map(r => r.project_id));
return { neu, entfernt: w ? (w.projects || []).filter(p => fehlendIds.has(String(p.id))) : [] };
}
// legeInPapierkorb: schreibt je gelöschtem Projekt einen Schnappschuss nach
// deleted_projects. Läuft NACH dem erfolgreichen Speichern der Woche — ein
// fehlgeschlagenes Speichern darf keinen Papierkorb-Eintrag für ein Projekt
// hinterlassen, das in Wahrheit noch da ist.
function legeInPapierkorb(projekte, weekLabel, user){
const ins = db.prepare(`INSERT INTO deleted_projects
(id, week_label, project_id, project_uid, nr, name, data, deleted_by_user_id, deleted_by_name, deleted_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`);
const jetzt = new Date().toISOString();
projekte.forEach(p => {
try {
ins.run(crypto.randomUUID(), String(weekLabel), String(p.id || ''), String(p.projectUid || ''),
String(p.nr || ''), String(p.name || ''), JSON.stringify(p),
user ? Number(user.id) || 0 : 0, user ? String(user.displayName || user.username || '') : '', jetzt);
console.log('Projekt in den Papierkorb gelegt:', ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim());
} catch(err){
console.error('Papierkorb-Eintrag fehlgeschlagen:', err.message);
}
});
}
// Route: Papierkorb — listet die gelöschten Projekte der letzten
// GELOESCHTE_PROJEKTE_TAGE Tage (ohne den Schnappschuss selbst).
app.get('/admin/deleted-projects', requireRole('admin'), (req, res) => {
try {
purgeGeloeschteProjekte();
const rows = db.prepare(`SELECT id, week_label, nr, name, deleted_by_name, deleted_at
FROM deleted_projects ORDER BY deleted_at DESC`).all();
const tagMs = 24 * 60 * 60 * 1000;
res.json({
ok: true,
aufbewahrungTage: GELOESCHTE_PROJEKTE_TAGE,
projekte: rows.map(r => ({
id: r.id, weekLabel: r.week_label, nr: r.nr, name: r.name,
deletedBy: r.deleted_by_name, deletedAt: r.deleted_at,
endgueltigAm: new Date(Date.parse(r.deleted_at) + GELOESCHTE_PROJEKTE_TAGE * tagMs).toISOString()
}))
});
} catch(err){
console.error('Papierkorb laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Papierkorb — stellt ein gelöschtes Projekt wieder her. Ziel ist
// immer die aktuelle (jüngste echte) Sitzung, nicht die, aus der es gelöscht
// wurde: Ältere Sitzungen sind gesperrte Historie. Eine Nummern-Kollision mit
// einem inzwischen neu angelegten Projekt wird mit 409 abgewiesen (Dateien
// und Protokolle hängen an der Nummer und gehörten sonst zwei Projekten).
app.post('/admin/deleted-projects/:id/restore', requireRole('admin'), (req, res) => {
try {
const row = db.prepare('SELECT * FROM deleted_projects WHERE id = ?').get(String(req.params.id));
if(!row) return res.status(404).json({ ok: false, error: 'Eintrag nicht gefunden (evtl. schon wiederhergestellt oder abgelaufen).' });
const echte = loadWeeks().filter(w => w.label !== SANDBOX_WEEK_LABEL)
.sort((a, b) => weekSortKeyServer(a.label).localeCompare(weekSortKeyServer(b.label)));
const ziel = echte[echte.length - 1];
if(!ziel) return res.status(409).json({ ok: false, error: 'Es gibt keine Sitzung, in die das Projekt zurückkehren könnte.' });
const projekt = JSON.parse(row.data);
const projekte = Array.isArray(ziel.projects) ? ziel.projects : (ziel.projects = []);
const nr = String(projekt.nr || '').trim();
if(nr && projekte.some(p => String(p.nr || '').trim() === nr)){
return res.status(409).json({ ok: false, error: 'In der Sitzung "' + ziel.label + '" gibt es bereits ein Projekt mit der Nummer ' + nr + '.' });
}
if(projekt.projectUid && projekte.some(p => p.projectUid === projekt.projectUid)){
return res.status(409).json({ ok: false, error: 'Dieses Projekt steht bereits in der Sitzung "' + ziel.label + '".' });
}
// Neue ID, falls die alte in der Zielsitzung schon vergeben ist (nach
// einem Sitzungswechsel praktisch ausgeschlossen, aber billig abzusichern).
if(!projekt.id || projekte.some(p => p.id === projekt.id)) projekt.id = crypto.randomUUID();
// Ans Ende seiner Gruppe — die alte Position kann inzwischen belegt sein.
const maxOrder = projekte.filter(p => p.group === projekt.group)
.reduce((m, p) => Math.max(m, Number(p.order) || 0), -1);
projekt.order = maxOrder + 1;
// Der Sitzungskommentar gehört zur Sitzung, aus der das Projekt stammt.
if(row.week_label !== ziel.label){
projekt.status = ''; projekt.statusAuthor = ''; projekt.statusAt = '';
projekt.besprochen = false; delete projekt.statusState;
}
projekte.push(projekt);
const updatedAt = saveWeekRelational(ziel);
db.prepare('DELETE FROM deleted_projects WHERE id = ?').run(row.id);
console.log('Projekt aus dem Papierkorb wiederhergestellt:', ((projekt.nr ? projekt.nr + ' ' : '') + (projekt.name || '')).trim(), '→', ziel.label);
res.json({ ok: true, weekLabel: ziel.label, updatedAt });
} catch(err){
console.error('Wiederherstellen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Papierkorb — entfernt einen Eintrag sofort endgültig.
app.delete('/admin/deleted-projects/:id', requireRole('admin'), (req, res) => {
try {
db.prepare('DELETE FROM deleted_projects WHERE id = ?').run(String(req.params.id));
res.json({ ok: true });
} catch(err){
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Löscht eine Woche (fehlende Woche wird toleriert) // Route: Löscht eine Woche (fehlende Woche wird toleriert)
app.post('/delete-week', requireRole('admin'), (req, res) => { app.post('/delete-week', requireRole('admin'), (req, res) => {
const { label } = req.body || {}; const { label } = req.body || {};
@@ -3620,7 +3939,7 @@ app.get('/my-dashboard', requireAuth, (req, res) => {
const pendingReviews = db.prepare(` const pendingReviews = db.prepare(`
SELECT f.id AS fileId, f.project_nr AS projectNr, SELECT f.id AS fileId, f.project_nr AS projectNr,
f.project_name AS projectName, f.original_filename AS originalFilename, f.project_name AS projectName, f.original_filename AS originalFilename,
COALESCE(NULLIF(u.display_name, ''), u.username) AS uploadedBy, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS uploadedBy,
f.created_at AS createdAt f.created_at AS createdAt
FROM project_file_reviewers r FROM project_file_reviewers r
JOIN project_files f ON f.id = r.file_id JOIN project_files f ON f.id = r.file_id
@@ -3873,14 +4192,14 @@ app.get('/project-files', requireAuth, (req, res) => {
try { try {
const projectNr = String(req.query.projectNr || ''); const projectNr = String(req.query.projectNr || '');
const rows = db.prepare(` const rows = db.prepare(`
SELECT f.*, COALESCE(NULLIF(u.display_name, ''), u.username) AS uploadedBy SELECT f.*, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS uploadedBy
FROM project_files f FROM project_files f
LEFT JOIN users u ON u.id = f.uploaded_by_user_id LEFT JOIN users u ON u.id = f.uploaded_by_user_id
WHERE f.project_nr = ? WHERE f.project_nr = ?
ORDER BY f.created_at DESC ORDER BY f.created_at DESC
`).all(projectNr); `).all(projectNr);
const reviewerStmt = db.prepare(` const reviewerStmt = db.prepare(`
SELECT r.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username) AS displayName, r.decision AS decision SELECT r.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS displayName, r.decision AS decision
FROM project_file_reviewers r FROM project_file_reviewers r
LEFT JOIN users u ON u.id = r.user_id LEFT JOIN users u ON u.id = r.user_id
WHERE r.file_id = ? WHERE r.file_id = ?
@@ -3964,7 +4283,7 @@ app.post('/project-files/:id/review', requireAuth, (req, res) => {
app.get('/project-files/:id/comments', requireAuth, (req, res) => { app.get('/project-files/:id/comments', requireAuth, (req, res) => {
try { try {
const rows = db.prepare(` const rows = db.prepare(`
SELECT c.id AS id, c.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username) AS displayName, SELECT c.id AS id, c.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS displayName,
c.body AS body, c.created_at AS createdAt c.body AS body, c.created_at AS createdAt
FROM project_file_comments c FROM project_file_comments c
LEFT JOIN users u ON u.id = c.user_id LEFT JOIN users u ON u.id = c.user_id
@@ -6558,6 +6877,8 @@ const server = app.listen(PORT, () => {
// Reihenfolge weiter oben. // Reihenfolge weiter oben.
ensureCurrentWeekRollover(); ensureCurrentWeekRollover();
setInterval(ensureCurrentWeekRollover, 60 * 60 * 1000); setInterval(ensureCurrentWeekRollover, 60 * 60 * 1000);
// Papierkorb gelöschter Projekte: Einträge nach 30 Tagen entfernen.
setInterval(purgeGeloeschteProjekte, 60 * 60 * 1000);
}); });
server.on('error', (err) => { server.on('error', (err) => {
console.error('Server konnte nicht starten:', err.message); console.error('Server konnte nicht starten:', err.message);