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:
1 parent
93505e0e48
commit
f4fb3c550b
13 files changed
+1210
-112
No files matched your search
@@ -423,10 +423,21 @@ on the JSON endpoints.
|
||||
forces `must_change_password=1`, deletes all sessions of that user (forces
|
||||
re-login), returns the plaintext once as `generatedPassword`.
|
||||
- `DELETE /admin/users/:id[?hard=1]` → Admin. Default is a soft-delete
|
||||
(`active=0` + delete sessions); `?hard=1` permanently deletes the row, but
|
||||
only if the user is referenced nowhere in `project_files.uploaded_by_user_id`
|
||||
/ `project_file_reviewers.user_id` / `project_file_comments.user_id` (else
|
||||
409). An admin can never delete their own account (soft or hard).
|
||||
(`active=0` + delete sessions). `?hard=1` (`loescheBenutzerEndgueltig()`,
|
||||
one transaction) permanently deletes the account **and, on Erik's request,
|
||||
removes the person from the projects and thereby the Auslastung — but only
|
||||
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
|
||||
(`id, username, displayName, email, personId, department, initials, roles`)
|
||||
— 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
|
||||
ü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
|
||||
|
||||
`projects.auftragsstatus` (`anfrage|angebot|beauftragt|abgeschlossen`, default
|
||||
@@ -2490,6 +2534,23 @@ gleichzeitig bearbeiten.
|
||||
`+ Neues Projekt`, `Wandtafel` (the former `Show Mode` entry — see
|
||||
"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`)
|
||||
|
||||
- Everything only admins may do, in one place. Split into **sections, one
|
||||
@@ -2514,7 +2575,40 @@ gleichzeitig bearbeiten.
|
||||
`renderAdminArea()` renders nothing for them.
|
||||
- Sections:
|
||||
- **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
|
||||
browser and the other masks compute with it (weekly hours → Auslastung).
|
||||
Marked `wide: true` in `ADMIN_SECTIONS`, which lifts the grid's width cap
|
||||
|
||||
+90
-36
@@ -34,12 +34,13 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Projektübersicht</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=64">
|
||||
<link rel="stylesheet" href="/styles.css?v=67">
|
||||
<link rel="stylesheet" href="/plan-styles.css?v=19">
|
||||
<!-- Fredoka/Nunito Sans: nur für die Wandtafel (js/wand/wand.js), siehe ihren
|
||||
Dateikopf — fällt ohne Internetzugang automatisch auf die System-Schrift
|
||||
zurück (font-family-Stack in styles.css), bricht also nichts offline. -->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600;700&family=Nunito+Sans:wght@400;600;700;800&display=swap">
|
||||
Dateikopf; Outfit: nur für die Kalenderwoche der Login-Seite. Alle fallen
|
||||
ohne Internetzugang automatisch auf die System-Schrift zurück
|
||||
(font-family-Stack in styles.css), brechen also nichts offline. -->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600;700&family=Outfit:wght@300;500&family=Nunito+Sans:wght@400;600;700;800&display=swap">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/pdfmake.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/vfs_fonts.js"></script>
|
||||
</head>
|
||||
@@ -186,7 +187,7 @@
|
||||
js/pdf.js. Damit fällt die einzige gewerkeübergreifende
|
||||
Sitzungsübersicht als PDF weg; die einzelnen Gewerke-Berichte
|
||||
im Wochenbericht bleiben die Ablage. -->
|
||||
<button class="menu-item sidebar-subitem" onclick="openNewProjWizard()">+ Neues Projekt</button>
|
||||
<button class="menu-item sidebar-subitem nur-projektleitung" onclick="openNewProjWizard()">+ Neues Projekt</button>
|
||||
<button class="menu-item sidebar-subitem" onclick="showTab('wand')">Wandtafel</button>
|
||||
</div>
|
||||
|
||||
@@ -212,6 +213,7 @@
|
||||
<button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button>
|
||||
<button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button>
|
||||
<button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb & 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 & Sitzungen</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,7 +241,7 @@
|
||||
zusätzlich stehen, weil er dort dieselbe Maske öffnet. -->
|
||||
<div class="bericht-titelzeile">
|
||||
<div class="section-title" style="margin:0">Projekte</div>
|
||||
<button type="button" class="secondary small" onclick="openNewProjWizard()">+ Projekt</button>
|
||||
<button type="button" class="secondary small nur-projektleitung" onclick="openNewProjWizard()">+ Projekt</button>
|
||||
</div>
|
||||
<!-- Die Farbe der Kreise richtet sich nach der ROLLE (siehe
|
||||
.board-avatar.role-* in styles.css), die Füllung nach der Art der
|
||||
@@ -715,7 +717,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzername</th>
|
||||
<th>Anzeigename</th>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Rolle</th>
|
||||
<th>Abteilung</th>
|
||||
@@ -731,6 +733,15 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide" id="adminPapierkorbCard" data-admin-section="papierkorb" style="display:none">
|
||||
<h3 class="settings-card-title">Papierkorb <span class="settings-scope">nur Administratoren</span></h3>
|
||||
<p class="hint">Aus der Projektliste gelöschte Projekte — samt Terminplan, Beteiligten und
|
||||
Leistungsphasen. Sie bleiben 30 Tage hier und lassen sich in die aktuelle Sitzung
|
||||
zurückholen; danach werden sie endgültig entfernt. Dateien und Protokolle hängen an der
|
||||
Projektnummer und sind nach dem Wiederherstellen ebenfalls wieder da.</p>
|
||||
<div id="adminPapierkorbList"><p class="hint">Wird geladen…</p></div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card" id="adminDatenbankCard" data-admin-section="datenbank" style="display:none">
|
||||
<h3 class="settings-card-title">Datenbank & Sitzungen <span class="settings-scope">nur Administratoren</span></h3>
|
||||
|
||||
@@ -1536,22 +1547,30 @@
|
||||
<div class="modal-bg" id="newUserModal" style="display:none">
|
||||
<div class="modal">
|
||||
<h2>Neuer Benutzer</h2>
|
||||
<div class="form-row-pair">
|
||||
<div class="form-row">
|
||||
<label>Benutzername</label>
|
||||
<input type="text" id="newUserUsername" placeholder="z. B. mmustermann" autocomplete="off">
|
||||
<label for="newUserFirstName">Vorname</label>
|
||||
<input type="text" id="newUserFirstName" placeholder="z. B. Max" autocomplete="off" oninput="onNewUserNameInput()">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Anzeigename</label>
|
||||
<input type="text" id="newUserDisplayName" placeholder="z. B. Max Mustermann" oninput="onNewUserDisplayNameInput()">
|
||||
<label for="newUserLastName">Nachname</label>
|
||||
<input type="text" id="newUserLastName" placeholder="z. B. Mustermann" autocomplete="off" oninput="onNewUserNameInput()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" id="newUserEmail" placeholder="max.mustermann@beispiel.de">
|
||||
<label for="newUserEmail">E-Mail</label>
|
||||
<input type="email" id="newUserEmail" placeholder="v.nachname@rauschenberg-ing.de">
|
||||
<span class="hint" style="margin:4px 0 0">Der Anfang der Adresse ist zugleich der Benutzername für die Anmeldung.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="newUserUsername">Benutzername</label>
|
||||
<input type="text" id="newUserUsername" placeholder="Anfang der E-Mail, z. B. m.mustermann" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Rolle</label>
|
||||
<select id="newUserRole" onchange="onUserRoleChange('new')">
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="projektleiter">Projektleiter (darf Projekte anlegen und löschen)</option>
|
||||
<option value="admin">Administrator</option>
|
||||
<option value="kiosk">Kiosk (Wandtafel-Konto)</option>
|
||||
</select>
|
||||
@@ -1563,7 +1582,7 @@
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Kürzel</label>
|
||||
<input type="text" id="newUserInitials" placeholder="wird aus dem Anzeigenamen vorgeschlagen" maxlength="4">
|
||||
<input type="text" id="newUserInitials" placeholder="wird aus dem Namen vorgeschlagen" maxlength="4">
|
||||
<p class="hint">Kurzform fürs Projektboard (2-4 Zeichen), wird vorgeschlagen und kann angepasst werden.</p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@@ -1573,7 +1592,7 @@
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Person-ID</label>
|
||||
<input type="text" id="newUserPersonId" placeholder="wird aus dem Anzeigenamen vorgeschlagen">
|
||||
<input type="text" id="newUserPersonId" placeholder="wird aus dem Kürzel gebildet, z. B. p_mm">
|
||||
<p class="hint">Verknüpft den Account mit einem Eintrag in der Projektbeteiligten-Liste (personIdForName). Bei Kollisionen anpassen.</p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@@ -1601,18 +1620,25 @@
|
||||
<div class="modal">
|
||||
<h2>Benutzer bearbeiten</h2>
|
||||
<p class="hint" id="editUserUsernameLabel" style="margin-bottom:10px"></p>
|
||||
<div class="form-row-pair">
|
||||
<div class="form-row">
|
||||
<label>Anzeigename</label>
|
||||
<input type="text" id="editUserDisplayName">
|
||||
<label for="editUserFirstName">Vorname</label>
|
||||
<input type="text" id="editUserFirstName" oninput="onEditUserNameInput()">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="editUserLastName">Nachname</label>
|
||||
<input type="text" id="editUserLastName" oninput="onEditUserNameInput()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" id="editUserEmail">
|
||||
<input type="email" id="editUserEmail" placeholder="v.nachname@rauschenberg-ing.de">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Rolle</label>
|
||||
<select id="editUserRole" onchange="onUserRoleChange('edit')">
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="projektleiter">Projektleiter (darf Projekte anlegen und löschen)</option>
|
||||
<option value="admin">Administrator</option>
|
||||
<option value="kiosk">Kiosk (Wandtafel-Konto)</option>
|
||||
</select>
|
||||
@@ -1748,41 +1774,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login-Overlay: blockiert die gesamte App ohne gültige Session (siehe js/auth.js) -->
|
||||
<div id="loginOverlay" style="display:none;" class="modal-bg">
|
||||
<div class="modal">
|
||||
<h2>Anmelden</h2>
|
||||
<form id="loginForm">
|
||||
<div class="form-row"><label>Benutzername</label><input type="text" id="loginUsername" autocomplete="username" required></div>
|
||||
<div class="form-row"><label>Passwort</label><input type="password" id="loginPassword" autocomplete="current-password" required></div>
|
||||
<div id="loginError" style="color:#c00;"></div>
|
||||
<div class="toolbar"><button type="submit">Anmelden</button></div>
|
||||
</form>
|
||||
<!-- Login-Seite: blockiert die gesamte App ohne gültige Session (siehe
|
||||
js/auth.js). Design "Kalenderwoche" (Eriks Wahl B aus dem Vergleich der
|
||||
Login-Entwürfe): links Logo und Formular, rechts die laufende
|
||||
Kalenderwoche als große Zahl mit ihren Arbeitstagen — die App denkt in
|
||||
Sitzungen je KW. Gefüllt von renderLoginWoche(). Das Logo liegt als
|
||||
blasses Wasserzeichen über die ganze Seite (Variante E aus dem
|
||||
Logo-Vergleich), ein kleines Logo gibt es bewusst nicht. -->
|
||||
<div id="loginOverlay" class="login-seite" style="display:none;">
|
||||
<div class="login-wz-rahmen" aria-hidden="true"><img class="login-wasserzeichen" src="/logo.png" alt=""></div>
|
||||
<main class="login-raster" aria-label="Anmeldung bei der Projektübersicht">
|
||||
<section class="login-links">
|
||||
<h1 class="login-titel">Projektübersicht</h1>
|
||||
<p class="login-unter">Melde dich mit dem Anfang deiner E-Mail-Adresse an.</p>
|
||||
<form id="loginForm" class="login-form" novalidate>
|
||||
<div class="login-feld">
|
||||
<label for="loginUsername">Benutzername</label>
|
||||
<input type="text" id="loginUsername" name="username" autocomplete="username" placeholder="z. B. m.mustermann" spellcheck="false" autocapitalize="off">
|
||||
</div>
|
||||
<div class="login-feld">
|
||||
<label for="loginPassword">Passwort</label>
|
||||
<div class="login-pw">
|
||||
<input type="password" id="loginPassword" name="password" autocomplete="current-password">
|
||||
<button type="button" id="loginPwToggle" class="login-pw-toggle" aria-pressed="false" aria-controls="loginPassword">Anzeigen</button>
|
||||
</div>
|
||||
<p id="loginCaps" class="login-caps" hidden>Die Feststelltaste ist aktiv.</p>
|
||||
</div>
|
||||
<p id="loginError" class="login-fehler" role="alert" hidden></p>
|
||||
<button type="submit" id="loginSubmit" class="login-btn"><span class="login-spinner" aria-hidden="true"></span><span class="login-btn-text">Anmelden</span></button>
|
||||
</form>
|
||||
<p class="login-hilfe">Passwort vergessen? Ein Administrator setzt es dir zurück.</p>
|
||||
</section>
|
||||
<section class="login-woche" aria-label="Laufende Kalenderwoche">
|
||||
<span class="login-kw-label">Kalenderwoche</span>
|
||||
<span class="login-kw-zahl" id="loginKwZahl"></span>
|
||||
<span class="login-kw-jahr" id="loginKwJahr"></span>
|
||||
<ol class="login-tage" id="loginTage"></ol>
|
||||
<p class="login-datum"><span id="loginDatum"></span>. Die Sitzung dieser Woche ist geöffnet.</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/globals.js?v=7"></script>
|
||||
<script src="/js/utils.js?v=18"></script>
|
||||
<script src="/js/utils.js?v=19"></script>
|
||||
<script src="/js/icons.js?v=2"></script>
|
||||
<script src="/js/ui.js?v=9"></script>
|
||||
<script src="/js/date-picker.js?v=5"></script>
|
||||
<script src="/js/server.js?v=6"></script>
|
||||
<script src="/js/auth.js?v=4"></script>
|
||||
<script src="/js/auth.js?v=6"></script>
|
||||
<script src="/js/store.js?v=10"></script>
|
||||
<script src="/js/weeks.js?v=11"></script>
|
||||
<script src="/js/board/board.js?v=30"></script>
|
||||
<script src="/js/board/board.js?v=31"></script>
|
||||
<script src="/js/wochenbericht/wochenbericht.js?v=20"></script>
|
||||
<script src="/js/people/people.js?v=7"></script>
|
||||
<script src="/js/planung/planung.js?v=1"></script>
|
||||
<script src="/js/planchecker/planchecker.js?v=1"></script>
|
||||
<script src="/js/projects/projects.js?v=7"></script>
|
||||
<script src="/js/projects/projects.js?v=8"></script>
|
||||
<script src="/js/load/load.js?v=18"></script>
|
||||
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
|
||||
der Neues-Projekt-Dialog dort die Statusauswahl mit AUFTRAGSSTATUS füllt. -->
|
||||
<script src="/js/auftrag.js?v=1"></script>
|
||||
<!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und
|
||||
load.js (faktorFormat), steht deshalb dahinter. -->
|
||||
<script src="/js/projekt-wizard.js?v=1"></script>
|
||||
<script src="/js/projekt-wizard.js?v=2"></script>
|
||||
<script src="/js/dashboard/dashboard.js?v=5"></script>
|
||||
<!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/
|
||||
stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek()
|
||||
@@ -1791,9 +1845,9 @@
|
||||
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
|
||||
Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
|
||||
<script src="/js/wand/wand.js?v=12"></script>
|
||||
<script src="/js/settings/settings.js?v=9"></script>
|
||||
<script src="/js/admin/admin.js?v=11"></script>
|
||||
<script src="/js/admin-users.js?v=4"></script>
|
||||
<script src="/js/settings/settings.js?v=10"></script>
|
||||
<script src="/js/admin/admin.js?v=12"></script>
|
||||
<script src="/js/admin-users.js?v=7"></script>
|
||||
<script src="/js/gantt.js?v=7"></script>
|
||||
<!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in
|
||||
project-plan.html; seit der Projektbereich ein Bereich dieser Seite ist,
|
||||
|
||||
+253
-32
@@ -37,6 +37,10 @@
|
||||
|
||||
// initialsManuallyEdited: gleiches Muster für das Kürzel-Feld.
|
||||
let initialsManuallyEdited = false;
|
||||
// emailManuallyEdited: wie oben, für den E-Mail-Vorschlag (emailVorschlag()).
|
||||
let emailManuallyEdited = false;
|
||||
// usernameManuallyEdited: wie oben, für den Benutzernamen ("Vorname Nachname").
|
||||
let usernameManuallyEdited = false;
|
||||
// editUserAbsencesCache: zuletzt geladene Abwesenheiten des im
|
||||
// Bearbeiten-Modal geöffneten Benutzers (für Rendern/Löschen ohne erneuten
|
||||
// Request pro Aktion).
|
||||
@@ -142,27 +146,24 @@
|
||||
const statusParts = [];
|
||||
statusParts.push(u.active ? '<span class="badge-active">Aktiv</span>' : '<span class="badge-inactive">Deaktiviert</span>');
|
||||
if(u.mustChangePassword) statusParts.push('<span class="badge-warn">Passwortwechsel erforderlich</span>');
|
||||
const toggleLabel = u.active ? 'Deaktivieren' : 'Aktivieren';
|
||||
const isSelf = currentUser && currentUser.id === u.id;
|
||||
const deleteBtn = (!u.active)
|
||||
? `<button class="danger small" ${isSelf ? 'disabled' : ''} onclick="hardDeleteUser(${u.id}, '${escJs(u.username)}')">Endgültig löschen</button>`
|
||||
: '';
|
||||
const deptLabel = (DEPARTMENTS[u.department] && DEPARTMENTS[u.department].label) || esc(u.department || '');
|
||||
return `<tr>
|
||||
<td>${esc(u.username)}</td>
|
||||
<td>${esc(u.displayName)}</td>
|
||||
<td>${esc(u.email || '')}</td>
|
||||
<td>${u.role === 'admin' ? 'Administrator' : u.role === 'kiosk' ? 'Kiosk' : 'Mitglied'}</td>
|
||||
<td>${esc(systemRolleText(u.role))}</td>
|
||||
<td>${deptLabel}</td>
|
||||
<td>${esc(u.initials || '')}</td>
|
||||
<td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td>
|
||||
<td>${esc(u.personId || '')}</td>
|
||||
<td>${statusParts.join('<br>')}</td>
|
||||
<td class="actions-cell">
|
||||
<span class="user-aktionen">
|
||||
<button class="secondary small" onclick="openEditUserModal(${u.id})">Bearbeiten</button>
|
||||
<button class="secondary small" onclick="resetUserPassword(${u.id}, '${escJs(u.username)}')">Passwort zurücksetzen</button>
|
||||
<button class="secondary small" ${isSelf ? 'disabled title="Sie können sich nicht selbst deaktivieren."' : ''} onclick="toggleUserActive(${u.id}, ${u.active})">${toggleLabel}</button>
|
||||
${deleteBtn}
|
||||
<button type="button" class="user-kebab" aria-haspopup="menu" aria-expanded="false"
|
||||
aria-label="Weitere Aktionen für ${esc(u.displayName || u.username)}"
|
||||
onclick="openUserActionMenu(event, ${u.id})">${PUNKTE_SVG}</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -198,7 +199,8 @@
|
||||
|
||||
window.openNewUserModal = function(){
|
||||
document.getElementById('newUserUsername').value = '';
|
||||
document.getElementById('newUserDisplayName').value = '';
|
||||
document.getElementById('newUserFirstName').value = '';
|
||||
document.getElementById('newUserLastName').value = '';
|
||||
document.getElementById('newUserEmail').value = '';
|
||||
document.getElementById('newUserRole').value = 'member';
|
||||
document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT;
|
||||
@@ -211,27 +213,66 @@
|
||||
document.getElementById('newUserError').textContent = '';
|
||||
personIdManuallyEdited = false;
|
||||
initialsManuallyEdited = false;
|
||||
emailManuallyEdited = false;
|
||||
usernameManuallyEdited = false;
|
||||
document.getElementById('newUserModal').style.display = 'flex';
|
||||
document.getElementById('newUserUsername').focus();
|
||||
document.getElementById('newUserFirstName').focus();
|
||||
};
|
||||
|
||||
window.closeNewUserModal = function(){
|
||||
document.getElementById('newUserModal').style.display = 'none';
|
||||
};
|
||||
|
||||
// onNewUserDisplayNameInput: schlägt beim Tippen des Anzeigenamens eine
|
||||
// Person-ID über personIdForName() vor (js/utils.js) — nur solange das Feld
|
||||
// nicht bereits manuell bearbeitet wurde (Kollisionsvermeidung laut Plan).
|
||||
window.onNewUserDisplayNameInput = function(){
|
||||
const name = document.getElementById('newUserDisplayName').value;
|
||||
if(!personIdManuallyEdited){
|
||||
document.getElementById('newUserPersonId').value = name.trim() ? personIdForName(name) : '';
|
||||
}
|
||||
// onNewUserNameInput: schlägt beim Tippen von Vor-/Nachname E-Mail,
|
||||
// Benutzername (= Anfang der E-Mail, z. B. m.koerner), Person-ID
|
||||
// (personIdForName()) und Kürzel vor (js/utils.js) — jedes Feld nur,
|
||||
// solange es nicht bereits manuell bearbeitet wurde.
|
||||
window.onNewUserNameInput = function(){
|
||||
const vorname = document.getElementById('newUserFirstName').value;
|
||||
const nachname = document.getElementById('newUserLastName').value;
|
||||
const name = (vorname.trim() + ' ' + nachname.trim()).trim();
|
||||
if(!initialsManuallyEdited){
|
||||
document.getElementById('newUserInitials').value = name.trim() ? initialsForName(name) : '';
|
||||
}
|
||||
personIdAusKuerzel();
|
||||
// Firmen-Adresse nach dem Schema v.nachname@rauschenberg-ing.de
|
||||
// (emailVorschlag(), js/utils.js) — wie Person-ID und Kürzel nur,
|
||||
// solange das Feld nicht von Hand geändert wurde.
|
||||
if(!emailManuallyEdited){
|
||||
document.getElementById('newUserEmail').value = emailVorschlag(vorname, nachname);
|
||||
}
|
||||
benutzernameAusEmail();
|
||||
};
|
||||
|
||||
// personIdAusKuerzel: Person-ID aus dem Kürzel ("MK" → "p_mk") — dasselbe
|
||||
// Schema, das alle Bestandskonten tragen (ihre IDs stammen aus der Zeit,
|
||||
// als die Personenliste über das Kürzel geführt wurde) und an dem ihre
|
||||
// Projektzuordnungen hängen. Vorher kam die ID aus dem vollen Namen
|
||||
// ("p_miriam_k_rner"), sodass neue und alte Konten verschieden aussahen.
|
||||
// Ist die ID schon vergeben, wird eine Ziffer angehängt (p_mk2, p_mk3 …),
|
||||
// denn person_id ist eindeutig. Folgt dem Kürzel, solange die ID nicht
|
||||
// von Hand geändert wurde.
|
||||
function personIdAusKuerzel(){
|
||||
if(personIdManuallyEdited) return;
|
||||
const kuerzel = document.getElementById('newUserInitials').value.trim();
|
||||
const feld = document.getElementById('newUserPersonId');
|
||||
if(!kuerzel){ feld.value = ''; return; }
|
||||
const basis = personIdForName(kuerzel);
|
||||
const vergeben = new Set(usersCache.map(u => u.personId).filter(Boolean));
|
||||
let id = basis, n = 2;
|
||||
while(vergeben.has(id)) id = basis + (n++);
|
||||
feld.value = id;
|
||||
}
|
||||
|
||||
// benutzernameAusEmail: Benutzername = Anfang der E-Mail-Adresse. Folgt
|
||||
// der Adresse auch dann, wenn sie von Hand geändert wird (z. B. bei einem
|
||||
// Doppelnamen) — solange der Benutzername selbst nicht von Hand geändert
|
||||
// wurde.
|
||||
function benutzernameAusEmail(){
|
||||
if(usernameManuallyEdited) return;
|
||||
const adresse = document.getElementById('newUserEmail').value.trim();
|
||||
document.getElementById('newUserUsername').value = adresse.includes('@') ? adresse.slice(0, adresse.indexOf('@')) : adresse;
|
||||
}
|
||||
window.onNewUserPwModeChange = function(){
|
||||
const manual = document.querySelector('input[name="newUserPwMode"]:checked').value === 'manual';
|
||||
document.getElementById('newUserPassword').style.display = manual ? '' : 'none';
|
||||
@@ -239,7 +280,9 @@
|
||||
|
||||
window.saveNewUser = function(){
|
||||
const username = document.getElementById('newUserUsername').value.trim();
|
||||
const displayName = document.getElementById('newUserDisplayName').value.trim();
|
||||
// Mit großem Anfangsbuchstaben gespeichert.
|
||||
const firstName = grossAnfang(document.getElementById('newUserFirstName').value);
|
||||
const lastName = grossAnfang(document.getElementById('newUserLastName').value);
|
||||
const email = document.getElementById('newUserEmail').value.trim();
|
||||
const role = document.getElementById('newUserRole').value;
|
||||
const department = document.getElementById('newUserDepartment').value;
|
||||
@@ -251,8 +294,12 @@
|
||||
const errEl = document.getElementById('newUserError');
|
||||
errEl.textContent = '';
|
||||
|
||||
if(!username || !displayName){
|
||||
errEl.textContent = 'Benutzername und Anzeigename sind erforderlich.';
|
||||
if(!firstName || !lastName){
|
||||
errEl.textContent = 'Vor- und Nachname sind erforderlich.';
|
||||
return;
|
||||
}
|
||||
if(!username){
|
||||
errEl.textContent = 'Benutzername ist erforderlich.';
|
||||
return;
|
||||
}
|
||||
if(!department){
|
||||
@@ -275,7 +322,7 @@
|
||||
errEl.textContent = 'Bitte eine Personen-Kennung vergeben — ohne sie kann der Benutzer keiner Sitzung und keinen Aufgaben zugeordnet werden.';
|
||||
return;
|
||||
}
|
||||
const body = { username, displayName, email, role, personId, department, initials, capacityHours, roles };
|
||||
const body = { username, firstName, lastName, email, role, personId, department, initials, capacityHours, roles };
|
||||
if(pwMode === 'manual'){
|
||||
const password = document.getElementById('newUserPassword').value;
|
||||
if(password.length < 8){
|
||||
@@ -307,8 +354,13 @@
|
||||
if(!u) return;
|
||||
editingUserId = id;
|
||||
document.getElementById('editUserUsernameLabel').textContent = 'Benutzername: ' + u.username + ' (nicht änderbar)';
|
||||
document.getElementById('editUserDisplayName').value = u.displayName || '';
|
||||
// Konten von vor der Umstellung tragen evtl. noch keinen Vor-/Nachnamen:
|
||||
// dann aus dem Anzeigenamen geteilt (erstes Wort / Rest).
|
||||
const teile = String(u.displayName || '').trim().split(/\s+/).filter(Boolean);
|
||||
document.getElementById('editUserFirstName').value = u.firstName || u.lastName ? (u.firstName || '') : (teile[0] || '');
|
||||
document.getElementById('editUserLastName').value = u.firstName || u.lastName ? (u.lastName || '') : teile.slice(1).join(' ');
|
||||
document.getElementById('editUserEmail').value = u.email || '';
|
||||
onEditUserNameInput();
|
||||
document.getElementById('editUserRole').value = u.role;
|
||||
document.getElementById('editUserDepartment').value = u.department || DEFAULT_DEPARTMENT;
|
||||
document.getElementById('editUserInitials').value = u.initials || '';
|
||||
@@ -346,7 +398,8 @@
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
displayName: document.getElementById('editUserDisplayName').value.trim(),
|
||||
firstName: grossAnfang(document.getElementById('editUserFirstName').value),
|
||||
lastName: grossAnfang(document.getElementById('editUserLastName').value),
|
||||
email: document.getElementById('editUserEmail').value.trim(),
|
||||
role: document.getElementById('editUserRole').value,
|
||||
department,
|
||||
@@ -447,8 +500,8 @@
|
||||
|
||||
// ---------- Zeilen-Aktionen (Passwort-Reset, Aktivieren/Deaktivieren, Löschen) ----------
|
||||
|
||||
window.resetUserPassword = function(id, username){
|
||||
if(!confirm('Passwort für "' + username + '" wirklich zurücksetzen? Bisherige Sitzungen dieses Benutzers werden beendet.')) return;
|
||||
window.resetUserPassword = function(id, username, ohneRueckfrage){
|
||||
if(!ohneRueckfrage && !confirm('Passwort für "' + username + '" wirklich zurücksetzen? Bisherige Sitzungen dieses Benutzers werden beendet.')) return;
|
||||
apiFetch('/admin/users/' + id + '/reset-password', { method: 'POST' }).then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
||||
@@ -459,9 +512,9 @@
|
||||
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
||||
};
|
||||
|
||||
window.toggleUserActive = function(id, currentlyActive){
|
||||
window.toggleUserActive = function(id, currentlyActive, ohneRueckfrage){
|
||||
const goingActive = !currentlyActive;
|
||||
if(!goingActive && !confirm('Benutzer wirklich deaktivieren? Bestehende Sitzungen werden sofort beendet.')) return;
|
||||
if(!goingActive && !ohneRueckfrage && !confirm('Benutzer wirklich deaktivieren? Bestehende Sitzungen werden sofort beendet.')) return;
|
||||
apiFetch('/admin/users/' + id, { method: 'PATCH', body: JSON.stringify({ active: goingActive }) }).then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
||||
@@ -471,17 +524,177 @@
|
||||
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
||||
};
|
||||
|
||||
window.hardDeleteUser = function(id, username){
|
||||
if(!confirm('Benutzer "' + username + '" wirklich ENDGÜLTIG löschen? Das kann nicht rückgängig gemacht werden.')) return;
|
||||
window.hardDeleteUser = function(id, username, ohneRueckfrage){
|
||||
if(!ohneRueckfrage && !confirm('Benutzer "' + username + '" wirklich ENDGÜLTIG löschen? Die Person wird auch aus allen Projekten und Aufgaben der aktuellen Sitzung entfernt. Das kann nicht rückgängig gemacht werden.')) return;
|
||||
apiFetch('/admin/users/' + id + '?hard=1', { method: 'DELETE' }).then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
||||
return;
|
||||
}
|
||||
loadAndRenderUsers();
|
||||
// Der Server hat die Person aus der aktuellen Sitzung entfernt (Projekt-
|
||||
// zuordnungen, Aufgaben, Teilnehmerkreis). Neu laden, damit Projekte
|
||||
// und Auslastung in diesem Browser sofort ohne sie dastehen und das
|
||||
// nächste Speichern nicht auf einem veralteten Stand aufsetzt.
|
||||
if(typeof load === 'function') load(true);
|
||||
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
||||
};
|
||||
|
||||
// ---------- Drei-Punkte-Menü der Zeilen ----------
|
||||
// „Bearbeiten" bleibt als Knopf in der Zeile, die selteneren Aktionen
|
||||
// liegen hinter den drei Punkten (Eriks Wahl „C" aus dem Vergleich der
|
||||
// Menü-Varianten). Was nicht rückgängig zu machen ist — Passwort
|
||||
// zurücksetzen, Deaktivieren, Endgültig löschen — fragt direkt im Menü
|
||||
// nach, statt ein Browser-Dialogfenster zu öffnen.
|
||||
//
|
||||
// Das Menü hängt an <body> und steht `position: fixed`: Die Tabelle liegt
|
||||
// in einem quer scrollbaren Rahmen, der ein darin verankertes Menü
|
||||
// abschneiden würde. Reicht der Platz unter dem Knopf nicht, klappt es nach
|
||||
// oben auf. Scrollen oder Fenstergröße ändern schließt es.
|
||||
|
||||
const PUNKTE_SVG = '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.2" cy="8" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12.8" cy="8" r="1.4"/></svg>';
|
||||
const MENU_IC = {
|
||||
schluessel: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5.5" cy="10.5" r="3"/><path d="M7.6 8.4L13.5 2.5M11 5l1.8 1.8M12.5 3.5l1.5 1.5"/></svg>',
|
||||
aus: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M4 12L12 4"/></svg>',
|
||||
an: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M5.5 8.2l1.8 1.8 3.4-3.6"/></svg>',
|
||||
muell: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4.5h11M6 4.5V3h4v1.5M4 4.5l.7 8.5h6.6l.7-8.5"/></svg>',
|
||||
};
|
||||
|
||||
// offenesMenu: { el, knopf, zeile, u } solange ein Menü offen ist.
|
||||
let offenesMenu = null;
|
||||
|
||||
function menuEintrag(ic, text, aktion, opts){
|
||||
opts = opts || {};
|
||||
return `<button type="button" role="menuitem" class="user-menu-item${opts.gefahr ? ' is-danger' : ''}"
|
||||
data-aktion="${aktion}" ${opts.gesperrt ? 'aria-disabled="true"' : ''}>
|
||||
${MENU_IC[ic]}<span class="user-menu-text"><span>${esc(text)}</span>${opts.unter ? `<span class="user-menu-sub">${esc(opts.unter)}</span>` : ''}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function menuInhalt(u){
|
||||
const isSelf = currentUser && currentUser.id === u.id;
|
||||
let h = menuEintrag('schluessel', 'Passwort zurücksetzen', 'passwort');
|
||||
h += u.active
|
||||
? menuEintrag('aus', 'Deaktivieren', 'deaktivieren', { gesperrt: isSelf, unter: isSelf ? 'Eigenes Konto' : '' })
|
||||
: menuEintrag('an', 'Aktivieren', 'aktivieren');
|
||||
if(!u.active){
|
||||
h += '<hr class="user-menu-trenner">'
|
||||
+ menuEintrag('muell', 'Endgültig löschen', 'loeschen', { gefahr: true, gesperrt: isSelf });
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
const RUECKFRAGEN = {
|
||||
passwort: u => ({ frage: 'Passwort von <b>' + esc(u.displayName || u.username) + '</b> zurücksetzen? Alle Sitzungen werden abgemeldet.', knopf: 'Zurücksetzen' }),
|
||||
deaktivieren: u => ({ frage: '<b>' + esc(u.displayName || u.username) + '</b> deaktivieren? Alle Sitzungen werden sofort abgemeldet.', knopf: 'Deaktivieren' }),
|
||||
loeschen: u => ({ frage: '<b>' + esc(u.displayName || u.username) + '</b> endgültig löschen? Die Person wird auch aus allen Projekten und Aufgaben der aktuellen Sitzung entfernt. Das lässt sich nicht rückgängig machen.', knopf: 'Löschen' }),
|
||||
};
|
||||
|
||||
function menuFokusziele(){
|
||||
return offenesMenu ? Array.from(offenesMenu.el.querySelectorAll('button:not([aria-disabled="true"])')) : [];
|
||||
}
|
||||
|
||||
function menuPositionieren(){
|
||||
if(!offenesMenu) return;
|
||||
const { el, knopf } = offenesMenu;
|
||||
const r = knopf.getBoundingClientRect();
|
||||
const m = el.getBoundingClientRect();
|
||||
const nachOben = (window.innerHeight - r.bottom) < m.height + 12 && r.top > m.height + 12;
|
||||
el.classList.toggle('nach-oben', nachOben);
|
||||
el.style.top = (nachOben ? r.top - m.height - 6 : r.bottom + 6) + 'px';
|
||||
el.style.left = Math.max(8, Math.min(r.right - m.width, window.innerWidth - m.width - 8)) + 'px';
|
||||
}
|
||||
|
||||
function schliesseUserMenu(fokusZurueck){
|
||||
if(!offenesMenu) return;
|
||||
const { el, knopf, zeile } = offenesMenu;
|
||||
offenesMenu = null;
|
||||
el.remove();
|
||||
knopf.setAttribute('aria-expanded', 'false');
|
||||
if(zeile) zeile.classList.remove('is-menu-open');
|
||||
if(fokusZurueck && document.contains(knopf)) knopf.focus();
|
||||
}
|
||||
|
||||
function zeigeMenuEintraege(){
|
||||
offenesMenu.el.innerHTML = menuInhalt(offenesMenu.u);
|
||||
menuPositionieren();
|
||||
}
|
||||
|
||||
function fuehreAus(aktion, u){
|
||||
schliesseUserMenu(true);
|
||||
if(aktion === 'passwort') resetUserPassword(u.id, u.username, true);
|
||||
else if(aktion === 'deaktivieren') toggleUserActive(u.id, true, true);
|
||||
else if(aktion === 'aktivieren') toggleUserActive(u.id, false, true);
|
||||
else if(aktion === 'loeschen') hardDeleteUser(u.id, u.username, true);
|
||||
}
|
||||
|
||||
// openUserActionMenu: öffnet (bzw. schließt bei erneutem Klick) das Menü
|
||||
// einer Zeile. Per Tastatur geöffnet (event.detail === 0) landet der Fokus
|
||||
// gleich auf dem ersten Eintrag.
|
||||
window.openUserActionMenu = function(event, id){
|
||||
event.stopPropagation();
|
||||
const knopf = event.currentTarget;
|
||||
if(offenesMenu && offenesMenu.knopf === knopf){ schliesseUserMenu(true); return; }
|
||||
schliesseUserMenu(false);
|
||||
const u = usersCache.find(x => x.id === id);
|
||||
if(!u) return;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'user-menu';
|
||||
el.setAttribute('role', 'menu');
|
||||
document.body.appendChild(el);
|
||||
const zeile = knopf.closest('tr');
|
||||
offenesMenu = { el, knopf, zeile, u };
|
||||
knopf.setAttribute('aria-expanded', 'true');
|
||||
if(zeile) zeile.classList.add('is-menu-open');
|
||||
zeigeMenuEintraege();
|
||||
|
||||
el.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const b = e.target.closest('button');
|
||||
if(!b || b.getAttribute('aria-disabled') === 'true') return;
|
||||
if(b.dataset.zurueck){ zeigeMenuEintraege(); const z = menuFokusziele(); if(z[0]) z[0].focus(); return; }
|
||||
if(b.dataset.ausfuehren){ fuehreAus(b.dataset.ausfuehren, u); return; }
|
||||
const aktion = b.dataset.aktion;
|
||||
if(RUECKFRAGEN[aktion]){
|
||||
const r = RUECKFRAGEN[aktion](u);
|
||||
el.innerHTML = `<div class="user-menu-confirm"><p>${r.frage}</p><div class="user-menu-confirm-row">
|
||||
<button type="button" class="secondary small" data-zurueck="1">Abbrechen</button>
|
||||
<button type="button" class="danger small" data-ausfuehren="${aktion}">${esc(r.knopf)}</button>
|
||||
</div></div>`;
|
||||
menuPositionieren();
|
||||
el.querySelector('[data-ausfuehren]').focus();
|
||||
return;
|
||||
}
|
||||
fuehreAus(aktion, u);
|
||||
});
|
||||
|
||||
if(event.detail === 0){ const z = menuFokusziele(); if(z[0]) z[0].focus(); }
|
||||
};
|
||||
|
||||
document.addEventListener('click', () => schliesseUserMenu(false));
|
||||
document.addEventListener('keydown', e => {
|
||||
if(!offenesMenu) return;
|
||||
const ziele = menuFokusziele();
|
||||
const i = ziele.indexOf(document.activeElement);
|
||||
if(e.key === 'Escape'){ e.preventDefault(); schliesseUserMenu(true); }
|
||||
else if(e.key === 'ArrowDown'){ e.preventDefault(); if(ziele.length) ziele[(i + 1) % ziele.length].focus(); }
|
||||
else if(e.key === 'ArrowUp'){ e.preventDefault(); if(ziele.length) ziele[(i - 1 + ziele.length) % ziele.length].focus(); }
|
||||
else if(e.key === 'Tab'){ schliesseUserMenu(false); }
|
||||
});
|
||||
window.addEventListener('resize', () => schliesseUserMenu(false));
|
||||
window.addEventListener('scroll', () => schliesseUserMenu(false), true);
|
||||
|
||||
// onEditUserNameInput: Im Bearbeiten-Dialog wird eine vorhandene Adresse
|
||||
// nie überschrieben; ist das E-Mail-Feld leer, steht der Vorschlag als
|
||||
// Platzhalter darin und folgt Vor-/Nachname.
|
||||
window.onEditUserNameInput = function(){
|
||||
const feld = document.getElementById('editUserEmail');
|
||||
if(!feld) return;
|
||||
feld.placeholder = emailVorschlag(document.getElementById('editUserFirstName').value,
|
||||
document.getElementById('editUserLastName').value) || 'v.nachname@' + EMAIL_DOMAIN;
|
||||
};
|
||||
|
||||
// personIdManuallyEdited wird gesetzt, sobald der Nutzer das Person-ID-Feld
|
||||
// selbst bearbeitet (nach dem Laden des DOM gebunden).
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
@@ -491,7 +704,15 @@
|
||||
}
|
||||
const initialsInput = document.getElementById('newUserInitials');
|
||||
if(initialsInput){
|
||||
initialsInput.addEventListener('input', function(){ initialsManuallyEdited = true; });
|
||||
initialsInput.addEventListener('input', function(){ initialsManuallyEdited = true; personIdAusKuerzel(); });
|
||||
}
|
||||
const emailInput = document.getElementById('newUserEmail');
|
||||
if(emailInput){
|
||||
emailInput.addEventListener('input', function(){ emailManuallyEdited = true; benutzernameAusEmail(); });
|
||||
}
|
||||
const usernameInput = document.getElementById('newUserUsername');
|
||||
if(usernameInput){
|
||||
usernameInput.addEventListener('input', function(){ usernameManuallyEdited = true; });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ const ADMIN_SECTIONS = [
|
||||
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen' },
|
||||
{ key: 'planchecker', title: 'Plan-Checker-Merkmale' },
|
||||
{ key: 'betrieb', title: 'Betrieb & Sicherheit' },
|
||||
{ key: 'papierkorb', title: 'Papierkorb' },
|
||||
{ key: 'datenbank', title: 'Datenbank & Sitzungen' },
|
||||
];
|
||||
|
||||
@@ -136,6 +137,9 @@ function showAdminSection(key){
|
||||
// aendert sich auch ausserhalb dieses Browsers, und die uebrigen Masken
|
||||
// rechnen mit ihr (Wochenstunden -> Auslastung).
|
||||
if(key === 'benutzer' && typeof renderAdminUsers === 'function') renderAdminUsers();
|
||||
// Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso
|
||||
// frisch holen.
|
||||
if(key === 'papierkorb') loadAdminPapierkorb();
|
||||
|
||||
try {
|
||||
history.replaceState(null, '', location.pathname + location.search + '#' + key);
|
||||
@@ -931,3 +935,73 @@ function loadHolidaysIntoState(){
|
||||
})
|
||||
.catch(err => console.error('Feiertage neu laden fehlgeschlagen:', err));
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Papierkorb gelöschter Projekte
|
||||
// -----------------------------
|
||||
// Ein aus der Projektliste gelöschtes Projekt legt der Server beim Speichern
|
||||
// der Sitzung als Schnappschuss in deleted_projects ab (server.js,
|
||||
// /save-week). Hier lässt es sich 30 Tage lang in die aktuelle Sitzung
|
||||
// zurückholen oder vorzeitig endgültig entfernen.
|
||||
|
||||
// loadAdminPapierkorb: holt die Liste (GET /admin/deleted-projects) und
|
||||
// rendert sie in #adminPapierkorbList.
|
||||
async function loadAdminPapierkorb(){
|
||||
const el = document.getElementById('adminPapierkorbList');
|
||||
if(!el) return;
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/admin/deleted-projects');
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
if(!data.projekte.length){
|
||||
el.innerHTML = '<p class="hint">Der Papierkorb ist leer.</p>';
|
||||
return;
|
||||
}
|
||||
const tagMs = 24 * 60 * 60 * 1000;
|
||||
el.innerHTML = data.projekte.map(p => {
|
||||
const bez = ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() || '(ohne Bezeichnung)';
|
||||
const am = new Date(p.deletedAt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
const rest = Math.max(0, Math.ceil((Date.parse(p.endgueltigAm) - Date.now()) / tagMs));
|
||||
return `<div class="template-admin-row">
|
||||
<span class="template-admin-title">${esc(bez)}
|
||||
<span class="hint" style="margin:0"> · gelöscht ${esc(am)}${p.deletedBy ? ' von ' + esc(p.deletedBy) : ''} · aus ${esc(p.weekLabel)}</span>
|
||||
</span>
|
||||
<span class="sitzung-chip">noch ${rest} ${rest === 1 ? 'Tag' : 'Tage'}</span>
|
||||
<button class="secondary small" onclick="adminProjektWiederherstellen('${esc(p.id)}')">Wiederherstellen</button>
|
||||
<button class="danger small" onclick="adminProjektEndgueltigLoeschen('${esc(p.id)}')" title="Sofort endgültig löschen">×</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(err){
|
||||
el.innerHTML = '<p class="hint">Papierkorb konnte nicht geladen werden: ' + esc(err.message) + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// adminProjektWiederherstellen: holt ein Projekt in die aktuelle Sitzung
|
||||
// zurück. Danach wird neu geladen, damit die Projektliste das Projekt zeigt
|
||||
// und die lokale Revision der Sitzung zum Server passt (sonst meldete das
|
||||
// nächste Speichern einen Konflikt).
|
||||
async function adminProjektWiederherstellen(id){
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/admin/deleted-projects/' + encodeURIComponent(id) + '/restore', { method: 'POST' });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
load();
|
||||
loadAdminPapierkorb();
|
||||
alert('Projekt wiederhergestellt in „' + data.weekLabel + '".');
|
||||
} catch(err){
|
||||
alert('Wiederherstellen fehlgeschlagen: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// adminProjektEndgueltigLoeschen: entfernt einen Papierkorb-Eintrag sofort.
|
||||
async function adminProjektEndgueltigLoeschen(id){
|
||||
if(!confirm('Projekt endgültig löschen? Das lässt sich nicht rückgängig machen.')) return;
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/admin/deleted-projects/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
loadAdminPapierkorb();
|
||||
} catch(err){
|
||||
alert('Löschen fehlgeschlagen: ' + err.message);
|
||||
}
|
||||
}
|
||||
+100
-7
@@ -33,12 +33,30 @@ let currentUser = null;
|
||||
function renderUserBar(){
|
||||
const info = document.getElementById('userBarInfo');
|
||||
if(info && currentUser){
|
||||
const roleLabel = currentUser.role === 'admin' ? 'Administrator'
|
||||
: currentUser.role === 'kiosk' ? 'Kiosk' : 'Mitglied';
|
||||
const roleLabel = systemRolleText(currentUser.role);
|
||||
info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')';
|
||||
}
|
||||
const navAdminSection = document.getElementById('navAdminSection');
|
||||
if(navAdminSection) navAdminSection.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
|
||||
document.querySelectorAll('.nur-projektleitung').forEach(el => { el.hidden = !darfProjektlisteAendern(); });
|
||||
}
|
||||
|
||||
// systemRolleText: Anzeigename einer Systemrolle (users.role). Auch von der
|
||||
// Benutzerverwaltung (js/admin-users.js) genutzt.
|
||||
function systemRolleText(role){
|
||||
return role === 'admin' ? 'Administrator'
|
||||
: role === 'projektleiter' ? 'Projektleiter'
|
||||
: role === 'kiosk' ? 'Kiosk' : 'Mitglied';
|
||||
}
|
||||
|
||||
// darfProjektlisteAendern: ob das angemeldete Konto Projekte anlegen und aus
|
||||
// der Projektliste löschen darf (Projektleiter und Administratoren). Reines
|
||||
// UI-Gate — dieselbe Regel setzt /save-week serverseitig durch
|
||||
// (darfProjektlisteAendern() in server.js), dort mit 403. Knöpfe zum
|
||||
// Anlegen tragen die Klasse .nur-projektleitung und werden von
|
||||
// renderUserBar() ausgeblendet.
|
||||
function darfProjektlisteAendern(){
|
||||
return !!currentUser && (currentUser.role === 'admin' || currentUser.role === 'projektleiter');
|
||||
}
|
||||
|
||||
// logout: beendet die Server-Session und lädt die Seite neu (zeigt danach
|
||||
@@ -48,9 +66,63 @@ async function logout(){
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// showLoginOverlay / hideLoginOverlay: blenden das blockierende Login-Overlay ein/aus.
|
||||
// showLoginOverlay / hideLoginOverlay: blenden die blockierende Login-Seite ein/aus.
|
||||
function showLoginOverlay(){
|
||||
renderLoginWoche();
|
||||
document.getElementById('loginOverlay').style.display = 'flex';
|
||||
const feld = document.getElementById('loginUsername');
|
||||
if(feld && !feld.value) feld.focus();
|
||||
}
|
||||
|
||||
// renderLoginWoche: füllt die rechte Seite der Login-Seite — laufende
|
||||
// Kalenderwoche (getWeekNumber(), js/utils.js), Jahr der ISO-Woche, die
|
||||
// Arbeitstage Mo-Fr mit dem heutigen Tag markiert und das Datum.
|
||||
function renderLoginWoche(){
|
||||
const heute = new Date();
|
||||
const kw = getWeekNumber(heute);
|
||||
// ISO-Jahr: das Jahr des Donnerstags dieser Woche (Ende Dezember/Anfang
|
||||
// Januar weicht es vom Kalenderjahr ab, z. B. KW 01 / 2027 am 30.12.2026).
|
||||
const donnerstag = new Date(heute); donnerstag.setDate(heute.getDate() + 3 - ((heute.getDay() + 6) % 7));
|
||||
const zahl = document.getElementById('loginKwZahl');
|
||||
if(!zahl) return;
|
||||
zahl.textContent = String(kw).padStart(2, '0');
|
||||
document.getElementById('loginKwJahr').textContent = donnerstag.getFullYear();
|
||||
document.getElementById('loginDatum').textContent =
|
||||
heute.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const montag = new Date(heute); montag.setDate(heute.getDate() - ((heute.getDay() + 6) % 7));
|
||||
document.getElementById('loginTage').innerHTML = ['Mo', 'Di', 'Mi', 'Do', 'Fr'].map((n, i) => {
|
||||
const d = new Date(montag); d.setDate(montag.getDate() + i);
|
||||
const istHeute = d.toDateString() === heute.toDateString();
|
||||
return '<li' + (istHeute ? ' class="ist-heute" aria-current="date"' : '') + '><span>' + n + '</span><b>' + d.getDate() + '</b></li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// verdrahteLoginHilfen: „Anzeigen" für das Passwort und der Hinweis auf
|
||||
// eine aktive Feststelltaste. Einmalig.
|
||||
let loginHilfenVerdrahtet = false;
|
||||
function verdrahteLoginHilfen(){
|
||||
if(loginHilfenVerdrahtet) return;
|
||||
loginHilfenVerdrahtet = true;
|
||||
const pw = document.getElementById('loginPassword');
|
||||
const toggle = document.getElementById('loginPwToggle');
|
||||
const caps = document.getElementById('loginCaps');
|
||||
if(toggle) toggle.addEventListener('click', () => {
|
||||
const sichtbar = pw.type === 'text';
|
||||
pw.type = sichtbar ? 'password' : 'text';
|
||||
toggle.textContent = sichtbar ? 'Anzeigen' : 'Verbergen';
|
||||
toggle.setAttribute('aria-pressed', String(!sichtbar));
|
||||
pw.focus();
|
||||
});
|
||||
const capsPruefen = e => { if(caps && e.getModifierState) caps.hidden = !e.getModifierState('CapsLock'); };
|
||||
pw.addEventListener('keydown', capsPruefen);
|
||||
pw.addEventListener('keyup', capsPruefen);
|
||||
pw.addEventListener('blur', () => { if(caps) caps.hidden = true; });
|
||||
['loginUsername', 'loginPassword'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', e => {
|
||||
e.target.closest('.login-feld').classList.remove('hat-fehler');
|
||||
document.getElementById('loginError').hidden = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
function hideLoginOverlay(){
|
||||
document.getElementById('loginOverlay').style.display = 'none';
|
||||
@@ -174,24 +246,45 @@ async function checkAuthAndInit(onAuthenticated){
|
||||
return;
|
||||
}
|
||||
showLoginOverlay();
|
||||
verdrahteLoginHilfen();
|
||||
const form = document.getElementById('loginForm');
|
||||
const errEl = document.getElementById('loginError');
|
||||
const btn = document.getElementById('loginSubmit');
|
||||
const zeigeFehler = (text, feldId) => {
|
||||
errEl.textContent = text;
|
||||
errEl.hidden = false;
|
||||
if(feldId){
|
||||
const feld = document.getElementById(feldId);
|
||||
feld.closest('.login-feld').classList.add('hat-fehler');
|
||||
feld.focus();
|
||||
}
|
||||
};
|
||||
form.onsubmit = async function(ev){
|
||||
ev.preventDefault();
|
||||
errEl.textContent = '';
|
||||
const username = document.getElementById('loginUsername').value;
|
||||
errEl.hidden = true;
|
||||
const username = document.getElementById('loginUsername').value.trim();
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
if(!username) return zeigeFehler('Bitte den Benutzernamen eingeben, z. B. m.mustermann.', 'loginUsername');
|
||||
if(!password) return zeigeFehler('Bitte das Passwort eingeben.', 'loginPassword');
|
||||
btn.disabled = true; btn.classList.add('laedt');
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/login', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const d = await r.json();
|
||||
if(!r.ok || !d.ok){ errEl.textContent = d.error || 'Anmeldung fehlgeschlagen.'; return; }
|
||||
if(!r.ok || !d.ok){
|
||||
zeigeFehler(d.error || 'Anmeldung fehlgeschlagen.', 'loginPassword');
|
||||
document.getElementById('loginPassword').select();
|
||||
return;
|
||||
}
|
||||
currentUser = d.user;
|
||||
document.getElementById('loginPassword').value = '';
|
||||
afterAuthenticated(onAuthenticated);
|
||||
} catch(err){
|
||||
errEl.textContent = 'Verbindung zum Server fehlgeschlagen.';
|
||||
zeigeFehler('Der Server ist nicht erreichbar. Bitte später erneut versuchen.');
|
||||
} finally {
|
||||
btn.disabled = false; btn.classList.remove('laedt');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -157,7 +157,7 @@ function renderBoardRow(p, allPeople, locked, weekLabel, opts){
|
||||
// einen Ort haben — hier steht nur, was die LISTE betrifft.
|
||||
html += `<div class="board-row-actions" onclick="event.stopPropagation()">
|
||||
<button type="button" class="secondary small" onclick="openProjectPlan('${p.id}')" title="Projektbereich öffnen">Öffnen</button>
|
||||
${locked ? '' : `<button type="button" class="danger small" onclick="delProjectFromList('${p.id}')" title="Projekt aus dieser Sitzung löschen">Löschen</button>`}
|
||||
${locked || !darfProjektlisteAendern() ? '' : `<button type="button" class="danger small" onclick="delProjectFromList('${p.id}')" title="Projekt löschen (30 Tage durch Administratoren wiederherstellbar)">Löschen</button>`}
|
||||
</div>`;
|
||||
html += `</div>`; // board-row-main
|
||||
|
||||
|
||||
@@ -25,10 +25,20 @@
|
||||
|
||||
// delProjectFromList: löscht ein Projekt (per Bestätigungsdialog) in der
|
||||
// Listen-Ansicht. Parameter `id`: Projekt-ID. Keine Rückgabe; bei gesperrter
|
||||
// Sitzung abgebrochen.
|
||||
// Sitzung abgebrochen. Nur für Projektleiter und Administratoren
|
||||
// (darfProjektlisteAendern(), js/auth.js; serverseitig in /save-week
|
||||
// durchgesetzt). Der Server legt das Projekt beim Speichern in den
|
||||
// Papierkorb, aus dem es ein Administrator 30 Tage lang zurückholen kann
|
||||
// (Admin → Papierkorb, js/admin/admin.js).
|
||||
function delProjectFromList(id){
|
||||
if(guardWeekLocked()) return;
|
||||
if(!confirm("Projekt aus dieser Sitzung löschen?")) return;
|
||||
if(!darfProjektlisteAendern()){
|
||||
alert('Projekte löschen dürfen nur Projektleiter und Administratoren.');
|
||||
return;
|
||||
}
|
||||
const p = currentWeek().projects.find(x => x.id === id);
|
||||
const bez = p ? ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() : '';
|
||||
if(!confirm('Projekt ' + (bez ? '„' + bez + '" ' : '') + 'löschen?\n\nEin Administrator kann es 30 Tage lang wiederherstellen.')) return;
|
||||
const w = currentWeek();
|
||||
w.projects = w.projects.filter(p=>p.id!==id);
|
||||
save(); renderProjects(); renderBoard();
|
||||
@@ -160,7 +170,7 @@ function renderProjects(){
|
||||
</div>
|
||||
<div class="project-card-footer">
|
||||
<button class="secondary small" onclick="openLpModal('${p.id}')" ${locked ? 'disabled' : ''}>LP-Aufwand</button>
|
||||
<button class="danger small" onclick="delProjectFromList('${p.id}')" ${locked ? 'disabled' : ''}>Löschen</button>
|
||||
<button class="danger small" onclick="delProjectFromList('${p.id}')" ${locked || !darfProjektlisteAendern() ? 'disabled' : ''}>Löschen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
@@ -51,9 +51,15 @@ const GEWERK_LABELS = { ELT: 'Elektrotechnik', HLS: 'Heizung · Lüftung · Sani
|
||||
let wizard = null;
|
||||
|
||||
// openNewProjWizard: öffnet den Assistenten mit leerem Zustand.
|
||||
// Keine Parameter, keine Rückgabe; gesperrte Sitzungen werden abgewiesen.
|
||||
// Keine Parameter, keine Rückgabe; gesperrte Sitzungen werden abgewiesen,
|
||||
// ebenso Konten ohne Rolle Projektleiter/Administrator (die Knöpfe sind für
|
||||
// sie ohnehin ausgeblendet; /save-week weist das Anlegen mit 403 ab).
|
||||
function openNewProjWizard(){
|
||||
if(guardWeekLocked()) return;
|
||||
if(!darfProjektlisteAendern()){
|
||||
alert('Projekte anlegen dürfen nur Projektleiter und Administratoren.');
|
||||
return;
|
||||
}
|
||||
wizard = {
|
||||
schritt: 0,
|
||||
fertig: null, // angelegtes Projekt, solange der Abschluss steht
|
||||
|
||||
@@ -47,7 +47,7 @@ function renderSettingsAccount(){
|
||||
+ `<div class="settings-kv-row">
|
||||
<span class="settings-kv-key">E-Mail</span>
|
||||
<span class="settings-email-edit">
|
||||
<input type="email" id="settingsEmailInput" value="${esc(currentUser.email || '')}" placeholder="name@firma.de">
|
||||
<input type="email" id="settingsEmailInput" value="${esc(currentUser.email || '')}" placeholder="${esc(emailVorschlag(currentUser.displayName) || 'v.nachname@' + EMAIL_DOMAIN)}">
|
||||
<button class="secondary" onclick="submitEmailChange()">Speichern</button>
|
||||
<span id="settingsEmailError" class="settings-warn" hidden></span>
|
||||
</span>
|
||||
|
||||
@@ -104,6 +104,50 @@ function initialsForName(name){
|
||||
return words.slice(0, 4).map(w => w[0]).join('').toUpperCase();
|
||||
}
|
||||
|
||||
// EMAIL_DOMAIN: Domain der Firmen-Adressen, Grundlage von emailVorschlag().
|
||||
const EMAIL_DOMAIN = 'rauschenberg-ing.de';
|
||||
|
||||
// emailVorschlag: schlägt aus einem Namen die Firmen-Adresse nach dem Schema
|
||||
// „Anfangsbuchstabe Vorname . Nachname @rauschenberg-ing.de" vor, klein
|
||||
// geschrieben, Umlaute umschrieben (ä→ae, ß→ss), übrige Sonderzeichen
|
||||
// entfernt: "Miriam Körner" → "m.koerner@rauschenberg-ing.de". Vorname ist
|
||||
// das erste, Nachname das letzte Wort. Nur ein Vorschlag — bleibt
|
||||
// überschreibbar. Parameter: name (String) — oder, wenn `nachname` gegeben
|
||||
// ist, Vorname und Nachname getrennt (dann zählt der ganze Nachname, z. B.
|
||||
// "von Plum" → "vonplum"). Rückgabe: Adresse oder ''.
|
||||
function emailVorschlag(name, nachname){
|
||||
if(nachname !== undefined){
|
||||
const v = String(name || '').trim(), n = String(nachname || '').trim();
|
||||
if(!v || !n) return emailVorschlag((v + ' ' + n).trim());
|
||||
const l = emailVorschlag(v.split(/\s+/)[0] + ' ' + n.replace(/\s+/g, ''));
|
||||
return l;
|
||||
}
|
||||
const teil = s => String(s).toLowerCase()
|
||||
.replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss')
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g,'')
|
||||
.replace(/[^a-z0-9-]/g,'');
|
||||
const woerter = String(name || '').trim().split(/\s+/).map(teil).filter(Boolean);
|
||||
if(woerter.length === 0) return '';
|
||||
const lokal = woerter.length === 1 ? woerter[0] : woerter[0][0] + '.' + woerter[woerter.length - 1];
|
||||
return lokal + '@' + EMAIL_DOMAIN;
|
||||
}
|
||||
|
||||
// benutzernameVorschlag: Benutzername eines neuen Kontos = Anfang der
|
||||
// E-Mail-Adresse, also "Anfangsbuchstabe Vorname . Nachname" in der
|
||||
// Schreibweise von emailVorschlag() ("Miriam Körner" → "m.koerner").
|
||||
// Angemeldet wird mit genau diesem Namen. Rückgabe: String oder ''.
|
||||
function benutzernameVorschlag(vorname, nachname){
|
||||
const adresse = emailVorschlag(vorname, nachname);
|
||||
return adresse ? adresse.slice(0, adresse.indexOf('@')) : '';
|
||||
}
|
||||
|
||||
// grossAnfang: erster Buchstabe groß, Leerraum zusammengefasst — für Vor-
|
||||
// und Nachnamen beim Speichern ("miriam" → "Miriam").
|
||||
function grossAnfang(s){
|
||||
s = String(s || '').trim().replace(/\s+/g, ' ');
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
|
||||
}
|
||||
|
||||
// formatWeekDate: wandelt ein ISO-Datum (YYYY-MM-DD) in deutsches Format DD.MM.YYYY um
|
||||
// Parameter: dateString (String). Rückgabe: formatierter String oder leer.
|
||||
function formatWeekDate(dateString){
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
+183
-2
@@ -4030,8 +4030,63 @@ html[data-theme="dark"] .absence-type-fortbildung { background: rgba(255,159,10,
|
||||
Seiten-Huelle (.admin-page/.admin-header/.admin-body) sind mit ihr
|
||||
entfallen, weil der Abschnitt jetzt in der normalen App-Huelle liegt. */
|
||||
.admin-toolbar { margin-bottom: 14px; }
|
||||
td.actions-cell { white-space: nowrap; }
|
||||
td.actions-cell button { margin: 2px; }
|
||||
td.actions-cell { white-space: nowrap; width: 1%; text-align: right; }
|
||||
|
||||
/* Vor- und Nachname nebeneinander (Benutzer anlegen/bearbeiten), auf
|
||||
schmalen Bildschirmen untereinander. */
|
||||
.form-row-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
|
||||
.form-row-pair > .form-row { min-width: 0; }
|
||||
@media (max-width: 480px) { .form-row-pair { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Zeilenaktionen der Benutzerverwaltung: „Bearbeiten" + Drei-Punkte-Menü
|
||||
(js/admin-users.js, openUserActionMenu()). Das Menü hängt an <body> und
|
||||
steht fixed, damit der quer scrollbare Tabellenrahmen es nicht abschneidet. */
|
||||
.user-aktionen { display: inline-flex; gap: 6px; align-items: center; }
|
||||
.user-kebab {
|
||||
width: 30px; height: 30px; padding: 0;
|
||||
display: inline-grid; place-items: center;
|
||||
border: 1px solid transparent; border-radius: var(--r-sm);
|
||||
background: transparent; color: var(--text-muted); cursor: pointer;
|
||||
transition: background var(--dur) var(--ease-out), color var(--dur) var(--ease-out), border-color var(--dur) var(--ease-out);
|
||||
}
|
||||
.user-kebab:hover { background: var(--surface-sunk); color: var(--text); }
|
||||
.user-kebab[aria-expanded="true"] {
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
.user-kebab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.user-kebab svg { display: block; }
|
||||
tr.is-menu-open { background: var(--surface-alt); }
|
||||
|
||||
.user-menu {
|
||||
position: fixed; z-index: 250; min-width: 220px; max-width: 280px;
|
||||
background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: var(--r-md); box-shadow: var(--sh-3); padding: 5px;
|
||||
transform-origin: top right;
|
||||
animation: user-menu-auf var(--dur) var(--ease-out);
|
||||
}
|
||||
.user-menu.nach-oben { transform-origin: bottom right; animation-name: user-menu-auf-oben; }
|
||||
@keyframes user-menu-auf { from { opacity: 0; transform: scale(.96) translateY(-4px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes user-menu-auf-oben { from { opacity: 0; transform: scale(.96) translateY(4px); } to { opacity: 1; transform: none; } }
|
||||
.user-menu-item {
|
||||
all: unset; box-sizing: border-box;
|
||||
display: flex; gap: 10px; align-items: flex-start; width: 100%;
|
||||
padding: 7px 10px; border-radius: var(--r-sm);
|
||||
color: var(--text); font-size: 13.5px; line-height: 1.35; cursor: pointer;
|
||||
}
|
||||
.user-menu-item:hover:not([aria-disabled="true"]), .user-menu-item:focus-visible { background: var(--surface-sunk); }
|
||||
.user-menu-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.user-menu-ic { flex: none; width: 16px; height: 16px; margin-top: 1px; color: var(--text-muted); }
|
||||
.user-menu-text { display: grid; gap: 1px; }
|
||||
.user-menu-sub { font-size: 12px; color: var(--text-subtle); }
|
||||
.user-menu-item.is-danger, .user-menu-item.is-danger .user-menu-ic { color: var(--red); }
|
||||
.user-menu-item.is-danger:hover:not([aria-disabled="true"]),
|
||||
.user-menu-item.is-danger:focus-visible { background: color-mix(in srgb, var(--red) 10%, transparent); }
|
||||
.user-menu-item[aria-disabled="true"], .user-menu-item[aria-disabled="true"] .user-menu-ic { color: var(--text-subtle); cursor: not-allowed; }
|
||||
.user-menu-trenner { border: 0; border-top: 1px solid var(--line-soft); margin: 5px 4px; }
|
||||
.user-menu-confirm { padding: 10px 10px 8px; display: grid; gap: 10px; }
|
||||
.user-menu-confirm p { margin: 0; font-size: 13px; line-height: 1.4; }
|
||||
.user-menu-confirm-row { display: flex; gap: 6px; justify-content: flex-end; }
|
||||
.badge-active { color: var(--green); font-weight: 600; }
|
||||
.badge-inactive { color: var(--text-muted); }
|
||||
.badge-warn { color: var(--amber); font-weight: 600; }
|
||||
@@ -5252,3 +5307,129 @@ td.actions-cell button { margin: 2px; }
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =========================================================================
|
||||
LOGIN-SEITE (#loginOverlay, Design "Kalenderwoche" — Eriks Wahl B)
|
||||
Links Logo und Formular, rechts die laufende Kalenderwoche als große Zahl
|
||||
mit ihren Arbeitstagen (renderLoginWoche(), js/auth.js). Farben über die
|
||||
App-Tokens; nur die Zahl hat eigene Tokens (Logo-Hellblau im hellen,
|
||||
gedämpft im dunklen Thema). Liegt über allem (z-index 400 > .modal-bg 300).
|
||||
Das Logo ist ein blasses Wasserzeichen über die ganze Seite
|
||||
(.login-wasserzeichen, Variante E), kein eigenes kleines Logo.
|
||||
========================================================================= */
|
||||
.login-seite {
|
||||
--login-zahl: #94b0cc;
|
||||
--login-jahr: #116896;
|
||||
--login-wz: .11;
|
||||
position: fixed; inset: 0; z-index: 400;
|
||||
display: none; overflow-y: auto; overflow-x: hidden;
|
||||
background: var(--bg); color: var(--text);
|
||||
padding: 0 20px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not([data-theme="light"]) .login-seite { --login-zahl: #4f7695; --login-jahr: var(--accent); --login-wz: .07; }
|
||||
}
|
||||
html[data-theme="dark"] .login-seite { --login-zahl: #4f7695; --login-jahr: var(--accent); --login-wz: .07; }
|
||||
|
||||
/* Wasserzeichen: ganzes Zeichen sichtbar, an den Rändern angeschnitten. Die
|
||||
Felder und Tageskacheln haben eigene Flächen und bleiben davor lesbar.
|
||||
Der Rahmen ist genau so groß wie das Fenster und schneidet alles
|
||||
Überstehende ab — sonst ragte das Logo über den Rand und machte die
|
||||
Seite scrollbar. */
|
||||
.login-wz-rahmen { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 0; }
|
||||
.login-wasserzeichen {
|
||||
position: absolute; z-index: 0; left: 50%; top: 50%;
|
||||
width: min(1500px, 98vw); height: auto; transform: translate(-44%, -47%);
|
||||
opacity: var(--login-wz); filter: blur(1px);
|
||||
pointer-events: none; user-select: none;
|
||||
}
|
||||
|
||||
.login-raster {
|
||||
position: relative; z-index: 1;
|
||||
width: 100%; max-width: 1180px; min-height: 100%; margin: 0 auto;
|
||||
display: grid; grid-template-columns: minmax(0, 400px) minmax(0, 1fr);
|
||||
gap: clamp(32px, 7vw, 110px); align-items: center;
|
||||
padding-block: calc(48px + env(safe-area-inset-top, 0px)) calc(48px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
.login-titel { margin: 0 0 6px; font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-weight: 500; font-size: 30px; letter-spacing: -.01em; }
|
||||
.login-unter { margin: 0 0 30px; color: var(--text-muted); }
|
||||
|
||||
.login-form { display: grid; gap: 18px; }
|
||||
.login-feld { display: grid; gap: 7px; }
|
||||
.login-feld label { font-size: 13.5px; font-weight: 600; color: var(--text); }
|
||||
.login-feld input {
|
||||
width: 100%; box-sizing: border-box; min-height: 46px; padding: 11px 13px;
|
||||
font: inherit; font-size: 15px; color: var(--text);
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg);
|
||||
transition: border-color var(--dur) var(--ease-out), box-shadow var(--dur) var(--ease-out);
|
||||
}
|
||||
.login-feld input::placeholder { color: var(--text-subtle); opacity: 1; }
|
||||
.login-feld input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
|
||||
.login-feld.hat-fehler input { border-color: var(--red); }
|
||||
.login-pw { position: relative; }
|
||||
.login-pw input { padding-right: 96px; }
|
||||
.login-pw-toggle {
|
||||
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
|
||||
font: inherit; font-size: 12.5px; font-weight: 600; color: var(--accent);
|
||||
background: transparent; border: 0; border-radius: var(--r-md); padding: 7px 10px; cursor: pointer;
|
||||
}
|
||||
.login-pw-toggle:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||
.login-pw-toggle:focus-visible, .login-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.login-caps { margin: 0; font-size: 12.5px; font-weight: 600; color: var(--amber); }
|
||||
.login-fehler {
|
||||
margin: 0; padding: 10px 12px; border-radius: var(--r-lg); font-size: 13.5px; line-height: 1.45;
|
||||
color: var(--red); background: color-mix(in srgb, var(--red) 10%, transparent);
|
||||
}
|
||||
.login-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
|
||||
width: 100%; min-height: 48px; margin-top: 4px;
|
||||
font: inherit; font-size: 15px; font-weight: 650; color: #fff;
|
||||
background: var(--accent); border: 0; border-radius: var(--r-lg); cursor: pointer;
|
||||
transition: filter var(--dur) var(--ease-out), transform var(--dur) var(--ease-out);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .login-btn { color: #0b1c26; } }
|
||||
html[data-theme="dark"] .login-btn { color: #0b1c26; }
|
||||
.login-btn:hover { filter: brightness(1.07); }
|
||||
.login-btn:active { transform: scale(var(--press)); }
|
||||
.login-btn:disabled { cursor: progress; filter: saturate(.7); }
|
||||
.login-spinner {
|
||||
display: none; width: 15px; height: 15px; border-radius: 50%;
|
||||
border: 2px solid color-mix(in srgb, currentColor 35%, transparent); border-top-color: currentColor;
|
||||
animation: login-dreh .7s linear infinite;
|
||||
}
|
||||
.login-btn.laedt .login-spinner { display: block; }
|
||||
@keyframes login-dreh { to { transform: rotate(360deg); } }
|
||||
.login-hilfe { margin: 18px 0 0; font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
.login-woche { justify-self: end; text-align: right; animation: login-auf .7s var(--ease-out) both; }
|
||||
@keyframes login-auf { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||
.login-kw-label, .login-kw-jahr, .login-kw-zahl, .login-tage b { font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
.login-kw-label { display: block; font-weight: 500; font-size: 22px; color: var(--text-muted); letter-spacing: .02em; }
|
||||
.login-kw-zahl {
|
||||
display: block; font-weight: 300; font-size: clamp(150px, 21vw, 300px); line-height: .86;
|
||||
letter-spacing: -.05em; color: var(--login-zahl); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.login-kw-jahr { display: block; font-weight: 500; font-size: 20px; color: var(--login-jahr); }
|
||||
.login-tage { list-style: none; margin: 30px 0 0; padding: 0; display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.login-tage li {
|
||||
display: grid; justify-items: center; gap: 2px; width: 58px; padding: 10px 0 9px;
|
||||
border-radius: var(--r-lg); background: var(--surface); border: 1px solid var(--line);
|
||||
}
|
||||
.login-tage li span { font-size: 12px; font-weight: 600; color: var(--text-muted); }
|
||||
.login-tage li b { font-weight: 500; font-size: 20px; font-variant-numeric: tabular-nums; }
|
||||
.login-tage li.ist-heute { background: var(--accent); border-color: var(--accent); }
|
||||
.login-tage li.ist-heute span, .login-tage li.ist-heute b { color: #fff; }
|
||||
@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .login-tage li.ist-heute span, html:not([data-theme="light"]) .login-tage li.ist-heute b { color: #0b1c26; } }
|
||||
html[data-theme="dark"] .login-tage li.ist-heute span, html[data-theme="dark"] .login-tage li.ist-heute b { color: #0b1c26; }
|
||||
.login-datum { margin: 18px 0 0; color: var(--text-muted); font-size: 15px; }
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.login-raster { grid-template-columns: 1fr; gap: 28px; padding-block: calc(28px + env(safe-area-inset-top, 0px)) 48px; align-content: start; }
|
||||
.login-woche { order: -1; justify-self: start; text-align: left; }
|
||||
.login-kw-zahl { font-size: 110px; }
|
||||
.login-tage { justify-content: flex-start; }
|
||||
.login-tage li { width: auto; flex: 1; max-width: 58px; }
|
||||
.login-wasserzeichen { width: 260vw; top: 0; transform: translate(-30%, -10%); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .login-woche { animation: none; } .login-spinner { animation: none; } }
|
||||
@@ -283,8 +283,18 @@ const VALID_DISCIPLINE_ROLES = new Set(['Planung','Projektleitung','Bauüberwach
|
||||
// "Wandtafel"): darf nur die lesenden Routen erreichen, die die Wandtafel
|
||||
// braucht (requireAuthOderKiosk weiter unten), überall sonst wie requireAuth
|
||||
// abgewiesen — und darf keine Fachrolle tragen (dort geprüft, wo roles
|
||||
// validiert wird).
|
||||
const VALID_ROLES = new Set(['admin', 'member', 'kiosk']);
|
||||
// validiert wird). 'projektleiter' ist ein Mitglied, das zusätzlich Projekte
|
||||
// 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
|
||||
// (projects.auftragsstatus). Die Reihenfolge ist die fachliche Abfolge und
|
||||
// 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
|
||||
// sein (alte people-Tabelle); jetzt identifiziert person_id global einen
|
||||
// Benutzer, daher müssen Bestandskollisionen vor dem UNIQUE INDEX aufgelöst
|
||||
@@ -2692,6 +2725,7 @@ migrateAddProtokollPunktErstelltAm();
|
||||
migrateAddProtokollPunktStatus();
|
||||
migrateAddProtokollPunktEntfaelltNaechstes();
|
||||
migrateUsersDepartment();
|
||||
migrateUsersVorNachname();
|
||||
ensureSandboxWeek();
|
||||
ensureUserPersonIdUniqueIndex();
|
||||
migrateRetirePeopleTablesIntoUsers();
|
||||
@@ -2722,6 +2756,41 @@ try {
|
||||
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 ----------
|
||||
|
||||
const SESSION_COOKIE = 'puebersicht_session';
|
||||
@@ -2880,7 +2949,12 @@ app.post('/login', (req, res) => {
|
||||
if(loginIsBlocked(clientKey)){
|
||||
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
|
||||
// Antwortzeit (scrypt läuft bewusst langsam), welche Konten es gibt.
|
||||
const salt = row ? row.password_salt : 'unbekannt';
|
||||
@@ -2989,6 +3063,8 @@ function serializeUserRow(row){
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
displayName: row.display_name,
|
||||
firstName: row.first_name || '',
|
||||
lastName: row.last_name || '',
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
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()
|
||||
// passiert clientseitig).
|
||||
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(!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';
|
||||
if(!VALID_ROLES.has(finalRole)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' });
|
||||
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();
|
||||
try {
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`)
|
||||
.run(String(username).trim(), String(displayName).trim(), String(email || ''), finalRole, String(personId || ''), department, String(initials || ''), capacity, hash, salt, now, now);
|
||||
(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, ?, ?)`)
|
||||
.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 (?, ?, ?)');
|
||||
roleList.forEach((r, idx) => insRole.run(info.lastInsertRowid, r, idx));
|
||||
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 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(role !== undefined){ fields.push('role = ?'); values.push(role); }
|
||||
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
|
||||
// sind schon angelegt, aber aktuell noch leer). Ein Admin kann sich selbst
|
||||
// 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) => {
|
||||
const id = Number(req.params.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';
|
||||
try {
|
||||
if(hard){
|
||||
const referenced = db.prepare(`SELECT
|
||||
(SELECT COUNT(*) FROM project_files WHERE uploaded_by_user_id = ?) +
|
||||
(SELECT COUNT(*) FROM project_file_reviewers WHERE user_id = ?) +
|
||||
(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 ergebnis = loescheBenutzerEndgueltig(row);
|
||||
console.log('Benutzer endgültig gelöscht:', row.username, ergebnis);
|
||||
return res.json(Object.assign({ ok: true, hard: true }, ergebnis));
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
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
|
||||
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
|
||||
const updatedAt = saveWeekRelational(week);
|
||||
if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user);
|
||||
console.log('Saved week:', week.label);
|
||||
return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
|
||||
} 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)
|
||||
app.post('/delete-week', requireRole('admin'), (req, res) => {
|
||||
const { label } = req.body || {};
|
||||
@@ -3620,7 +3939,7 @@ app.get('/my-dashboard', requireAuth, (req, res) => {
|
||||
const pendingReviews = db.prepare(`
|
||||
SELECT f.id AS fileId, f.project_nr AS projectNr,
|
||||
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
|
||||
FROM project_file_reviewers r
|
||||
JOIN project_files f ON f.id = r.file_id
|
||||
@@ -3873,14 +4192,14 @@ app.get('/project-files', requireAuth, (req, res) => {
|
||||
try {
|
||||
const projectNr = String(req.query.projectNr || '');
|
||||
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
|
||||
LEFT JOIN users u ON u.id = f.uploaded_by_user_id
|
||||
WHERE f.project_nr = ?
|
||||
ORDER BY f.created_at DESC
|
||||
`).all(projectNr);
|
||||
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
|
||||
LEFT JOIN users u ON u.id = r.user_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) => {
|
||||
try {
|
||||
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
|
||||
FROM project_file_comments c
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
@@ -6558,6 +6877,8 @@ const server = app.listen(PORT, () => {
|
||||
// Reihenfolge weiter oben.
|
||||
ensureCurrentWeekRollover();
|
||||
setInterval(ensureCurrentWeekRollover, 60 * 60 * 1000);
|
||||
// Papierkorb gelöschter Projekte: Einträge nach 30 Tagen entfernen.
|
||||
setInterval(purgeGeloeschteProjekte, 60 * 60 * 1000);
|
||||
});
|
||||
server.on('error', (err) => {
|
||||
console.error('Server konnte nicht starten:', err.message);
|
||||
|
||||
Reference in new issue
Block a user