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
+1214
-116
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
|
forces `must_change_password=1`, deletes all sessions of that user (forces
|
||||||
re-login), returns the plaintext once as `generatedPassword`.
|
re-login), returns the plaintext once as `generatedPassword`.
|
||||||
- `DELETE /admin/users/:id[?hard=1]` → Admin. Default is a soft-delete
|
- `DELETE /admin/users/:id[?hard=1]` → Admin. Default is a soft-delete
|
||||||
(`active=0` + delete sessions); `?hard=1` permanently deletes the row, but
|
(`active=0` + delete sessions). `?hard=1` (`loescheBenutzerEndgueltig()`,
|
||||||
only if the user is referenced nowhere in `project_files.uploaded_by_user_id`
|
one transaction) permanently deletes the account **and, on Erik's request,
|
||||||
/ `project_file_reviewers.user_id` / `project_file_comments.user_id` (else
|
removes the person from the projects and thereby the Auslastung — but only
|
||||||
409). An admin can never delete their own account (soft or hard).
|
in the current session (latest real week) and the Planung sandbox**; older
|
||||||
|
sessions are history and stay untouched. Removed there: `project_assign`,
|
||||||
|
`plan_todo_assignees`, `plan_people`, `week_people`/`_roles`,
|
||||||
|
`attendance`, matched via `personenKennungenInWoche()` = `users.person_id`
|
||||||
|
plus every id in that week's `week_people` with the same full name
|
||||||
|
(legacy short ids like `p_ap`). Those weeks get a new `updated_at`, so a
|
||||||
|
stale browser gets a 409 instead of writing the person back. Files are no
|
||||||
|
longer a blocker: uploads/comments stay and show as "Gelöschter Benutzer"
|
||||||
|
(`COALESCE(..., 'Gelöschter Benutzer')` in the file queries), the person's
|
||||||
|
review assignments are deleted and pending files' status recomputed.
|
||||||
|
Response includes counts (`zuordnungen`, `aufgaben`, `pruefauftraege`).
|
||||||
|
An admin can never delete their own account (soft or hard).
|
||||||
- `GET /users` → Auth (not admin-only). Lists active users
|
- `GET /users` → Auth (not admin-only). Lists active users
|
||||||
(`id, username, displayName, email, personId, department, initials, roles`)
|
(`id, username, displayName, email, personId, department, initials, roles`)
|
||||||
— used both by the reviewer-picker in the file upload UI and by the
|
— used both by the reviewer-picker in the file upload UI and by the
|
||||||
@@ -1176,6 +1187,39 @@ Phase eine Aufgabe in `project.plan.todos`.
|
|||||||
180.000 € rechnerisch 172 Arbeitstage für eine Person, und der Plan lief
|
180.000 € rechnerisch 172 Arbeitstage für eine Person, und der Plan lief
|
||||||
über Jahre.
|
über Jahre.
|
||||||
|
|
||||||
|
### Projekte anlegen/löschen — Rolle „Projektleiter" und Papierkorb
|
||||||
|
|
||||||
|
- **Projekte anlegen und aus der Projektliste löschen dürfen nur
|
||||||
|
`projektleiter` und `admin`** (`users.role`; `VALID_ROLES` enthält jetzt
|
||||||
|
`admin|member|projektleiter|kiosk`). `projektleiter` ist sonst ein
|
||||||
|
gewöhnliches Mitglied — nicht zu verwechseln mit der Fachrolle
|
||||||
|
`Projektleitung` (`user_roles`), die keine Berechtigung verleiht. Vergeben
|
||||||
|
in Admin → Benutzerverwaltung. Bearbeiten bestehender Projekte bleibt
|
||||||
|
jedem angemeldeten Konto erlaubt.
|
||||||
|
- **Serverseitig durchgesetzt**, nicht nur UI: Da `/save-week` immer die ganze
|
||||||
|
Woche schreibt, vergleicht `projektlisteAenderungen()` (`server.js`) den
|
||||||
|
eingehenden mit dem gespeicherten Stand (Abgleich per `id` oder
|
||||||
|
`projectUid`) und liefert neue und fehlende Projekte; ohne
|
||||||
|
`darfProjektlisteAendern(req.user)` → 403 vor jedem Schreiben. Die
|
||||||
|
Planung-Sandbox ist ausgenommen. Clientseitig blendet
|
||||||
|
`darfProjektlisteAendern()` (`js/auth.js`) den Löschen-Knopf in
|
||||||
|
`renderBoardRow()` aus, `renderUserBar()` alle Elemente mit der Klasse
|
||||||
|
`.nur-projektleitung` (die beiden „+ Projekt"-Knöpfe), und
|
||||||
|
`openNewProjWizard()` weist zusätzlich ab.
|
||||||
|
- **Papierkorb** (`deleted_projects`: Schnappschuss des kompletten
|
||||||
|
Projekt-Objekts als JSON, wer/wann/aus welcher Sitzung). Nach dem
|
||||||
|
erfolgreichen Speichern legt `legeInPapierkorb()` die gelöschten Projekte
|
||||||
|
dort ab. `purgeGeloeschteProjekte()` entfernt Einträge nach
|
||||||
|
`GELOESCHTE_PROJEKTE_TAGE` (30) — beim Start und stündlich.
|
||||||
|
Admin → **Papierkorb** (`loadAdminPapierkorb()`, `js/admin/admin.js`):
|
||||||
|
`GET /admin/deleted-projects`, `POST /admin/deleted-projects/:id/restore`
|
||||||
|
(setzt das Projekt ans Ende seiner Gruppe in der **jüngsten echten
|
||||||
|
Sitzung**, leert den Sitzungskommentar, wenn es aus einer älteren Sitzung
|
||||||
|
stammt; 409 bei Nummern-Kollision), `DELETE /admin/deleted-projects/:id`
|
||||||
|
(sofort endgültig) — alle `requireRole('admin')`. Dateien/Protokolle
|
||||||
|
hängen an `project_nr` und werden beim Löschen nie angefasst. Nicht in
|
||||||
|
`IMPORT_TABLES` — ein DB-Import lässt den Papierkorb unberührt.
|
||||||
|
|
||||||
### Auftragsstatus — von der Anfrage zum Projekt
|
### Auftragsstatus — von der Anfrage zum Projekt
|
||||||
|
|
||||||
`projects.auftragsstatus` (`anfrage|angebot|beauftragt|abgeschlossen`, default
|
`projects.auftragsstatus` (`anfrage|angebot|beauftragt|abgeschlossen`, default
|
||||||
@@ -2490,6 +2534,23 @@ gleichzeitig bearbeiten.
|
|||||||
`+ Neues Projekt`, `Wandtafel` (the former `Show Mode` entry — see
|
`+ Neues Projekt`, `Wandtafel` (the former `Show Mode` entry — see
|
||||||
"Wandtafel" below).
|
"Wandtafel" below).
|
||||||
|
|
||||||
|
### Login-Seite (`#loginOverlay`)
|
||||||
|
|
||||||
|
Full-screen page, not a dialog: design "Kalenderwoche" (Erik's pick B from
|
||||||
|
three Taste-Skill drafts). The logo is a large, pale watermark across the
|
||||||
|
whole page (`.login-wasserzeichen`, `/logo.png` converted from `logo.ico`;
|
||||||
|
Erik's pick E from a second comparison of logo placements) — deliberately
|
||||||
|
no small logo. Left: heading and form; right: the current ISO calendar week as a large
|
||||||
|
number with Mo-Fr and today highlighted (`renderLoginWoche()` in
|
||||||
|
`js/auth.js`, called by `showLoginOverlay()`) — the app thinks in weekly
|
||||||
|
sessions, so the week is what you see before logging in. Form has inline
|
||||||
|
errors (`.login-feld.hat-fehler`, `#loginError`), a loading state on the
|
||||||
|
button, "Anzeigen" for the password and a caps-lock hint
|
||||||
|
(`verdrahteLoginHilfen()`). Styles: "LOGIN-SEITE" block at the end of
|
||||||
|
`styles.css`, app tokens plus `--login-zahl`/`--login-jahr`. The number uses
|
||||||
|
the web font Outfit (same Google-Fonts link as the Wandtafel, falls back to
|
||||||
|
the system font offline). `z-index: 400`, above `.modal-bg` (300).
|
||||||
|
|
||||||
### Admin (tab `admin`, `js/admin/admin.js`)
|
### Admin (tab `admin`, `js/admin/admin.js`)
|
||||||
|
|
||||||
- Everything only admins may do, in one place. Split into **sections, one
|
- Everything only admins may do, in one place. Split into **sections, one
|
||||||
@@ -2514,7 +2575,40 @@ gleichzeitig bearbeiten.
|
|||||||
`renderAdminArea()` renders nothing for them.
|
`renderAdminArea()` renders nothing for them.
|
||||||
- Sections:
|
- Sections:
|
||||||
- **Benutzerverwaltung** — accounts, roles, weekly hours, absences
|
- **Benutzerverwaltung** — accounts, roles, weekly hours, absences
|
||||||
(`js/admin-users.js`, `renderAdminUsers()`); reloaded from the server
|
(`js/admin-users.js`, `renderAdminUsers()`). Row actions: "Bearbeiten"
|
||||||
|
stays a visible button, everything else sits behind a "⋯" menu
|
||||||
|
(`openUserActionMenu()`, Erik's pick "C" from a three-way comparison):
|
||||||
|
Passwort zurücksetzen, De-/Aktivieren, and — only for deactivated
|
||||||
|
accounts — Endgültig löschen. Irreversible actions confirm inside the
|
||||||
|
menu instead of `confirm()`; the action functions take an
|
||||||
|
`ohneRueckfrage` flag for that. The menu is appended to `<body>` with
|
||||||
|
`position: fixed` (the table's horizontally scrolling frame would clip
|
||||||
|
it) and flips upward when there is no room below.
|
||||||
|
**Vor- und Nachname** are separate fields and columns
|
||||||
|
(`users.first_name`/`last_name`, migration `migrateUsersVorNachname()`);
|
||||||
|
`display_name` stays and is always written as "Vorname Nachname" by
|
||||||
|
`POST`/`PATCH /admin/users*` — every other place still reads
|
||||||
|
`display_name`; existing accounts got Vor-/Nachname once from
|
||||||
|
`display_name` (first word / rest). **The username is the local part of
|
||||||
|
the e-mail address** (`e.dunin`) — Erik first wanted "Vorname Nachname",
|
||||||
|
then reverted to this before it ever reached the real database, so
|
||||||
|
existing usernames were never renamed. In the new-user dialog the
|
||||||
|
username follows the e-mail field (`benutzernameAusEmail()`), which
|
||||||
|
itself follows Vor-/Nachname; each field stops following once edited by
|
||||||
|
hand. **Person-IDs of new accounts come from the Kürzel** (`p_mk`,
|
||||||
|
`personIdAusKuerzel()`), with a digit appended if taken (`p_mk2`) — the
|
||||||
|
scheme every existing account and its project data already use. Before,
|
||||||
|
the dialog derived them from the full name (`p_miriam_k_rner`), which
|
||||||
|
split one person into two ids (account vs. project data).
|
||||||
|
`/login` matches `COLLATE NOCASE` and also accepts the full
|
||||||
|
address (everything from "@" is dropped); `POST /admin/users` checks
|
||||||
|
uniqueness case-insensitively (409).
|
||||||
|
The e-mail field
|
||||||
|
pre-fills `v.nachname@rauschenberg-ing.de` from Vor-/Nachname
|
||||||
|
(`emailVorschlag()`/`EMAIL_DOMAIN`, `js/utils.js`; umlauts transliterated)
|
||||||
|
while not edited by hand — same pattern as Person-ID/Kürzel; the edit
|
||||||
|
dialog and Einstellungen → Mein Konto only show it as a placeholder and
|
||||||
|
never overwrite a stored address; reloaded from the server
|
||||||
every time the section is entered, since the list changes outside this
|
every time the section is entered, since the list changes outside this
|
||||||
browser and the other masks compute with it (weekly hours → Auslastung).
|
browser and the other masks compute with it (weekly hours → Auslastung).
|
||||||
Marked `wide: true` in `ADMIN_SECTIONS`, which lifts the grid's width cap
|
Marked `wide: true` in `ADMIN_SECTIONS`, which lifts the grid's width cap
|
||||||
|
|||||||
+93
-39
@@ -34,12 +34,13 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Projektübersicht</title>
|
<title>Projektübersicht</title>
|
||||||
<link rel="stylesheet" href="/styles.css?v=64">
|
<link rel="stylesheet" href="/styles.css?v=67">
|
||||||
<link rel="stylesheet" href="/plan-styles.css?v=19">
|
<link rel="stylesheet" href="/plan-styles.css?v=19">
|
||||||
<!-- Fredoka/Nunito Sans: nur für die Wandtafel (js/wand/wand.js), siehe ihren
|
<!-- Fredoka/Nunito Sans: nur für die Wandtafel (js/wand/wand.js), siehe ihren
|
||||||
Dateikopf — fällt ohne Internetzugang automatisch auf die System-Schrift
|
Dateikopf; Outfit: nur für die Kalenderwoche der Login-Seite. Alle fallen
|
||||||
zurück (font-family-Stack in styles.css), bricht also nichts offline. -->
|
ohne Internetzugang automatisch auf die System-Schrift zurück
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600;700&family=Nunito+Sans:wght@400;600;700;800&display=swap">
|
(font-family-Stack in styles.css), brechen also nichts offline. -->
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600;700&family=Outfit:wght@300;500&family=Nunito+Sans:wght@400;600;700;800&display=swap">
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/pdfmake.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/pdfmake.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/vfs_fonts.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/vfs_fonts.js"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -186,7 +187,7 @@
|
|||||||
js/pdf.js. Damit fällt die einzige gewerkeübergreifende
|
js/pdf.js. Damit fällt die einzige gewerkeübergreifende
|
||||||
Sitzungsübersicht als PDF weg; die einzelnen Gewerke-Berichte
|
Sitzungsübersicht als PDF weg; die einzelnen Gewerke-Berichte
|
||||||
im Wochenbericht bleiben die Ablage. -->
|
im Wochenbericht bleiben die Ablage. -->
|
||||||
<button class="menu-item sidebar-subitem" onclick="openNewProjWizard()">+ Neues Projekt</button>
|
<button class="menu-item sidebar-subitem nur-projektleitung" onclick="openNewProjWizard()">+ Neues Projekt</button>
|
||||||
<button class="menu-item sidebar-subitem" onclick="showTab('wand')">Wandtafel</button>
|
<button class="menu-item sidebar-subitem" onclick="showTab('wand')">Wandtafel</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,6 +213,7 @@
|
|||||||
<button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb & Sicherheit</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>
|
<button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank & Sitzungen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +241,7 @@
|
|||||||
zusätzlich stehen, weil er dort dieselbe Maske öffnet. -->
|
zusätzlich stehen, weil er dort dieselbe Maske öffnet. -->
|
||||||
<div class="bericht-titelzeile">
|
<div class="bericht-titelzeile">
|
||||||
<div class="section-title" style="margin:0">Projekte</div>
|
<div class="section-title" style="margin:0">Projekte</div>
|
||||||
<button type="button" class="secondary small" onclick="openNewProjWizard()">+ Projekt</button>
|
<button type="button" class="secondary small nur-projektleitung" onclick="openNewProjWizard()">+ Projekt</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Die Farbe der Kreise richtet sich nach der ROLLE (siehe
|
<!-- Die Farbe der Kreise richtet sich nach der ROLLE (siehe
|
||||||
.board-avatar.role-* in styles.css), die Füllung nach der Art der
|
.board-avatar.role-* in styles.css), die Füllung nach der Art der
|
||||||
@@ -715,7 +717,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Benutzername</th>
|
<th>Benutzername</th>
|
||||||
<th>Anzeigename</th>
|
<th>Name</th>
|
||||||
<th>E-Mail</th>
|
<th>E-Mail</th>
|
||||||
<th>Rolle</th>
|
<th>Rolle</th>
|
||||||
<th>Abteilung</th>
|
<th>Abteilung</th>
|
||||||
@@ -731,6 +733,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-card settings-card-wide" id="adminPapierkorbCard" data-admin-section="papierkorb" style="display:none">
|
||||||
|
<h3 class="settings-card-title">Papierkorb <span class="settings-scope">nur Administratoren</span></h3>
|
||||||
|
<p class="hint">Aus der Projektliste gelöschte Projekte — samt Terminplan, Beteiligten und
|
||||||
|
Leistungsphasen. Sie bleiben 30 Tage hier und lassen sich in die aktuelle Sitzung
|
||||||
|
zurückholen; danach werden sie endgültig entfernt. Dateien und Protokolle hängen an der
|
||||||
|
Projektnummer und sind nach dem Wiederherstellen ebenfalls wieder da.</p>
|
||||||
|
<div id="adminPapierkorbList"><p class="hint">Wird geladen…</p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings-card" id="adminDatenbankCard" data-admin-section="datenbank" style="display:none">
|
<section class="settings-card" id="adminDatenbankCard" data-admin-section="datenbank" style="display:none">
|
||||||
<h3 class="settings-card-title">Datenbank & Sitzungen <span class="settings-scope">nur Administratoren</span></h3>
|
<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-bg" id="newUserModal" style="display:none">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2>Neuer Benutzer</h2>
|
<h2>Neuer Benutzer</h2>
|
||||||
<div class="form-row">
|
<div class="form-row-pair">
|
||||||
<label>Benutzername</label>
|
<div class="form-row">
|
||||||
<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 for="newUserLastName">Nachname</label>
|
||||||
|
<input type="text" id="newUserLastName" placeholder="z. B. Mustermann" autocomplete="off" oninput="onNewUserNameInput()">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Anzeigename</label>
|
<label for="newUserEmail">E-Mail</label>
|
||||||
<input type="text" id="newUserDisplayName" placeholder="z. B. Max Mustermann" oninput="onNewUserDisplayNameInput()">
|
<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>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>E-Mail</label>
|
<label for="newUserUsername">Benutzername</label>
|
||||||
<input type="email" id="newUserEmail" placeholder="max.mustermann@beispiel.de">
|
<input type="text" id="newUserUsername" placeholder="Anfang der E-Mail, z. B. m.mustermann" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Rolle</label>
|
<label>Rolle</label>
|
||||||
<select id="newUserRole" onchange="onUserRoleChange('new')">
|
<select id="newUserRole" onchange="onUserRoleChange('new')">
|
||||||
<option value="member">Mitglied</option>
|
<option value="member">Mitglied</option>
|
||||||
|
<option value="projektleiter">Projektleiter (darf Projekte anlegen und löschen)</option>
|
||||||
<option value="admin">Administrator</option>
|
<option value="admin">Administrator</option>
|
||||||
<option value="kiosk">Kiosk (Wandtafel-Konto)</option>
|
<option value="kiosk">Kiosk (Wandtafel-Konto)</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -1563,7 +1582,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Kürzel</label>
|
<label>Kürzel</label>
|
||||||
<input type="text" id="newUserInitials" placeholder="wird aus dem Anzeigenamen vorgeschlagen" maxlength="4">
|
<input type="text" id="newUserInitials" placeholder="wird aus dem Namen vorgeschlagen" maxlength="4">
|
||||||
<p class="hint">Kurzform fürs Projektboard (2-4 Zeichen), wird vorgeschlagen und kann angepasst werden.</p>
|
<p class="hint">Kurzform fürs Projektboard (2-4 Zeichen), wird vorgeschlagen und kann angepasst werden.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -1573,7 +1592,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Person-ID</label>
|
<label>Person-ID</label>
|
||||||
<input type="text" id="newUserPersonId" placeholder="wird aus dem Anzeigenamen vorgeschlagen">
|
<input type="text" id="newUserPersonId" placeholder="wird aus dem Kürzel gebildet, z. B. p_mm">
|
||||||
<p class="hint">Verknüpft den Account mit einem Eintrag in der Projektbeteiligten-Liste (personIdForName). Bei Kollisionen anpassen.</p>
|
<p class="hint">Verknüpft den Account mit einem Eintrag in der Projektbeteiligten-Liste (personIdForName). Bei Kollisionen anpassen.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -1601,18 +1620,25 @@
|
|||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2>Benutzer bearbeiten</h2>
|
<h2>Benutzer bearbeiten</h2>
|
||||||
<p class="hint" id="editUserUsernameLabel" style="margin-bottom:10px"></p>
|
<p class="hint" id="editUserUsernameLabel" style="margin-bottom:10px"></p>
|
||||||
<div class="form-row">
|
<div class="form-row-pair">
|
||||||
<label>Anzeigename</label>
|
<div class="form-row">
|
||||||
<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>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>E-Mail</label>
|
<label>E-Mail</label>
|
||||||
<input type="email" id="editUserEmail">
|
<input type="email" id="editUserEmail" placeholder="v.nachname@rauschenberg-ing.de">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Rolle</label>
|
<label>Rolle</label>
|
||||||
<select id="editUserRole" onchange="onUserRoleChange('edit')">
|
<select id="editUserRole" onchange="onUserRoleChange('edit')">
|
||||||
<option value="member">Mitglied</option>
|
<option value="member">Mitglied</option>
|
||||||
|
<option value="projektleiter">Projektleiter (darf Projekte anlegen und löschen)</option>
|
||||||
<option value="admin">Administrator</option>
|
<option value="admin">Administrator</option>
|
||||||
<option value="kiosk">Kiosk (Wandtafel-Konto)</option>
|
<option value="kiosk">Kiosk (Wandtafel-Konto)</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -1748,41 +1774,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login-Overlay: blockiert die gesamte App ohne gültige Session (siehe js/auth.js) -->
|
<!-- Login-Seite: blockiert die gesamte App ohne gültige Session (siehe
|
||||||
<div id="loginOverlay" style="display:none;" class="modal-bg">
|
js/auth.js). Design "Kalenderwoche" (Eriks Wahl B aus dem Vergleich der
|
||||||
<div class="modal">
|
Login-Entwürfe): links Logo und Formular, rechts die laufende
|
||||||
<h2>Anmelden</h2>
|
Kalenderwoche als große Zahl mit ihren Arbeitstagen — die App denkt in
|
||||||
<form id="loginForm">
|
Sitzungen je KW. Gefüllt von renderLoginWoche(). Das Logo liegt als
|
||||||
<div class="form-row"><label>Benutzername</label><input type="text" id="loginUsername" autocomplete="username" required></div>
|
blasses Wasserzeichen über die ganze Seite (Variante E aus dem
|
||||||
<div class="form-row"><label>Passwort</label><input type="password" id="loginPassword" autocomplete="current-password" required></div>
|
Logo-Vergleich), ein kleines Logo gibt es bewusst nicht. -->
|
||||||
<div id="loginError" style="color:#c00;"></div>
|
<div id="loginOverlay" class="login-seite" style="display:none;">
|
||||||
<div class="toolbar"><button type="submit">Anmelden</button></div>
|
<div class="login-wz-rahmen" aria-hidden="true"><img class="login-wasserzeichen" src="/logo.png" alt=""></div>
|
||||||
</form>
|
<main class="login-raster" aria-label="Anmeldung bei der Projektübersicht">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<script src="/js/globals.js?v=7"></script>
|
<script src="/js/globals.js?v=7"></script>
|
||||||
<script src="/js/utils.js?v=18"></script>
|
<script src="/js/utils.js?v=19"></script>
|
||||||
<script src="/js/icons.js?v=2"></script>
|
<script src="/js/icons.js?v=2"></script>
|
||||||
<script src="/js/ui.js?v=9"></script>
|
<script src="/js/ui.js?v=9"></script>
|
||||||
<script src="/js/date-picker.js?v=5"></script>
|
<script src="/js/date-picker.js?v=5"></script>
|
||||||
<script src="/js/server.js?v=6"></script>
|
<script src="/js/server.js?v=6"></script>
|
||||||
<script src="/js/auth.js?v=4"></script>
|
<script src="/js/auth.js?v=6"></script>
|
||||||
<script src="/js/store.js?v=10"></script>
|
<script src="/js/store.js?v=10"></script>
|
||||||
<script src="/js/weeks.js?v=11"></script>
|
<script src="/js/weeks.js?v=11"></script>
|
||||||
<script src="/js/board/board.js?v=30"></script>
|
<script src="/js/board/board.js?v=31"></script>
|
||||||
<script src="/js/wochenbericht/wochenbericht.js?v=20"></script>
|
<script src="/js/wochenbericht/wochenbericht.js?v=20"></script>
|
||||||
<script src="/js/people/people.js?v=7"></script>
|
<script src="/js/people/people.js?v=7"></script>
|
||||||
<script src="/js/planung/planung.js?v=1"></script>
|
<script src="/js/planung/planung.js?v=1"></script>
|
||||||
<script src="/js/planchecker/planchecker.js?v=1"></script>
|
<script src="/js/planchecker/planchecker.js?v=1"></script>
|
||||||
<script src="/js/projects/projects.js?v=7"></script>
|
<script src="/js/projects/projects.js?v=8"></script>
|
||||||
<script src="/js/load/load.js?v=18"></script>
|
<script src="/js/load/load.js?v=18"></script>
|
||||||
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
|
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
|
||||||
der Neues-Projekt-Dialog dort die Statusauswahl mit AUFTRAGSSTATUS füllt. -->
|
der Neues-Projekt-Dialog dort die Statusauswahl mit AUFTRAGSSTATUS füllt. -->
|
||||||
<script src="/js/auftrag.js?v=1"></script>
|
<script src="/js/auftrag.js?v=1"></script>
|
||||||
<!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und
|
<!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und
|
||||||
load.js (faktorFormat), steht deshalb dahinter. -->
|
load.js (faktorFormat), steht deshalb dahinter. -->
|
||||||
<script src="/js/projekt-wizard.js?v=1"></script>
|
<script src="/js/projekt-wizard.js?v=2"></script>
|
||||||
<script src="/js/dashboard/dashboard.js?v=5"></script>
|
<script src="/js/dashboard/dashboard.js?v=5"></script>
|
||||||
<!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/
|
<!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/
|
||||||
stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek()
|
stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek()
|
||||||
@@ -1791,9 +1845,9 @@
|
|||||||
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
|
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
|
||||||
Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
|
Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
|
||||||
<script src="/js/wand/wand.js?v=12"></script>
|
<script src="/js/wand/wand.js?v=12"></script>
|
||||||
<script src="/js/settings/settings.js?v=9"></script>
|
<script src="/js/settings/settings.js?v=10"></script>
|
||||||
<script src="/js/admin/admin.js?v=11"></script>
|
<script src="/js/admin/admin.js?v=12"></script>
|
||||||
<script src="/js/admin-users.js?v=4"></script>
|
<script src="/js/admin-users.js?v=7"></script>
|
||||||
<script src="/js/gantt.js?v=7"></script>
|
<script src="/js/gantt.js?v=7"></script>
|
||||||
<!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in
|
<!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in
|
||||||
project-plan.html; seit der Projektbereich ein Bereich dieser Seite ist,
|
project-plan.html; seit der Projektbereich ein Bereich dieser Seite ist,
|
||||||
|
|||||||
+254
-33
@@ -37,6 +37,10 @@
|
|||||||
|
|
||||||
// initialsManuallyEdited: gleiches Muster für das Kürzel-Feld.
|
// initialsManuallyEdited: gleiches Muster für das Kürzel-Feld.
|
||||||
let initialsManuallyEdited = false;
|
let initialsManuallyEdited = false;
|
||||||
|
// emailManuallyEdited: wie oben, für den E-Mail-Vorschlag (emailVorschlag()).
|
||||||
|
let emailManuallyEdited = false;
|
||||||
|
// usernameManuallyEdited: wie oben, für den Benutzernamen ("Vorname Nachname").
|
||||||
|
let usernameManuallyEdited = false;
|
||||||
// editUserAbsencesCache: zuletzt geladene Abwesenheiten des im
|
// editUserAbsencesCache: zuletzt geladene Abwesenheiten des im
|
||||||
// Bearbeiten-Modal geöffneten Benutzers (für Rendern/Löschen ohne erneuten
|
// Bearbeiten-Modal geöffneten Benutzers (für Rendern/Löschen ohne erneuten
|
||||||
// Request pro Aktion).
|
// Request pro Aktion).
|
||||||
@@ -142,27 +146,24 @@
|
|||||||
const statusParts = [];
|
const statusParts = [];
|
||||||
statusParts.push(u.active ? '<span class="badge-active">Aktiv</span>' : '<span class="badge-inactive">Deaktiviert</span>');
|
statusParts.push(u.active ? '<span class="badge-active">Aktiv</span>' : '<span class="badge-inactive">Deaktiviert</span>');
|
||||||
if(u.mustChangePassword) statusParts.push('<span class="badge-warn">Passwortwechsel erforderlich</span>');
|
if(u.mustChangePassword) statusParts.push('<span class="badge-warn">Passwortwechsel erforderlich</span>');
|
||||||
const toggleLabel = u.active ? 'Deaktivieren' : 'Aktivieren';
|
|
||||||
const isSelf = currentUser && currentUser.id === u.id;
|
|
||||||
const deleteBtn = (!u.active)
|
|
||||||
? `<button class="danger small" ${isSelf ? 'disabled' : ''} onclick="hardDeleteUser(${u.id}, '${escJs(u.username)}')">Endgültig löschen</button>`
|
|
||||||
: '';
|
|
||||||
const deptLabel = (DEPARTMENTS[u.department] && DEPARTMENTS[u.department].label) || esc(u.department || '');
|
const deptLabel = (DEPARTMENTS[u.department] && DEPARTMENTS[u.department].label) || esc(u.department || '');
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td>${esc(u.username)}</td>
|
<td>${esc(u.username)}</td>
|
||||||
<td>${esc(u.displayName)}</td>
|
<td>${esc(u.displayName)}</td>
|
||||||
<td>${esc(u.email || '')}</td>
|
<td>${esc(u.email || '')}</td>
|
||||||
<td>${u.role === 'admin' ? 'Administrator' : u.role === 'kiosk' ? 'Kiosk' : 'Mitglied'}</td>
|
<td>${esc(systemRolleText(u.role))}</td>
|
||||||
<td>${deptLabel}</td>
|
<td>${deptLabel}</td>
|
||||||
<td>${esc(u.initials || '')}</td>
|
<td>${esc(u.initials || '')}</td>
|
||||||
<td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td>
|
<td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td>
|
||||||
<td>${esc(u.personId || '')}</td>
|
<td>${esc(u.personId || '')}</td>
|
||||||
<td>${statusParts.join('<br>')}</td>
|
<td>${statusParts.join('<br>')}</td>
|
||||||
<td class="actions-cell">
|
<td class="actions-cell">
|
||||||
<button class="secondary small" onclick="openEditUserModal(${u.id})">Bearbeiten</button>
|
<span class="user-aktionen">
|
||||||
<button class="secondary small" onclick="resetUserPassword(${u.id}, '${escJs(u.username)}')">Passwort zurücksetzen</button>
|
<button class="secondary small" onclick="openEditUserModal(${u.id})">Bearbeiten</button>
|
||||||
<button class="secondary small" ${isSelf ? 'disabled title="Sie können sich nicht selbst deaktivieren."' : ''} onclick="toggleUserActive(${u.id}, ${u.active})">${toggleLabel}</button>
|
<button type="button" class="user-kebab" aria-haspopup="menu" aria-expanded="false"
|
||||||
${deleteBtn}
|
aria-label="Weitere Aktionen für ${esc(u.displayName || u.username)}"
|
||||||
|
onclick="openUserActionMenu(event, ${u.id})">${PUNKTE_SVG}</button>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -198,7 +199,8 @@
|
|||||||
|
|
||||||
window.openNewUserModal = function(){
|
window.openNewUserModal = function(){
|
||||||
document.getElementById('newUserUsername').value = '';
|
document.getElementById('newUserUsername').value = '';
|
||||||
document.getElementById('newUserDisplayName').value = '';
|
document.getElementById('newUserFirstName').value = '';
|
||||||
|
document.getElementById('newUserLastName').value = '';
|
||||||
document.getElementById('newUserEmail').value = '';
|
document.getElementById('newUserEmail').value = '';
|
||||||
document.getElementById('newUserRole').value = 'member';
|
document.getElementById('newUserRole').value = 'member';
|
||||||
document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT;
|
document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT;
|
||||||
@@ -211,27 +213,66 @@
|
|||||||
document.getElementById('newUserError').textContent = '';
|
document.getElementById('newUserError').textContent = '';
|
||||||
personIdManuallyEdited = false;
|
personIdManuallyEdited = false;
|
||||||
initialsManuallyEdited = false;
|
initialsManuallyEdited = false;
|
||||||
|
emailManuallyEdited = false;
|
||||||
|
usernameManuallyEdited = false;
|
||||||
document.getElementById('newUserModal').style.display = 'flex';
|
document.getElementById('newUserModal').style.display = 'flex';
|
||||||
document.getElementById('newUserUsername').focus();
|
document.getElementById('newUserFirstName').focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.closeNewUserModal = function(){
|
window.closeNewUserModal = function(){
|
||||||
document.getElementById('newUserModal').style.display = 'none';
|
document.getElementById('newUserModal').style.display = 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
// onNewUserDisplayNameInput: schlägt beim Tippen des Anzeigenamens eine
|
// onNewUserNameInput: schlägt beim Tippen von Vor-/Nachname E-Mail,
|
||||||
// Person-ID über personIdForName() vor (js/utils.js) — nur solange das Feld
|
// Benutzername (= Anfang der E-Mail, z. B. m.koerner), Person-ID
|
||||||
// nicht bereits manuell bearbeitet wurde (Kollisionsvermeidung laut Plan).
|
// (personIdForName()) und Kürzel vor (js/utils.js) — jedes Feld nur,
|
||||||
window.onNewUserDisplayNameInput = function(){
|
// solange es nicht bereits manuell bearbeitet wurde.
|
||||||
const name = document.getElementById('newUserDisplayName').value;
|
window.onNewUserNameInput = function(){
|
||||||
if(!personIdManuallyEdited){
|
const vorname = document.getElementById('newUserFirstName').value;
|
||||||
document.getElementById('newUserPersonId').value = name.trim() ? personIdForName(name) : '';
|
const nachname = document.getElementById('newUserLastName').value;
|
||||||
}
|
const name = (vorname.trim() + ' ' + nachname.trim()).trim();
|
||||||
if(!initialsManuallyEdited){
|
if(!initialsManuallyEdited){
|
||||||
document.getElementById('newUserInitials').value = name.trim() ? initialsForName(name) : '';
|
document.getElementById('newUserInitials').value = name.trim() ? initialsForName(name) : '';
|
||||||
}
|
}
|
||||||
|
personIdAusKuerzel();
|
||||||
|
// Firmen-Adresse nach dem Schema v.nachname@rauschenberg-ing.de
|
||||||
|
// (emailVorschlag(), js/utils.js) — wie Person-ID und Kürzel nur,
|
||||||
|
// solange das Feld nicht von Hand geändert wurde.
|
||||||
|
if(!emailManuallyEdited){
|
||||||
|
document.getElementById('newUserEmail').value = emailVorschlag(vorname, nachname);
|
||||||
|
}
|
||||||
|
benutzernameAusEmail();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// personIdAusKuerzel: Person-ID aus dem Kürzel ("MK" → "p_mk") — dasselbe
|
||||||
|
// Schema, das alle Bestandskonten tragen (ihre IDs stammen aus der Zeit,
|
||||||
|
// als die Personenliste über das Kürzel geführt wurde) und an dem ihre
|
||||||
|
// Projektzuordnungen hängen. Vorher kam die ID aus dem vollen Namen
|
||||||
|
// ("p_miriam_k_rner"), sodass neue und alte Konten verschieden aussahen.
|
||||||
|
// Ist die ID schon vergeben, wird eine Ziffer angehängt (p_mk2, p_mk3 …),
|
||||||
|
// denn person_id ist eindeutig. Folgt dem Kürzel, solange die ID nicht
|
||||||
|
// von Hand geändert wurde.
|
||||||
|
function personIdAusKuerzel(){
|
||||||
|
if(personIdManuallyEdited) return;
|
||||||
|
const kuerzel = document.getElementById('newUserInitials').value.trim();
|
||||||
|
const feld = document.getElementById('newUserPersonId');
|
||||||
|
if(!kuerzel){ feld.value = ''; return; }
|
||||||
|
const basis = personIdForName(kuerzel);
|
||||||
|
const vergeben = new Set(usersCache.map(u => u.personId).filter(Boolean));
|
||||||
|
let id = basis, n = 2;
|
||||||
|
while(vergeben.has(id)) id = basis + (n++);
|
||||||
|
feld.value = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// benutzernameAusEmail: Benutzername = Anfang der E-Mail-Adresse. Folgt
|
||||||
|
// der Adresse auch dann, wenn sie von Hand geändert wird (z. B. bei einem
|
||||||
|
// Doppelnamen) — solange der Benutzername selbst nicht von Hand geändert
|
||||||
|
// wurde.
|
||||||
|
function benutzernameAusEmail(){
|
||||||
|
if(usernameManuallyEdited) return;
|
||||||
|
const adresse = document.getElementById('newUserEmail').value.trim();
|
||||||
|
document.getElementById('newUserUsername').value = adresse.includes('@') ? adresse.slice(0, adresse.indexOf('@')) : adresse;
|
||||||
|
}
|
||||||
window.onNewUserPwModeChange = function(){
|
window.onNewUserPwModeChange = function(){
|
||||||
const manual = document.querySelector('input[name="newUserPwMode"]:checked').value === 'manual';
|
const manual = document.querySelector('input[name="newUserPwMode"]:checked').value === 'manual';
|
||||||
document.getElementById('newUserPassword').style.display = manual ? '' : 'none';
|
document.getElementById('newUserPassword').style.display = manual ? '' : 'none';
|
||||||
@@ -239,7 +280,9 @@
|
|||||||
|
|
||||||
window.saveNewUser = function(){
|
window.saveNewUser = function(){
|
||||||
const username = document.getElementById('newUserUsername').value.trim();
|
const username = document.getElementById('newUserUsername').value.trim();
|
||||||
const displayName = document.getElementById('newUserDisplayName').value.trim();
|
// Mit großem Anfangsbuchstaben gespeichert.
|
||||||
|
const firstName = grossAnfang(document.getElementById('newUserFirstName').value);
|
||||||
|
const lastName = grossAnfang(document.getElementById('newUserLastName').value);
|
||||||
const email = document.getElementById('newUserEmail').value.trim();
|
const email = document.getElementById('newUserEmail').value.trim();
|
||||||
const role = document.getElementById('newUserRole').value;
|
const role = document.getElementById('newUserRole').value;
|
||||||
const department = document.getElementById('newUserDepartment').value;
|
const department = document.getElementById('newUserDepartment').value;
|
||||||
@@ -251,8 +294,12 @@
|
|||||||
const errEl = document.getElementById('newUserError');
|
const errEl = document.getElementById('newUserError');
|
||||||
errEl.textContent = '';
|
errEl.textContent = '';
|
||||||
|
|
||||||
if(!username || !displayName){
|
if(!firstName || !lastName){
|
||||||
errEl.textContent = 'Benutzername und Anzeigename sind erforderlich.';
|
errEl.textContent = 'Vor- und Nachname sind erforderlich.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!username){
|
||||||
|
errEl.textContent = 'Benutzername ist erforderlich.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(!department){
|
if(!department){
|
||||||
@@ -275,7 +322,7 @@
|
|||||||
errEl.textContent = 'Bitte eine Personen-Kennung vergeben — ohne sie kann der Benutzer keiner Sitzung und keinen Aufgaben zugeordnet werden.';
|
errEl.textContent = 'Bitte eine Personen-Kennung vergeben — ohne sie kann der Benutzer keiner Sitzung und keinen Aufgaben zugeordnet werden.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = { username, displayName, email, role, personId, department, initials, capacityHours, roles };
|
const body = { username, firstName, lastName, email, role, personId, department, initials, capacityHours, roles };
|
||||||
if(pwMode === 'manual'){
|
if(pwMode === 'manual'){
|
||||||
const password = document.getElementById('newUserPassword').value;
|
const password = document.getElementById('newUserPassword').value;
|
||||||
if(password.length < 8){
|
if(password.length < 8){
|
||||||
@@ -307,8 +354,13 @@
|
|||||||
if(!u) return;
|
if(!u) return;
|
||||||
editingUserId = id;
|
editingUserId = id;
|
||||||
document.getElementById('editUserUsernameLabel').textContent = 'Benutzername: ' + u.username + ' (nicht änderbar)';
|
document.getElementById('editUserUsernameLabel').textContent = 'Benutzername: ' + u.username + ' (nicht änderbar)';
|
||||||
document.getElementById('editUserDisplayName').value = u.displayName || '';
|
// Konten von vor der Umstellung tragen evtl. noch keinen Vor-/Nachnamen:
|
||||||
|
// dann aus dem Anzeigenamen geteilt (erstes Wort / Rest).
|
||||||
|
const teile = String(u.displayName || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
document.getElementById('editUserFirstName').value = u.firstName || u.lastName ? (u.firstName || '') : (teile[0] || '');
|
||||||
|
document.getElementById('editUserLastName').value = u.firstName || u.lastName ? (u.lastName || '') : teile.slice(1).join(' ');
|
||||||
document.getElementById('editUserEmail').value = u.email || '';
|
document.getElementById('editUserEmail').value = u.email || '';
|
||||||
|
onEditUserNameInput();
|
||||||
document.getElementById('editUserRole').value = u.role;
|
document.getElementById('editUserRole').value = u.role;
|
||||||
document.getElementById('editUserDepartment').value = u.department || DEFAULT_DEPARTMENT;
|
document.getElementById('editUserDepartment').value = u.department || DEFAULT_DEPARTMENT;
|
||||||
document.getElementById('editUserInitials').value = u.initials || '';
|
document.getElementById('editUserInitials').value = u.initials || '';
|
||||||
@@ -346,7 +398,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = {
|
const body = {
|
||||||
displayName: document.getElementById('editUserDisplayName').value.trim(),
|
firstName: grossAnfang(document.getElementById('editUserFirstName').value),
|
||||||
|
lastName: grossAnfang(document.getElementById('editUserLastName').value),
|
||||||
email: document.getElementById('editUserEmail').value.trim(),
|
email: document.getElementById('editUserEmail').value.trim(),
|
||||||
role: document.getElementById('editUserRole').value,
|
role: document.getElementById('editUserRole').value,
|
||||||
department,
|
department,
|
||||||
@@ -447,8 +500,8 @@
|
|||||||
|
|
||||||
// ---------- Zeilen-Aktionen (Passwort-Reset, Aktivieren/Deaktivieren, Löschen) ----------
|
// ---------- Zeilen-Aktionen (Passwort-Reset, Aktivieren/Deaktivieren, Löschen) ----------
|
||||||
|
|
||||||
window.resetUserPassword = function(id, username){
|
window.resetUserPassword = function(id, username, ohneRueckfrage){
|
||||||
if(!confirm('Passwort für "' + username + '" wirklich zurücksetzen? Bisherige Sitzungen dieses Benutzers werden beendet.')) return;
|
if(!ohneRueckfrage && !confirm('Passwort für "' + username + '" wirklich zurücksetzen? Bisherige Sitzungen dieses Benutzers werden beendet.')) return;
|
||||||
apiFetch('/admin/users/' + id + '/reset-password', { method: 'POST' }).then(res => {
|
apiFetch('/admin/users/' + id + '/reset-password', { method: 'POST' }).then(res => {
|
||||||
if(!res.ok || !res.data || !res.data.ok){
|
if(!res.ok || !res.data || !res.data.ok){
|
||||||
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
||||||
@@ -459,9 +512,9 @@
|
|||||||
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
window.toggleUserActive = function(id, currentlyActive){
|
window.toggleUserActive = function(id, currentlyActive, ohneRueckfrage){
|
||||||
const goingActive = !currentlyActive;
|
const goingActive = !currentlyActive;
|
||||||
if(!goingActive && !confirm('Benutzer wirklich deaktivieren? Bestehende Sitzungen werden sofort beendet.')) return;
|
if(!goingActive && !ohneRueckfrage && !confirm('Benutzer wirklich deaktivieren? Bestehende Sitzungen werden sofort beendet.')) return;
|
||||||
apiFetch('/admin/users/' + id, { method: 'PATCH', body: JSON.stringify({ active: goingActive }) }).then(res => {
|
apiFetch('/admin/users/' + id, { method: 'PATCH', body: JSON.stringify({ active: goingActive }) }).then(res => {
|
||||||
if(!res.ok || !res.data || !res.data.ok){
|
if(!res.ok || !res.data || !res.data.ok){
|
||||||
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
||||||
@@ -471,17 +524,177 @@
|
|||||||
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
window.hardDeleteUser = function(id, username){
|
window.hardDeleteUser = function(id, username, ohneRueckfrage){
|
||||||
if(!confirm('Benutzer "' + username + '" wirklich ENDGÜLTIG löschen? Das kann nicht rückgängig gemacht werden.')) return;
|
if(!ohneRueckfrage && !confirm('Benutzer "' + username + '" wirklich ENDGÜLTIG löschen? Die Person wird auch aus allen Projekten und Aufgaben der aktuellen Sitzung entfernt. Das kann nicht rückgängig gemacht werden.')) return;
|
||||||
apiFetch('/admin/users/' + id + '?hard=1', { method: 'DELETE' }).then(res => {
|
apiFetch('/admin/users/' + id + '?hard=1', { method: 'DELETE' }).then(res => {
|
||||||
if(!res.ok || !res.data || !res.data.ok){
|
if(!res.ok || !res.data || !res.data.ok){
|
||||||
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadAndRenderUsers();
|
loadAndRenderUsers();
|
||||||
|
// Der Server hat die Person aus der aktuellen Sitzung entfernt (Projekt-
|
||||||
|
// zuordnungen, Aufgaben, Teilnehmerkreis). Neu laden, damit Projekte
|
||||||
|
// und Auslastung in diesem Browser sofort ohne sie dastehen und das
|
||||||
|
// nächste Speichern nicht auf einem veralteten Stand aufsetzt.
|
||||||
|
if(typeof load === 'function') load(true);
|
||||||
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------- Drei-Punkte-Menü der Zeilen ----------
|
||||||
|
// „Bearbeiten" bleibt als Knopf in der Zeile, die selteneren Aktionen
|
||||||
|
// liegen hinter den drei Punkten (Eriks Wahl „C" aus dem Vergleich der
|
||||||
|
// Menü-Varianten). Was nicht rückgängig zu machen ist — Passwort
|
||||||
|
// zurücksetzen, Deaktivieren, Endgültig löschen — fragt direkt im Menü
|
||||||
|
// nach, statt ein Browser-Dialogfenster zu öffnen.
|
||||||
|
//
|
||||||
|
// Das Menü hängt an <body> und steht `position: fixed`: Die Tabelle liegt
|
||||||
|
// in einem quer scrollbaren Rahmen, der ein darin verankertes Menü
|
||||||
|
// abschneiden würde. Reicht der Platz unter dem Knopf nicht, klappt es nach
|
||||||
|
// oben auf. Scrollen oder Fenstergröße ändern schließt es.
|
||||||
|
|
||||||
|
const PUNKTE_SVG = '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.2" cy="8" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12.8" cy="8" r="1.4"/></svg>';
|
||||||
|
const MENU_IC = {
|
||||||
|
schluessel: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5.5" cy="10.5" r="3"/><path d="M7.6 8.4L13.5 2.5M11 5l1.8 1.8M12.5 3.5l1.5 1.5"/></svg>',
|
||||||
|
aus: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M4 12L12 4"/></svg>',
|
||||||
|
an: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M5.5 8.2l1.8 1.8 3.4-3.6"/></svg>',
|
||||||
|
muell: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4.5h11M6 4.5V3h4v1.5M4 4.5l.7 8.5h6.6l.7-8.5"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
|
// offenesMenu: { el, knopf, zeile, u } solange ein Menü offen ist.
|
||||||
|
let offenesMenu = null;
|
||||||
|
|
||||||
|
function menuEintrag(ic, text, aktion, opts){
|
||||||
|
opts = opts || {};
|
||||||
|
return `<button type="button" role="menuitem" class="user-menu-item${opts.gefahr ? ' is-danger' : ''}"
|
||||||
|
data-aktion="${aktion}" ${opts.gesperrt ? 'aria-disabled="true"' : ''}>
|
||||||
|
${MENU_IC[ic]}<span class="user-menu-text"><span>${esc(text)}</span>${opts.unter ? `<span class="user-menu-sub">${esc(opts.unter)}</span>` : ''}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuInhalt(u){
|
||||||
|
const isSelf = currentUser && currentUser.id === u.id;
|
||||||
|
let h = menuEintrag('schluessel', 'Passwort zurücksetzen', 'passwort');
|
||||||
|
h += u.active
|
||||||
|
? menuEintrag('aus', 'Deaktivieren', 'deaktivieren', { gesperrt: isSelf, unter: isSelf ? 'Eigenes Konto' : '' })
|
||||||
|
: menuEintrag('an', 'Aktivieren', 'aktivieren');
|
||||||
|
if(!u.active){
|
||||||
|
h += '<hr class="user-menu-trenner">'
|
||||||
|
+ menuEintrag('muell', 'Endgültig löschen', 'loeschen', { gefahr: true, gesperrt: isSelf });
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUECKFRAGEN = {
|
||||||
|
passwort: u => ({ frage: 'Passwort von <b>' + esc(u.displayName || u.username) + '</b> zurücksetzen? Alle Sitzungen werden abgemeldet.', knopf: 'Zurücksetzen' }),
|
||||||
|
deaktivieren: u => ({ frage: '<b>' + esc(u.displayName || u.username) + '</b> deaktivieren? Alle Sitzungen werden sofort abgemeldet.', knopf: 'Deaktivieren' }),
|
||||||
|
loeschen: u => ({ frage: '<b>' + esc(u.displayName || u.username) + '</b> endgültig löschen? Die Person wird auch aus allen Projekten und Aufgaben der aktuellen Sitzung entfernt. Das lässt sich nicht rückgängig machen.', knopf: 'Löschen' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
function menuFokusziele(){
|
||||||
|
return offenesMenu ? Array.from(offenesMenu.el.querySelectorAll('button:not([aria-disabled="true"])')) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuPositionieren(){
|
||||||
|
if(!offenesMenu) return;
|
||||||
|
const { el, knopf } = offenesMenu;
|
||||||
|
const r = knopf.getBoundingClientRect();
|
||||||
|
const m = el.getBoundingClientRect();
|
||||||
|
const nachOben = (window.innerHeight - r.bottom) < m.height + 12 && r.top > m.height + 12;
|
||||||
|
el.classList.toggle('nach-oben', nachOben);
|
||||||
|
el.style.top = (nachOben ? r.top - m.height - 6 : r.bottom + 6) + 'px';
|
||||||
|
el.style.left = Math.max(8, Math.min(r.right - m.width, window.innerWidth - m.width - 8)) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function schliesseUserMenu(fokusZurueck){
|
||||||
|
if(!offenesMenu) return;
|
||||||
|
const { el, knopf, zeile } = offenesMenu;
|
||||||
|
offenesMenu = null;
|
||||||
|
el.remove();
|
||||||
|
knopf.setAttribute('aria-expanded', 'false');
|
||||||
|
if(zeile) zeile.classList.remove('is-menu-open');
|
||||||
|
if(fokusZurueck && document.contains(knopf)) knopf.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeMenuEintraege(){
|
||||||
|
offenesMenu.el.innerHTML = menuInhalt(offenesMenu.u);
|
||||||
|
menuPositionieren();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fuehreAus(aktion, u){
|
||||||
|
schliesseUserMenu(true);
|
||||||
|
if(aktion === 'passwort') resetUserPassword(u.id, u.username, true);
|
||||||
|
else if(aktion === 'deaktivieren') toggleUserActive(u.id, true, true);
|
||||||
|
else if(aktion === 'aktivieren') toggleUserActive(u.id, false, true);
|
||||||
|
else if(aktion === 'loeschen') hardDeleteUser(u.id, u.username, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// openUserActionMenu: öffnet (bzw. schließt bei erneutem Klick) das Menü
|
||||||
|
// einer Zeile. Per Tastatur geöffnet (event.detail === 0) landet der Fokus
|
||||||
|
// gleich auf dem ersten Eintrag.
|
||||||
|
window.openUserActionMenu = function(event, id){
|
||||||
|
event.stopPropagation();
|
||||||
|
const knopf = event.currentTarget;
|
||||||
|
if(offenesMenu && offenesMenu.knopf === knopf){ schliesseUserMenu(true); return; }
|
||||||
|
schliesseUserMenu(false);
|
||||||
|
const u = usersCache.find(x => x.id === id);
|
||||||
|
if(!u) return;
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'user-menu';
|
||||||
|
el.setAttribute('role', 'menu');
|
||||||
|
document.body.appendChild(el);
|
||||||
|
const zeile = knopf.closest('tr');
|
||||||
|
offenesMenu = { el, knopf, zeile, u };
|
||||||
|
knopf.setAttribute('aria-expanded', 'true');
|
||||||
|
if(zeile) zeile.classList.add('is-menu-open');
|
||||||
|
zeigeMenuEintraege();
|
||||||
|
|
||||||
|
el.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const b = e.target.closest('button');
|
||||||
|
if(!b || b.getAttribute('aria-disabled') === 'true') return;
|
||||||
|
if(b.dataset.zurueck){ zeigeMenuEintraege(); const z = menuFokusziele(); if(z[0]) z[0].focus(); return; }
|
||||||
|
if(b.dataset.ausfuehren){ fuehreAus(b.dataset.ausfuehren, u); return; }
|
||||||
|
const aktion = b.dataset.aktion;
|
||||||
|
if(RUECKFRAGEN[aktion]){
|
||||||
|
const r = RUECKFRAGEN[aktion](u);
|
||||||
|
el.innerHTML = `<div class="user-menu-confirm"><p>${r.frage}</p><div class="user-menu-confirm-row">
|
||||||
|
<button type="button" class="secondary small" data-zurueck="1">Abbrechen</button>
|
||||||
|
<button type="button" class="danger small" data-ausfuehren="${aktion}">${esc(r.knopf)}</button>
|
||||||
|
</div></div>`;
|
||||||
|
menuPositionieren();
|
||||||
|
el.querySelector('[data-ausfuehren]').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fuehreAus(aktion, u);
|
||||||
|
});
|
||||||
|
|
||||||
|
if(event.detail === 0){ const z = menuFokusziele(); if(z[0]) z[0].focus(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', () => schliesseUserMenu(false));
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if(!offenesMenu) return;
|
||||||
|
const ziele = menuFokusziele();
|
||||||
|
const i = ziele.indexOf(document.activeElement);
|
||||||
|
if(e.key === 'Escape'){ e.preventDefault(); schliesseUserMenu(true); }
|
||||||
|
else if(e.key === 'ArrowDown'){ e.preventDefault(); if(ziele.length) ziele[(i + 1) % ziele.length].focus(); }
|
||||||
|
else if(e.key === 'ArrowUp'){ e.preventDefault(); if(ziele.length) ziele[(i - 1 + ziele.length) % ziele.length].focus(); }
|
||||||
|
else if(e.key === 'Tab'){ schliesseUserMenu(false); }
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => schliesseUserMenu(false));
|
||||||
|
window.addEventListener('scroll', () => schliesseUserMenu(false), true);
|
||||||
|
|
||||||
|
// onEditUserNameInput: Im Bearbeiten-Dialog wird eine vorhandene Adresse
|
||||||
|
// nie überschrieben; ist das E-Mail-Feld leer, steht der Vorschlag als
|
||||||
|
// Platzhalter darin und folgt Vor-/Nachname.
|
||||||
|
window.onEditUserNameInput = function(){
|
||||||
|
const feld = document.getElementById('editUserEmail');
|
||||||
|
if(!feld) return;
|
||||||
|
feld.placeholder = emailVorschlag(document.getElementById('editUserFirstName').value,
|
||||||
|
document.getElementById('editUserLastName').value) || 'v.nachname@' + EMAIL_DOMAIN;
|
||||||
|
};
|
||||||
|
|
||||||
// personIdManuallyEdited wird gesetzt, sobald der Nutzer das Person-ID-Feld
|
// personIdManuallyEdited wird gesetzt, sobald der Nutzer das Person-ID-Feld
|
||||||
// selbst bearbeitet (nach dem Laden des DOM gebunden).
|
// selbst bearbeitet (nach dem Laden des DOM gebunden).
|
||||||
document.addEventListener('DOMContentLoaded', function(){
|
document.addEventListener('DOMContentLoaded', function(){
|
||||||
@@ -491,7 +704,15 @@
|
|||||||
}
|
}
|
||||||
const initialsInput = document.getElementById('newUserInitials');
|
const initialsInput = document.getElementById('newUserInitials');
|
||||||
if(initialsInput){
|
if(initialsInput){
|
||||||
initialsInput.addEventListener('input', function(){ initialsManuallyEdited = true; });
|
initialsInput.addEventListener('input', function(){ initialsManuallyEdited = true; personIdAusKuerzel(); });
|
||||||
|
}
|
||||||
|
const emailInput = document.getElementById('newUserEmail');
|
||||||
|
if(emailInput){
|
||||||
|
emailInput.addEventListener('input', function(){ emailManuallyEdited = true; benutzernameAusEmail(); });
|
||||||
|
}
|
||||||
|
const usernameInput = document.getElementById('newUserUsername');
|
||||||
|
if(usernameInput){
|
||||||
|
usernameInput.addEventListener('input', function(){ usernameManuallyEdited = true; });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const ADMIN_SECTIONS = [
|
|||||||
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen' },
|
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen' },
|
||||||
{ key: 'planchecker', title: 'Plan-Checker-Merkmale' },
|
{ key: 'planchecker', title: 'Plan-Checker-Merkmale' },
|
||||||
{ key: 'betrieb', title: 'Betrieb & Sicherheit' },
|
{ key: 'betrieb', title: 'Betrieb & Sicherheit' },
|
||||||
|
{ key: 'papierkorb', title: 'Papierkorb' },
|
||||||
{ key: 'datenbank', title: 'Datenbank & Sitzungen' },
|
{ key: 'datenbank', title: 'Datenbank & Sitzungen' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -136,6 +137,9 @@ function showAdminSection(key){
|
|||||||
// aendert sich auch ausserhalb dieses Browsers, und die uebrigen Masken
|
// aendert sich auch ausserhalb dieses Browsers, und die uebrigen Masken
|
||||||
// rechnen mit ihr (Wochenstunden -> Auslastung).
|
// rechnen mit ihr (Wochenstunden -> Auslastung).
|
||||||
if(key === 'benutzer' && typeof renderAdminUsers === 'function') renderAdminUsers();
|
if(key === 'benutzer' && typeof renderAdminUsers === 'function') renderAdminUsers();
|
||||||
|
// Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso
|
||||||
|
// frisch holen.
|
||||||
|
if(key === 'papierkorb') loadAdminPapierkorb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
history.replaceState(null, '', location.pathname + location.search + '#' + key);
|
history.replaceState(null, '', location.pathname + location.search + '#' + key);
|
||||||
@@ -931,3 +935,73 @@ function loadHolidaysIntoState(){
|
|||||||
})
|
})
|
||||||
.catch(err => console.error('Feiertage neu laden fehlgeschlagen:', err));
|
.catch(err => console.error('Feiertage neu laden fehlgeschlagen:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Papierkorb gelöschter Projekte
|
||||||
|
// -----------------------------
|
||||||
|
// Ein aus der Projektliste gelöschtes Projekt legt der Server beim Speichern
|
||||||
|
// der Sitzung als Schnappschuss in deleted_projects ab (server.js,
|
||||||
|
// /save-week). Hier lässt es sich 30 Tage lang in die aktuelle Sitzung
|
||||||
|
// zurückholen oder vorzeitig endgültig entfernen.
|
||||||
|
|
||||||
|
// loadAdminPapierkorb: holt die Liste (GET /admin/deleted-projects) und
|
||||||
|
// rendert sie in #adminPapierkorbList.
|
||||||
|
async function loadAdminPapierkorb(){
|
||||||
|
const el = document.getElementById('adminPapierkorbList');
|
||||||
|
if(!el) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(baseUrl() + '/admin/deleted-projects');
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||||
|
if(!data.projekte.length){
|
||||||
|
el.innerHTML = '<p class="hint">Der Papierkorb ist leer.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tagMs = 24 * 60 * 60 * 1000;
|
||||||
|
el.innerHTML = data.projekte.map(p => {
|
||||||
|
const bez = ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() || '(ohne Bezeichnung)';
|
||||||
|
const am = new Date(p.deletedAt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
const rest = Math.max(0, Math.ceil((Date.parse(p.endgueltigAm) - Date.now()) / tagMs));
|
||||||
|
return `<div class="template-admin-row">
|
||||||
|
<span class="template-admin-title">${esc(bez)}
|
||||||
|
<span class="hint" style="margin:0"> · gelöscht ${esc(am)}${p.deletedBy ? ' von ' + esc(p.deletedBy) : ''} · aus ${esc(p.weekLabel)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="sitzung-chip">noch ${rest} ${rest === 1 ? 'Tag' : 'Tage'}</span>
|
||||||
|
<button class="secondary small" onclick="adminProjektWiederherstellen('${esc(p.id)}')">Wiederherstellen</button>
|
||||||
|
<button class="danger small" onclick="adminProjektEndgueltigLoeschen('${esc(p.id)}')" title="Sofort endgültig löschen">×</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch(err){
|
||||||
|
el.innerHTML = '<p class="hint">Papierkorb konnte nicht geladen werden: ' + esc(err.message) + '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminProjektWiederherstellen: holt ein Projekt in die aktuelle Sitzung
|
||||||
|
// zurück. Danach wird neu geladen, damit die Projektliste das Projekt zeigt
|
||||||
|
// und die lokale Revision der Sitzung zum Server passt (sonst meldete das
|
||||||
|
// nächste Speichern einen Konflikt).
|
||||||
|
async function adminProjektWiederherstellen(id){
|
||||||
|
try {
|
||||||
|
const r = await fetch(baseUrl() + '/admin/deleted-projects/' + encodeURIComponent(id) + '/restore', { method: 'POST' });
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||||
|
load();
|
||||||
|
loadAdminPapierkorb();
|
||||||
|
alert('Projekt wiederhergestellt in „' + data.weekLabel + '".');
|
||||||
|
} catch(err){
|
||||||
|
alert('Wiederherstellen fehlgeschlagen: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminProjektEndgueltigLoeschen: entfernt einen Papierkorb-Eintrag sofort.
|
||||||
|
async function adminProjektEndgueltigLoeschen(id){
|
||||||
|
if(!confirm('Projekt endgültig löschen? Das lässt sich nicht rückgängig machen.')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(baseUrl() + '/admin/deleted-projects/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||||
|
loadAdminPapierkorb();
|
||||||
|
} catch(err){
|
||||||
|
alert('Löschen fehlgeschlagen: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
-7
@@ -33,12 +33,30 @@ let currentUser = null;
|
|||||||
function renderUserBar(){
|
function renderUserBar(){
|
||||||
const info = document.getElementById('userBarInfo');
|
const info = document.getElementById('userBarInfo');
|
||||||
if(info && currentUser){
|
if(info && currentUser){
|
||||||
const roleLabel = currentUser.role === 'admin' ? 'Administrator'
|
const roleLabel = systemRolleText(currentUser.role);
|
||||||
: currentUser.role === 'kiosk' ? 'Kiosk' : 'Mitglied';
|
|
||||||
info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')';
|
info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')';
|
||||||
}
|
}
|
||||||
const navAdminSection = document.getElementById('navAdminSection');
|
const navAdminSection = document.getElementById('navAdminSection');
|
||||||
if(navAdminSection) navAdminSection.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
|
if(navAdminSection) navAdminSection.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
|
||||||
|
document.querySelectorAll('.nur-projektleitung').forEach(el => { el.hidden = !darfProjektlisteAendern(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemRolleText: Anzeigename einer Systemrolle (users.role). Auch von der
|
||||||
|
// Benutzerverwaltung (js/admin-users.js) genutzt.
|
||||||
|
function systemRolleText(role){
|
||||||
|
return role === 'admin' ? 'Administrator'
|
||||||
|
: role === 'projektleiter' ? 'Projektleiter'
|
||||||
|
: role === 'kiosk' ? 'Kiosk' : 'Mitglied';
|
||||||
|
}
|
||||||
|
|
||||||
|
// darfProjektlisteAendern: ob das angemeldete Konto Projekte anlegen und aus
|
||||||
|
// der Projektliste löschen darf (Projektleiter und Administratoren). Reines
|
||||||
|
// UI-Gate — dieselbe Regel setzt /save-week serverseitig durch
|
||||||
|
// (darfProjektlisteAendern() in server.js), dort mit 403. Knöpfe zum
|
||||||
|
// Anlegen tragen die Klasse .nur-projektleitung und werden von
|
||||||
|
// renderUserBar() ausgeblendet.
|
||||||
|
function darfProjektlisteAendern(){
|
||||||
|
return !!currentUser && (currentUser.role === 'admin' || currentUser.role === 'projektleiter');
|
||||||
}
|
}
|
||||||
|
|
||||||
// logout: beendet die Server-Session und lädt die Seite neu (zeigt danach
|
// logout: beendet die Server-Session und lädt die Seite neu (zeigt danach
|
||||||
@@ -48,9 +66,63 @@ async function logout(){
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
// showLoginOverlay / hideLoginOverlay: blenden das blockierende Login-Overlay ein/aus.
|
// showLoginOverlay / hideLoginOverlay: blenden die blockierende Login-Seite ein/aus.
|
||||||
function showLoginOverlay(){
|
function showLoginOverlay(){
|
||||||
|
renderLoginWoche();
|
||||||
document.getElementById('loginOverlay').style.display = 'flex';
|
document.getElementById('loginOverlay').style.display = 'flex';
|
||||||
|
const feld = document.getElementById('loginUsername');
|
||||||
|
if(feld && !feld.value) feld.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderLoginWoche: füllt die rechte Seite der Login-Seite — laufende
|
||||||
|
// Kalenderwoche (getWeekNumber(), js/utils.js), Jahr der ISO-Woche, die
|
||||||
|
// Arbeitstage Mo-Fr mit dem heutigen Tag markiert und das Datum.
|
||||||
|
function renderLoginWoche(){
|
||||||
|
const heute = new Date();
|
||||||
|
const kw = getWeekNumber(heute);
|
||||||
|
// ISO-Jahr: das Jahr des Donnerstags dieser Woche (Ende Dezember/Anfang
|
||||||
|
// Januar weicht es vom Kalenderjahr ab, z. B. KW 01 / 2027 am 30.12.2026).
|
||||||
|
const donnerstag = new Date(heute); donnerstag.setDate(heute.getDate() + 3 - ((heute.getDay() + 6) % 7));
|
||||||
|
const zahl = document.getElementById('loginKwZahl');
|
||||||
|
if(!zahl) return;
|
||||||
|
zahl.textContent = String(kw).padStart(2, '0');
|
||||||
|
document.getElementById('loginKwJahr').textContent = donnerstag.getFullYear();
|
||||||
|
document.getElementById('loginDatum').textContent =
|
||||||
|
heute.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
const montag = new Date(heute); montag.setDate(heute.getDate() - ((heute.getDay() + 6) % 7));
|
||||||
|
document.getElementById('loginTage').innerHTML = ['Mo', 'Di', 'Mi', 'Do', 'Fr'].map((n, i) => {
|
||||||
|
const d = new Date(montag); d.setDate(montag.getDate() + i);
|
||||||
|
const istHeute = d.toDateString() === heute.toDateString();
|
||||||
|
return '<li' + (istHeute ? ' class="ist-heute" aria-current="date"' : '') + '><span>' + n + '</span><b>' + d.getDate() + '</b></li>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// verdrahteLoginHilfen: „Anzeigen" für das Passwort und der Hinweis auf
|
||||||
|
// eine aktive Feststelltaste. Einmalig.
|
||||||
|
let loginHilfenVerdrahtet = false;
|
||||||
|
function verdrahteLoginHilfen(){
|
||||||
|
if(loginHilfenVerdrahtet) return;
|
||||||
|
loginHilfenVerdrahtet = true;
|
||||||
|
const pw = document.getElementById('loginPassword');
|
||||||
|
const toggle = document.getElementById('loginPwToggle');
|
||||||
|
const caps = document.getElementById('loginCaps');
|
||||||
|
if(toggle) toggle.addEventListener('click', () => {
|
||||||
|
const sichtbar = pw.type === 'text';
|
||||||
|
pw.type = sichtbar ? 'password' : 'text';
|
||||||
|
toggle.textContent = sichtbar ? 'Anzeigen' : 'Verbergen';
|
||||||
|
toggle.setAttribute('aria-pressed', String(!sichtbar));
|
||||||
|
pw.focus();
|
||||||
|
});
|
||||||
|
const capsPruefen = e => { if(caps && e.getModifierState) caps.hidden = !e.getModifierState('CapsLock'); };
|
||||||
|
pw.addEventListener('keydown', capsPruefen);
|
||||||
|
pw.addEventListener('keyup', capsPruefen);
|
||||||
|
pw.addEventListener('blur', () => { if(caps) caps.hidden = true; });
|
||||||
|
['loginUsername', 'loginPassword'].forEach(id => {
|
||||||
|
document.getElementById(id).addEventListener('input', e => {
|
||||||
|
e.target.closest('.login-feld').classList.remove('hat-fehler');
|
||||||
|
document.getElementById('loginError').hidden = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function hideLoginOverlay(){
|
function hideLoginOverlay(){
|
||||||
document.getElementById('loginOverlay').style.display = 'none';
|
document.getElementById('loginOverlay').style.display = 'none';
|
||||||
@@ -174,24 +246,45 @@ async function checkAuthAndInit(onAuthenticated){
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showLoginOverlay();
|
showLoginOverlay();
|
||||||
|
verdrahteLoginHilfen();
|
||||||
const form = document.getElementById('loginForm');
|
const form = document.getElementById('loginForm');
|
||||||
const errEl = document.getElementById('loginError');
|
const errEl = document.getElementById('loginError');
|
||||||
|
const btn = document.getElementById('loginSubmit');
|
||||||
|
const zeigeFehler = (text, feldId) => {
|
||||||
|
errEl.textContent = text;
|
||||||
|
errEl.hidden = false;
|
||||||
|
if(feldId){
|
||||||
|
const feld = document.getElementById(feldId);
|
||||||
|
feld.closest('.login-feld').classList.add('hat-fehler');
|
||||||
|
feld.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
form.onsubmit = async function(ev){
|
form.onsubmit = async function(ev){
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
errEl.textContent = '';
|
errEl.hidden = true;
|
||||||
const username = document.getElementById('loginUsername').value;
|
const username = document.getElementById('loginUsername').value.trim();
|
||||||
const password = document.getElementById('loginPassword').value;
|
const password = document.getElementById('loginPassword').value;
|
||||||
|
if(!username) return zeigeFehler('Bitte den Benutzernamen eingeben, z. B. m.mustermann.', 'loginUsername');
|
||||||
|
if(!password) return zeigeFehler('Bitte das Passwort eingeben.', 'loginPassword');
|
||||||
|
btn.disabled = true; btn.classList.add('laedt');
|
||||||
try {
|
try {
|
||||||
const r = await fetch(baseUrl() + '/login', {
|
const r = await fetch(baseUrl() + '/login', {
|
||||||
method:'POST', headers:{'Content-Type':'application/json'},
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({ username, password })
|
body: JSON.stringify({ username, password })
|
||||||
});
|
});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if(!r.ok || !d.ok){ errEl.textContent = d.error || 'Anmeldung fehlgeschlagen.'; return; }
|
if(!r.ok || !d.ok){
|
||||||
|
zeigeFehler(d.error || 'Anmeldung fehlgeschlagen.', 'loginPassword');
|
||||||
|
document.getElementById('loginPassword').select();
|
||||||
|
return;
|
||||||
|
}
|
||||||
currentUser = d.user;
|
currentUser = d.user;
|
||||||
|
document.getElementById('loginPassword').value = '';
|
||||||
afterAuthenticated(onAuthenticated);
|
afterAuthenticated(onAuthenticated);
|
||||||
} catch(err){
|
} catch(err){
|
||||||
errEl.textContent = 'Verbindung zum Server fehlgeschlagen.';
|
zeigeFehler('Der Server ist nicht erreichbar. Bitte später erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.classList.remove('laedt');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ function renderBoardRow(p, allPeople, locked, weekLabel, opts){
|
|||||||
// einen Ort haben — hier steht nur, was die LISTE betrifft.
|
// einen Ort haben — hier steht nur, was die LISTE betrifft.
|
||||||
html += `<div class="board-row-actions" onclick="event.stopPropagation()">
|
html += `<div class="board-row-actions" onclick="event.stopPropagation()">
|
||||||
<button type="button" class="secondary small" onclick="openProjectPlan('${p.id}')" title="Projektbereich öffnen">Öffnen</button>
|
<button type="button" class="secondary small" onclick="openProjectPlan('${p.id}')" title="Projektbereich öffnen">Öffnen</button>
|
||||||
${locked ? '' : `<button type="button" class="danger small" onclick="delProjectFromList('${p.id}')" title="Projekt aus dieser Sitzung löschen">Löschen</button>`}
|
${locked || !darfProjektlisteAendern() ? '' : `<button type="button" class="danger small" onclick="delProjectFromList('${p.id}')" title="Projekt löschen (30 Tage durch Administratoren wiederherstellbar)">Löschen</button>`}
|
||||||
</div>`;
|
</div>`;
|
||||||
html += `</div>`; // board-row-main
|
html += `</div>`; // board-row-main
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,20 @@
|
|||||||
|
|
||||||
// delProjectFromList: löscht ein Projekt (per Bestätigungsdialog) in der
|
// delProjectFromList: löscht ein Projekt (per Bestätigungsdialog) in der
|
||||||
// Listen-Ansicht. Parameter `id`: Projekt-ID. Keine Rückgabe; bei gesperrter
|
// Listen-Ansicht. Parameter `id`: Projekt-ID. Keine Rückgabe; bei gesperrter
|
||||||
// Sitzung abgebrochen.
|
// Sitzung abgebrochen. Nur für Projektleiter und Administratoren
|
||||||
|
// (darfProjektlisteAendern(), js/auth.js; serverseitig in /save-week
|
||||||
|
// durchgesetzt). Der Server legt das Projekt beim Speichern in den
|
||||||
|
// Papierkorb, aus dem es ein Administrator 30 Tage lang zurückholen kann
|
||||||
|
// (Admin → Papierkorb, js/admin/admin.js).
|
||||||
function delProjectFromList(id){
|
function delProjectFromList(id){
|
||||||
if(guardWeekLocked()) return;
|
if(guardWeekLocked()) return;
|
||||||
if(!confirm("Projekt aus dieser Sitzung löschen?")) return;
|
if(!darfProjektlisteAendern()){
|
||||||
|
alert('Projekte löschen dürfen nur Projektleiter und Administratoren.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = currentWeek().projects.find(x => x.id === id);
|
||||||
|
const bez = p ? ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() : '';
|
||||||
|
if(!confirm('Projekt ' + (bez ? '„' + bez + '" ' : '') + 'löschen?\n\nEin Administrator kann es 30 Tage lang wiederherstellen.')) return;
|
||||||
const w = currentWeek();
|
const w = currentWeek();
|
||||||
w.projects = w.projects.filter(p=>p.id!==id);
|
w.projects = w.projects.filter(p=>p.id!==id);
|
||||||
save(); renderProjects(); renderBoard();
|
save(); renderProjects(); renderBoard();
|
||||||
@@ -160,7 +170,7 @@ function renderProjects(){
|
|||||||
</div>
|
</div>
|
||||||
<div class="project-card-footer">
|
<div class="project-card-footer">
|
||||||
<button class="secondary small" onclick="openLpModal('${p.id}')" ${locked ? 'disabled' : ''}>LP-Aufwand</button>
|
<button class="secondary small" onclick="openLpModal('${p.id}')" ${locked ? 'disabled' : ''}>LP-Aufwand</button>
|
||||||
<button class="danger small" onclick="delProjectFromList('${p.id}')" ${locked ? 'disabled' : ''}>Löschen</button>
|
<button class="danger small" onclick="delProjectFromList('${p.id}')" ${locked || !darfProjektlisteAendern() ? 'disabled' : ''}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,9 +51,15 @@ const GEWERK_LABELS = { ELT: 'Elektrotechnik', HLS: 'Heizung · Lüftung · Sani
|
|||||||
let wizard = null;
|
let wizard = null;
|
||||||
|
|
||||||
// openNewProjWizard: öffnet den Assistenten mit leerem Zustand.
|
// openNewProjWizard: öffnet den Assistenten mit leerem Zustand.
|
||||||
// Keine Parameter, keine Rückgabe; gesperrte Sitzungen werden abgewiesen.
|
// Keine Parameter, keine Rückgabe; gesperrte Sitzungen werden abgewiesen,
|
||||||
|
// ebenso Konten ohne Rolle Projektleiter/Administrator (die Knöpfe sind für
|
||||||
|
// sie ohnehin ausgeblendet; /save-week weist das Anlegen mit 403 ab).
|
||||||
function openNewProjWizard(){
|
function openNewProjWizard(){
|
||||||
if(guardWeekLocked()) return;
|
if(guardWeekLocked()) return;
|
||||||
|
if(!darfProjektlisteAendern()){
|
||||||
|
alert('Projekte anlegen dürfen nur Projektleiter und Administratoren.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
wizard = {
|
wizard = {
|
||||||
schritt: 0,
|
schritt: 0,
|
||||||
fertig: null, // angelegtes Projekt, solange der Abschluss steht
|
fertig: null, // angelegtes Projekt, solange der Abschluss steht
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function renderSettingsAccount(){
|
|||||||
+ `<div class="settings-kv-row">
|
+ `<div class="settings-kv-row">
|
||||||
<span class="settings-kv-key">E-Mail</span>
|
<span class="settings-kv-key">E-Mail</span>
|
||||||
<span class="settings-email-edit">
|
<span class="settings-email-edit">
|
||||||
<input type="email" id="settingsEmailInput" value="${esc(currentUser.email || '')}" placeholder="name@firma.de">
|
<input type="email" id="settingsEmailInput" value="${esc(currentUser.email || '')}" placeholder="${esc(emailVorschlag(currentUser.displayName) || 'v.nachname@' + EMAIL_DOMAIN)}">
|
||||||
<button class="secondary" onclick="submitEmailChange()">Speichern</button>
|
<button class="secondary" onclick="submitEmailChange()">Speichern</button>
|
||||||
<span id="settingsEmailError" class="settings-warn" hidden></span>
|
<span id="settingsEmailError" class="settings-warn" hidden></span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -104,6 +104,50 @@ function initialsForName(name){
|
|||||||
return words.slice(0, 4).map(w => w[0]).join('').toUpperCase();
|
return words.slice(0, 4).map(w => w[0]).join('').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EMAIL_DOMAIN: Domain der Firmen-Adressen, Grundlage von emailVorschlag().
|
||||||
|
const EMAIL_DOMAIN = 'rauschenberg-ing.de';
|
||||||
|
|
||||||
|
// emailVorschlag: schlägt aus einem Namen die Firmen-Adresse nach dem Schema
|
||||||
|
// „Anfangsbuchstabe Vorname . Nachname @rauschenberg-ing.de" vor, klein
|
||||||
|
// geschrieben, Umlaute umschrieben (ä→ae, ß→ss), übrige Sonderzeichen
|
||||||
|
// entfernt: "Miriam Körner" → "m.koerner@rauschenberg-ing.de". Vorname ist
|
||||||
|
// das erste, Nachname das letzte Wort. Nur ein Vorschlag — bleibt
|
||||||
|
// überschreibbar. Parameter: name (String) — oder, wenn `nachname` gegeben
|
||||||
|
// ist, Vorname und Nachname getrennt (dann zählt der ganze Nachname, z. B.
|
||||||
|
// "von Plum" → "vonplum"). Rückgabe: Adresse oder ''.
|
||||||
|
function emailVorschlag(name, nachname){
|
||||||
|
if(nachname !== undefined){
|
||||||
|
const v = String(name || '').trim(), n = String(nachname || '').trim();
|
||||||
|
if(!v || !n) return emailVorschlag((v + ' ' + n).trim());
|
||||||
|
const l = emailVorschlag(v.split(/\s+/)[0] + ' ' + n.replace(/\s+/g, ''));
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
const teil = s => String(s).toLowerCase()
|
||||||
|
.replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss')
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g,'')
|
||||||
|
.replace(/[^a-z0-9-]/g,'');
|
||||||
|
const woerter = String(name || '').trim().split(/\s+/).map(teil).filter(Boolean);
|
||||||
|
if(woerter.length === 0) return '';
|
||||||
|
const lokal = woerter.length === 1 ? woerter[0] : woerter[0][0] + '.' + woerter[woerter.length - 1];
|
||||||
|
return lokal + '@' + EMAIL_DOMAIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// benutzernameVorschlag: Benutzername eines neuen Kontos = Anfang der
|
||||||
|
// E-Mail-Adresse, also "Anfangsbuchstabe Vorname . Nachname" in der
|
||||||
|
// Schreibweise von emailVorschlag() ("Miriam Körner" → "m.koerner").
|
||||||
|
// Angemeldet wird mit genau diesem Namen. Rückgabe: String oder ''.
|
||||||
|
function benutzernameVorschlag(vorname, nachname){
|
||||||
|
const adresse = emailVorschlag(vorname, nachname);
|
||||||
|
return adresse ? adresse.slice(0, adresse.indexOf('@')) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// grossAnfang: erster Buchstabe groß, Leerraum zusammengefasst — für Vor-
|
||||||
|
// und Nachnamen beim Speichern ("miriam" → "Miriam").
|
||||||
|
function grossAnfang(s){
|
||||||
|
s = String(s || '').trim().replace(/\s+/g, ' ');
|
||||||
|
return s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
|
||||||
|
}
|
||||||
|
|
||||||
// formatWeekDate: wandelt ein ISO-Datum (YYYY-MM-DD) in deutsches Format DD.MM.YYYY um
|
// formatWeekDate: wandelt ein ISO-Datum (YYYY-MM-DD) in deutsches Format DD.MM.YYYY um
|
||||||
// Parameter: dateString (String). Rückgabe: formatierter String oder leer.
|
// Parameter: dateString (String). Rückgabe: formatierter String oder leer.
|
||||||
function formatWeekDate(dateString){
|
function formatWeekDate(dateString){
|
||||||
|
|||||||
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
|
Seiten-Huelle (.admin-page/.admin-header/.admin-body) sind mit ihr
|
||||||
entfallen, weil der Abschnitt jetzt in der normalen App-Huelle liegt. */
|
entfallen, weil der Abschnitt jetzt in der normalen App-Huelle liegt. */
|
||||||
.admin-toolbar { margin-bottom: 14px; }
|
.admin-toolbar { margin-bottom: 14px; }
|
||||||
td.actions-cell { white-space: nowrap; }
|
td.actions-cell { white-space: nowrap; width: 1%; text-align: right; }
|
||||||
td.actions-cell button { margin: 2px; }
|
|
||||||
|
/* Vor- und Nachname nebeneinander (Benutzer anlegen/bearbeiten), auf
|
||||||
|
schmalen Bildschirmen untereinander. */
|
||||||
|
.form-row-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
|
||||||
|
.form-row-pair > .form-row { min-width: 0; }
|
||||||
|
@media (max-width: 480px) { .form-row-pair { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* Zeilenaktionen der Benutzerverwaltung: „Bearbeiten" + Drei-Punkte-Menü
|
||||||
|
(js/admin-users.js, openUserActionMenu()). Das Menü hängt an <body> und
|
||||||
|
steht fixed, damit der quer scrollbare Tabellenrahmen es nicht abschneidet. */
|
||||||
|
.user-aktionen { display: inline-flex; gap: 6px; align-items: center; }
|
||||||
|
.user-kebab {
|
||||||
|
width: 30px; height: 30px; padding: 0;
|
||||||
|
display: inline-grid; place-items: center;
|
||||||
|
border: 1px solid transparent; border-radius: var(--r-sm);
|
||||||
|
background: transparent; color: var(--text-muted); cursor: pointer;
|
||||||
|
transition: background var(--dur) var(--ease-out), color var(--dur) var(--ease-out), border-color var(--dur) var(--ease-out);
|
||||||
|
}
|
||||||
|
.user-kebab:hover { background: var(--surface-sunk); color: var(--text); }
|
||||||
|
.user-kebab[aria-expanded="true"] {
|
||||||
|
background: var(--accent-soft); color: var(--accent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
}
|
||||||
|
.user-kebab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.user-kebab svg { display: block; }
|
||||||
|
tr.is-menu-open { background: var(--surface-alt); }
|
||||||
|
|
||||||
|
.user-menu {
|
||||||
|
position: fixed; z-index: 250; min-width: 220px; max-width: 280px;
|
||||||
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-md); box-shadow: var(--sh-3); padding: 5px;
|
||||||
|
transform-origin: top right;
|
||||||
|
animation: user-menu-auf var(--dur) var(--ease-out);
|
||||||
|
}
|
||||||
|
.user-menu.nach-oben { transform-origin: bottom right; animation-name: user-menu-auf-oben; }
|
||||||
|
@keyframes user-menu-auf { from { opacity: 0; transform: scale(.96) translateY(-4px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes user-menu-auf-oben { from { opacity: 0; transform: scale(.96) translateY(4px); } to { opacity: 1; transform: none; } }
|
||||||
|
.user-menu-item {
|
||||||
|
all: unset; box-sizing: border-box;
|
||||||
|
display: flex; gap: 10px; align-items: flex-start; width: 100%;
|
||||||
|
padding: 7px 10px; border-radius: var(--r-sm);
|
||||||
|
color: var(--text); font-size: 13.5px; line-height: 1.35; cursor: pointer;
|
||||||
|
}
|
||||||
|
.user-menu-item:hover:not([aria-disabled="true"]), .user-menu-item:focus-visible { background: var(--surface-sunk); }
|
||||||
|
.user-menu-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
|
.user-menu-ic { flex: none; width: 16px; height: 16px; margin-top: 1px; color: var(--text-muted); }
|
||||||
|
.user-menu-text { display: grid; gap: 1px; }
|
||||||
|
.user-menu-sub { font-size: 12px; color: var(--text-subtle); }
|
||||||
|
.user-menu-item.is-danger, .user-menu-item.is-danger .user-menu-ic { color: var(--red); }
|
||||||
|
.user-menu-item.is-danger:hover:not([aria-disabled="true"]),
|
||||||
|
.user-menu-item.is-danger:focus-visible { background: color-mix(in srgb, var(--red) 10%, transparent); }
|
||||||
|
.user-menu-item[aria-disabled="true"], .user-menu-item[aria-disabled="true"] .user-menu-ic { color: var(--text-subtle); cursor: not-allowed; }
|
||||||
|
.user-menu-trenner { border: 0; border-top: 1px solid var(--line-soft); margin: 5px 4px; }
|
||||||
|
.user-menu-confirm { padding: 10px 10px 8px; display: grid; gap: 10px; }
|
||||||
|
.user-menu-confirm p { margin: 0; font-size: 13px; line-height: 1.4; }
|
||||||
|
.user-menu-confirm-row { display: flex; gap: 6px; justify-content: flex-end; }
|
||||||
.badge-active { color: var(--green); font-weight: 600; }
|
.badge-active { color: var(--green); font-weight: 600; }
|
||||||
.badge-inactive { color: var(--text-muted); }
|
.badge-inactive { color: var(--text-muted); }
|
||||||
.badge-warn { color: var(--amber); font-weight: 600; }
|
.badge-warn { color: var(--amber); font-weight: 600; }
|
||||||
@@ -5252,3 +5307,129 @@ td.actions-cell button { margin: 2px; }
|
|||||||
scroll-behavior: auto !important;
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
LOGIN-SEITE (#loginOverlay, Design "Kalenderwoche" — Eriks Wahl B)
|
||||||
|
Links Logo und Formular, rechts die laufende Kalenderwoche als große Zahl
|
||||||
|
mit ihren Arbeitstagen (renderLoginWoche(), js/auth.js). Farben über die
|
||||||
|
App-Tokens; nur die Zahl hat eigene Tokens (Logo-Hellblau im hellen,
|
||||||
|
gedämpft im dunklen Thema). Liegt über allem (z-index 400 > .modal-bg 300).
|
||||||
|
Das Logo ist ein blasses Wasserzeichen über die ganze Seite
|
||||||
|
(.login-wasserzeichen, Variante E), kein eigenes kleines Logo.
|
||||||
|
========================================================================= */
|
||||||
|
.login-seite {
|
||||||
|
--login-zahl: #94b0cc;
|
||||||
|
--login-jahr: #116896;
|
||||||
|
--login-wz: .11;
|
||||||
|
position: fixed; inset: 0; z-index: 400;
|
||||||
|
display: none; overflow-y: auto; overflow-x: hidden;
|
||||||
|
background: var(--bg); color: var(--text);
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html:not([data-theme="light"]) .login-seite { --login-zahl: #4f7695; --login-jahr: var(--accent); --login-wz: .07; }
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .login-seite { --login-zahl: #4f7695; --login-jahr: var(--accent); --login-wz: .07; }
|
||||||
|
|
||||||
|
/* Wasserzeichen: ganzes Zeichen sichtbar, an den Rändern angeschnitten. Die
|
||||||
|
Felder und Tageskacheln haben eigene Flächen und bleiben davor lesbar.
|
||||||
|
Der Rahmen ist genau so groß wie das Fenster und schneidet alles
|
||||||
|
Überstehende ab — sonst ragte das Logo über den Rand und machte die
|
||||||
|
Seite scrollbar. */
|
||||||
|
.login-wz-rahmen { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 0; }
|
||||||
|
.login-wasserzeichen {
|
||||||
|
position: absolute; z-index: 0; left: 50%; top: 50%;
|
||||||
|
width: min(1500px, 98vw); height: auto; transform: translate(-44%, -47%);
|
||||||
|
opacity: var(--login-wz); filter: blur(1px);
|
||||||
|
pointer-events: none; user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-raster {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
width: 100%; max-width: 1180px; min-height: 100%; margin: 0 auto;
|
||||||
|
display: grid; grid-template-columns: minmax(0, 400px) minmax(0, 1fr);
|
||||||
|
gap: clamp(32px, 7vw, 110px); align-items: center;
|
||||||
|
padding-block: calc(48px + env(safe-area-inset-top, 0px)) calc(48px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
.login-titel { margin: 0 0 6px; font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-weight: 500; font-size: 30px; letter-spacing: -.01em; }
|
||||||
|
.login-unter { margin: 0 0 30px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.login-form { display: grid; gap: 18px; }
|
||||||
|
.login-feld { display: grid; gap: 7px; }
|
||||||
|
.login-feld label { font-size: 13.5px; font-weight: 600; color: var(--text); }
|
||||||
|
.login-feld input {
|
||||||
|
width: 100%; box-sizing: border-box; min-height: 46px; padding: 11px 13px;
|
||||||
|
font: inherit; font-size: 15px; color: var(--text);
|
||||||
|
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg);
|
||||||
|
transition: border-color var(--dur) var(--ease-out), box-shadow var(--dur) var(--ease-out);
|
||||||
|
}
|
||||||
|
.login-feld input::placeholder { color: var(--text-subtle); opacity: 1; }
|
||||||
|
.login-feld input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
|
||||||
|
.login-feld.hat-fehler input { border-color: var(--red); }
|
||||||
|
.login-pw { position: relative; }
|
||||||
|
.login-pw input { padding-right: 96px; }
|
||||||
|
.login-pw-toggle {
|
||||||
|
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
|
||||||
|
font: inherit; font-size: 12.5px; font-weight: 600; color: var(--accent);
|
||||||
|
background: transparent; border: 0; border-radius: var(--r-md); padding: 7px 10px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.login-pw-toggle:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||||
|
.login-pw-toggle:focus-visible, .login-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.login-caps { margin: 0; font-size: 12.5px; font-weight: 600; color: var(--amber); }
|
||||||
|
.login-fehler {
|
||||||
|
margin: 0; padding: 10px 12px; border-radius: var(--r-lg); font-size: 13.5px; line-height: 1.45;
|
||||||
|
color: var(--red); background: color-mix(in srgb, var(--red) 10%, transparent);
|
||||||
|
}
|
||||||
|
.login-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
|
||||||
|
width: 100%; min-height: 48px; margin-top: 4px;
|
||||||
|
font: inherit; font-size: 15px; font-weight: 650; color: #fff;
|
||||||
|
background: var(--accent); border: 0; border-radius: var(--r-lg); cursor: pointer;
|
||||||
|
transition: filter var(--dur) var(--ease-out), transform var(--dur) var(--ease-out);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .login-btn { color: #0b1c26; } }
|
||||||
|
html[data-theme="dark"] .login-btn { color: #0b1c26; }
|
||||||
|
.login-btn:hover { filter: brightness(1.07); }
|
||||||
|
.login-btn:active { transform: scale(var(--press)); }
|
||||||
|
.login-btn:disabled { cursor: progress; filter: saturate(.7); }
|
||||||
|
.login-spinner {
|
||||||
|
display: none; width: 15px; height: 15px; border-radius: 50%;
|
||||||
|
border: 2px solid color-mix(in srgb, currentColor 35%, transparent); border-top-color: currentColor;
|
||||||
|
animation: login-dreh .7s linear infinite;
|
||||||
|
}
|
||||||
|
.login-btn.laedt .login-spinner { display: block; }
|
||||||
|
@keyframes login-dreh { to { transform: rotate(360deg); } }
|
||||||
|
.login-hilfe { margin: 18px 0 0; font-size: 13px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.login-woche { justify-self: end; text-align: right; animation: login-auf .7s var(--ease-out) both; }
|
||||||
|
@keyframes login-auf { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||||
|
.login-kw-label, .login-kw-jahr, .login-kw-zahl, .login-tage b { font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||||
|
.login-kw-label { display: block; font-weight: 500; font-size: 22px; color: var(--text-muted); letter-spacing: .02em; }
|
||||||
|
.login-kw-zahl {
|
||||||
|
display: block; font-weight: 300; font-size: clamp(150px, 21vw, 300px); line-height: .86;
|
||||||
|
letter-spacing: -.05em; color: var(--login-zahl); font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.login-kw-jahr { display: block; font-weight: 500; font-size: 20px; color: var(--login-jahr); }
|
||||||
|
.login-tage { list-style: none; margin: 30px 0 0; padding: 0; display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.login-tage li {
|
||||||
|
display: grid; justify-items: center; gap: 2px; width: 58px; padding: 10px 0 9px;
|
||||||
|
border-radius: var(--r-lg); background: var(--surface); border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.login-tage li span { font-size: 12px; font-weight: 600; color: var(--text-muted); }
|
||||||
|
.login-tage li b { font-weight: 500; font-size: 20px; font-variant-numeric: tabular-nums; }
|
||||||
|
.login-tage li.ist-heute { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.login-tage li.ist-heute span, .login-tage li.ist-heute b { color: #fff; }
|
||||||
|
@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .login-tage li.ist-heute span, html:not([data-theme="light"]) .login-tage li.ist-heute b { color: #0b1c26; } }
|
||||||
|
html[data-theme="dark"] .login-tage li.ist-heute span, html[data-theme="dark"] .login-tage li.ist-heute b { color: #0b1c26; }
|
||||||
|
.login-datum { margin: 18px 0 0; color: var(--text-muted); font-size: 15px; }
|
||||||
|
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.login-raster { grid-template-columns: 1fr; gap: 28px; padding-block: calc(28px + env(safe-area-inset-top, 0px)) 48px; align-content: start; }
|
||||||
|
.login-woche { order: -1; justify-self: start; text-align: left; }
|
||||||
|
.login-kw-zahl { font-size: 110px; }
|
||||||
|
.login-tage { justify-content: flex-start; }
|
||||||
|
.login-tage li { width: auto; flex: 1; max-width: 58px; }
|
||||||
|
.login-wasserzeichen { width: 260vw; top: 0; transform: translate(-30%, -10%); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .login-woche { animation: none; } .login-spinner { animation: none; } }
|
||||||
@@ -283,8 +283,18 @@ const VALID_DISCIPLINE_ROLES = new Set(['Planung','Projektleitung','Bauüberwach
|
|||||||
// "Wandtafel"): darf nur die lesenden Routen erreichen, die die Wandtafel
|
// "Wandtafel"): darf nur die lesenden Routen erreichen, die die Wandtafel
|
||||||
// braucht (requireAuthOderKiosk weiter unten), überall sonst wie requireAuth
|
// braucht (requireAuthOderKiosk weiter unten), überall sonst wie requireAuth
|
||||||
// abgewiesen — und darf keine Fachrolle tragen (dort geprüft, wo roles
|
// abgewiesen — und darf keine Fachrolle tragen (dort geprüft, wo roles
|
||||||
// validiert wird).
|
// validiert wird). 'projektleiter' ist ein Mitglied, das zusätzlich Projekte
|
||||||
const VALID_ROLES = new Set(['admin', 'member', 'kiosk']);
|
// anlegen und aus der Projektliste löschen darf (siehe
|
||||||
|
// darfProjektlisteAendern()); sonst gilt
|
||||||
|
// für das Konto alles wie für 'member'. Nicht zu verwechseln mit der
|
||||||
|
// Fachrolle 'Projektleitung' (user_roles), die nur die Projektzuordnung
|
||||||
|
// betrifft und keine Berechtigung verleiht.
|
||||||
|
const VALID_ROLES = new Set(['admin', 'member', 'projektleiter', 'kiosk']);
|
||||||
|
// GELOESCHTE_PROJEKTE_TAGE: so lange bleibt ein aus der Projektliste
|
||||||
|
// gelöschtes Projekt im Papierkorb (deleted_projects) und ist für
|
||||||
|
// Administratoren wiederherstellbar; danach entfernt es
|
||||||
|
// purgeGeloeschteProjekte() endgültig.
|
||||||
|
const GELOESCHTE_PROJEKTE_TAGE = 30;
|
||||||
// VALID_AUFTRAGSSTATUS: der Weg von der Anfrage zum abgeschlossenen Auftrag
|
// VALID_AUFTRAGSSTATUS: der Weg von der Anfrage zum abgeschlossenen Auftrag
|
||||||
// (projects.auftragsstatus). Die Reihenfolge ist die fachliche Abfolge und
|
// (projects.auftragsstatus). Die Reihenfolge ist die fachliche Abfolge und
|
||||||
// steht genauso in AUFTRAGSSTATUS in public/js/auftrag.js — bewusst
|
// steht genauso in AUFTRAGSSTATUS in public/js/auftrag.js — bewusst
|
||||||
@@ -2577,6 +2587,29 @@ function migrateUsersDepartment(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateUsersVorNachname: Vor- und Nachname als eigene Spalten
|
||||||
|
// (first_name/last_name). Bis dahin gab es nur den Anzeigenamen; daraus
|
||||||
|
// ließen sich Benutzername und E-Mail-Adresse nicht verlässlich ableiten (ein
|
||||||
|
// einzelnes Wort im Feld ergab z. B. nur den Nachnamen). display_name bleibt
|
||||||
|
// bestehen und wird beim Anlegen/Bearbeiten als "Vorname Nachname" gesetzt —
|
||||||
|
// alle übrigen Stellen lesen weiter display_name. Bestandskonten bekommen
|
||||||
|
// Vor-/Nachname einmalig aus display_name (erstes Wort / Rest). Benutzernamen
|
||||||
|
// bleiben unverändert: Angemeldet wird mit dem Anfang der E-Mail-Adresse
|
||||||
|
// (z. B. e.dunin), und genau so heißen die Bestandskonten bereits.
|
||||||
|
function migrateUsersVorNachname(){
|
||||||
|
const cols = db.prepare("SELECT name FROM pragma_table_info('users')").all().map(r => r.name);
|
||||||
|
if(cols.includes('first_name') && cols.includes('last_name')) return;
|
||||||
|
if(!cols.includes('first_name')) db.exec("ALTER TABLE users ADD COLUMN first_name TEXT NOT NULL DEFAULT ''");
|
||||||
|
if(!cols.includes('last_name')) db.exec("ALTER TABLE users ADD COLUMN last_name TEXT NOT NULL DEFAULT ''");
|
||||||
|
const rows = db.prepare('SELECT id, display_name FROM users').all();
|
||||||
|
const setNamen = db.prepare('UPDATE users SET first_name = ?, last_name = ? WHERE id = ?');
|
||||||
|
rows.forEach(r => {
|
||||||
|
const woerter = String(r.display_name || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
setNamen.run(woerter[0] || '', woerter.slice(1).join(' '), r.id);
|
||||||
|
});
|
||||||
|
console.log('Benutzer-Tabelle um Vor-/Nachname ergänzt (' + rows.length + ' Konten aus dem Anzeigenamen befüllt).');
|
||||||
|
}
|
||||||
|
|
||||||
// dedupeUserPersonIds: person_id musste bisher nur je Abteilung eindeutig
|
// dedupeUserPersonIds: person_id musste bisher nur je Abteilung eindeutig
|
||||||
// sein (alte people-Tabelle); jetzt identifiziert person_id global einen
|
// sein (alte people-Tabelle); jetzt identifiziert person_id global einen
|
||||||
// Benutzer, daher müssen Bestandskollisionen vor dem UNIQUE INDEX aufgelöst
|
// Benutzer, daher müssen Bestandskollisionen vor dem UNIQUE INDEX aufgelöst
|
||||||
@@ -2692,6 +2725,7 @@ migrateAddProtokollPunktErstelltAm();
|
|||||||
migrateAddProtokollPunktStatus();
|
migrateAddProtokollPunktStatus();
|
||||||
migrateAddProtokollPunktEntfaelltNaechstes();
|
migrateAddProtokollPunktEntfaelltNaechstes();
|
||||||
migrateUsersDepartment();
|
migrateUsersDepartment();
|
||||||
|
migrateUsersVorNachname();
|
||||||
ensureSandboxWeek();
|
ensureSandboxWeek();
|
||||||
ensureUserPersonIdUniqueIndex();
|
ensureUserPersonIdUniqueIndex();
|
||||||
migrateRetirePeopleTablesIntoUsers();
|
migrateRetirePeopleTablesIntoUsers();
|
||||||
@@ -2722,6 +2756,41 @@ try {
|
|||||||
console.error('Aufräumen der Sitzungen fehlgeschlagen:', err.message);
|
console.error('Aufräumen der Sitzungen fehlgeschlagen:', err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Papierkorb gelöschter Projekte ----------
|
||||||
|
// deleted_projects: ein Schnappschuss (das vollständige Projekt-Objekt als
|
||||||
|
// JSON, wie es /load-all liefert — inkl. Terminplan, Beteiligten, LPH) je
|
||||||
|
// gelöschtem Projekt. Bewusst JSON statt relational: Der Eintrag wird nie
|
||||||
|
// abgefragt oder ausgewertet, nur 1:1 wieder in eine Sitzung eingesetzt.
|
||||||
|
// Dateien und Protokolle hängen an project_nr (nicht an der Projekt-ID) und
|
||||||
|
// werden beim Löschen gar nicht angefasst — nach dem Wiederherstellen sind
|
||||||
|
// sie deshalb ohne weiteres Zutun wieder da.
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS deleted_projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
week_label TEXT NOT NULL DEFAULT '',
|
||||||
|
project_id TEXT NOT NULL DEFAULT '',
|
||||||
|
project_uid TEXT NOT NULL DEFAULT '',
|
||||||
|
nr TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
deleted_by_user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_by_name TEXT NOT NULL DEFAULT '',
|
||||||
|
deleted_at TEXT NOT NULL
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// purgeGeloeschteProjekte: entfernt Papierkorb-Einträge, die älter als
|
||||||
|
// GELOESCHTE_PROJEKTE_TAGE sind. Läuft beim Start und stündlich mit dem
|
||||||
|
// Sitzungswechsel (siehe app.listen()).
|
||||||
|
function purgeGeloeschteProjekte(){
|
||||||
|
try {
|
||||||
|
const grenze = new Date(Date.now() - GELOESCHTE_PROJEKTE_TAGE * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const r = db.prepare('DELETE FROM deleted_projects WHERE deleted_at < ?').run(grenze);
|
||||||
|
if(r && r.changes) console.log('Papierkorb: ' + r.changes + ' Projekt(e) nach ' + GELOESCHTE_PROJEKTE_TAGE + ' Tagen endgültig entfernt.');
|
||||||
|
} catch(err){
|
||||||
|
console.error('Aufräumen des Projekt-Papierkorbs fehlgeschlagen:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
purgeGeloeschteProjekte();
|
||||||
|
|
||||||
// ---------- Auth: Sessions, Middleware, Endpunkte ----------
|
// ---------- Auth: Sessions, Middleware, Endpunkte ----------
|
||||||
|
|
||||||
const SESSION_COOKIE = 'puebersicht_session';
|
const SESSION_COOKIE = 'puebersicht_session';
|
||||||
@@ -2880,7 +2949,12 @@ app.post('/login', (req, res) => {
|
|||||||
if(loginIsBlocked(clientKey)){
|
if(loginIsBlocked(clientKey)){
|
||||||
return res.status(429).json({ ok:false, error:'Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.' });
|
return res.status(429).json({ ok:false, error:'Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.' });
|
||||||
}
|
}
|
||||||
const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
// Benutzername = Anfang der E-Mail-Adresse (z. B. e.dunin). Wer die
|
||||||
|
// ganze Adresse eintippt, wird ebenso erkannt (alles ab "@" fällt weg),
|
||||||
|
// und Groß-/Kleinschreibung spielt keine Rolle ("E.Dunin").
|
||||||
|
const eingabe = String(username).trim();
|
||||||
|
const kennung = eingabe.includes('@') ? eingabe.slice(0, eingabe.indexOf('@')) : eingabe;
|
||||||
|
const row = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(kennung);
|
||||||
// Auch bei unbekanntem Benutzernamen einmal hashen: Sonst verrät die
|
// Auch bei unbekanntem Benutzernamen einmal hashen: Sonst verrät die
|
||||||
// Antwortzeit (scrypt läuft bewusst langsam), welche Konten es gibt.
|
// Antwortzeit (scrypt läuft bewusst langsam), welche Konten es gibt.
|
||||||
const salt = row ? row.password_salt : 'unbekannt';
|
const salt = row ? row.password_salt : 'unbekannt';
|
||||||
@@ -2989,6 +3063,8 @@ function serializeUserRow(row){
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
displayName: row.display_name,
|
displayName: row.display_name,
|
||||||
|
firstName: row.first_name || '',
|
||||||
|
lastName: row.last_name || '',
|
||||||
email: row.email,
|
email: row.email,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
personId: row.person_id,
|
personId: row.person_id,
|
||||||
@@ -3045,9 +3121,16 @@ app.get('/admin/users/:id/absences', requireRole('admin'), (req, res) => {
|
|||||||
// wird 1:1 vom Client übernommen (Berechnung/Vorschau via personIdForName()
|
// wird 1:1 vom Client übernommen (Berechnung/Vorschau via personIdForName()
|
||||||
// passiert clientseitig).
|
// passiert clientseitig).
|
||||||
app.post('/admin/users', requireRole('admin'), (req, res) => {
|
app.post('/admin/users', requireRole('admin'), (req, res) => {
|
||||||
const { username, displayName, email, role, personId, department, initials, capacityHours, roles, password, generatePassword } = req.body || {};
|
const { username, email, role, personId, department, initials, capacityHours, roles, password, generatePassword } = req.body || {};
|
||||||
|
const firstName = String((req.body || {}).firstName || '').trim();
|
||||||
|
const lastName = String((req.body || {}).lastName || '').trim();
|
||||||
|
// Anzeigename = "Vorname Nachname"; ältere Clients schicken nur displayName.
|
||||||
|
const displayName = (firstName || lastName) ? [firstName, lastName].filter(Boolean).join(' ') : String((req.body || {}).displayName || '').trim();
|
||||||
if(!username || !String(username).trim()) return res.status(400).json({ ok: false, error: 'Benutzername erforderlich.' });
|
if(!username || !String(username).trim()) return res.status(400).json({ ok: false, error: 'Benutzername erforderlich.' });
|
||||||
if(!displayName || !String(displayName).trim()) return res.status(400).json({ ok: false, error: 'Anzeigename erforderlich.' });
|
if(!displayName) return res.status(400).json({ ok: false, error: 'Vor- und Nachname erforderlich.' });
|
||||||
|
if(db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE').get(String(username).trim())){
|
||||||
|
return res.status(409).json({ ok: false, error: 'Benutzername bereits vergeben.' });
|
||||||
|
}
|
||||||
const finalRole = role ? String(role) : 'member';
|
const finalRole = role ? String(role) : 'member';
|
||||||
if(!VALID_ROLES.has(finalRole)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' });
|
if(!VALID_ROLES.has(finalRole)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' });
|
||||||
if(typeof department !== 'string' || !VALID_DEPARTMENTS.has(department)){
|
if(typeof department !== 'string' || !VALID_DEPARTMENTS.has(department)){
|
||||||
@@ -3084,9 +3167,9 @@ app.post('/admin/users', requireRole('admin'), (req, res) => {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
try {
|
try {
|
||||||
const info = db.prepare(`INSERT INTO users
|
const info = db.prepare(`INSERT INTO users
|
||||||
(username, display_name, email, role, person_id, department, initials, capacity_hours, password_hash, password_salt, active, must_change_password, created_at, updated_at)
|
(username, display_name, first_name, last_name, email, role, person_id, department, initials, capacity_hours, password_hash, password_salt, active, must_change_password, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`)
|
||||||
.run(String(username).trim(), String(displayName).trim(), String(email || ''), finalRole, String(personId || ''), department, String(initials || ''), capacity, hash, salt, now, now);
|
.run(String(username).trim(), displayName, firstName, lastName, String(email || ''), finalRole, String(personId || ''), department, String(initials || ''), capacity, hash, salt, now, now);
|
||||||
const insRole = db.prepare('INSERT INTO user_roles (user_id, role, role_order) VALUES (?, ?, ?)');
|
const insRole = db.prepare('INSERT INTO user_roles (user_id, role, role_order) VALUES (?, ?, ?)');
|
||||||
roleList.forEach((r, idx) => insRole.run(info.lastInsertRowid, r, idx));
|
roleList.forEach((r, idx) => insRole.run(info.lastInsertRowid, r, idx));
|
||||||
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid);
|
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid);
|
||||||
@@ -3154,7 +3237,16 @@ app.patch('/admin/users/:id', requireRole('admin'), (req, res) => {
|
|||||||
|
|
||||||
const fields = [];
|
const fields = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
if(displayName !== undefined){ fields.push('display_name = ?'); values.push(String(displayName)); }
|
const { firstName, lastName } = req.body || {};
|
||||||
|
if(firstName !== undefined || lastName !== undefined){
|
||||||
|
// Vor-/Nachname setzen auch den Anzeigenamen — er ist ihre Verbindung.
|
||||||
|
const alt = db.prepare('SELECT first_name, last_name FROM users WHERE id = ?').get(id) || {};
|
||||||
|
const vn = firstName !== undefined ? String(firstName).trim() : (alt.first_name || '');
|
||||||
|
const nn = lastName !== undefined ? String(lastName).trim() : (alt.last_name || '');
|
||||||
|
if(!vn && !nn) return res.status(400).json({ ok: false, error: 'Vor- und Nachname erforderlich.' });
|
||||||
|
fields.push('first_name = ?', 'last_name = ?', 'display_name = ?');
|
||||||
|
values.push(vn, nn, [vn, nn].filter(Boolean).join(' '));
|
||||||
|
} else if(displayName !== undefined){ fields.push('display_name = ?'); values.push(String(displayName)); }
|
||||||
if(email !== undefined){ fields.push('email = ?'); values.push(String(email)); }
|
if(email !== undefined){ fields.push('email = ?'); values.push(String(email)); }
|
||||||
if(role !== undefined){ fields.push('role = ?'); values.push(role); }
|
if(role !== undefined){ fields.push('role = ?'); values.push(role); }
|
||||||
if(active !== undefined){ fields.push('active = ?'); values.push(active ? 1 : 0); }
|
if(active !== undefined){ fields.push('active = ?'); values.push(active ? 1 : 0); }
|
||||||
@@ -3215,6 +3307,84 @@ app.post('/admin/users/:id/reset-password', requireRole('admin'), (req, res) =>
|
|||||||
// project_file_reviewers.user_id, project_file_comments.user_id — diese Tabellen
|
// project_file_reviewers.user_id, project_file_comments.user_id — diese Tabellen
|
||||||
// sind schon angelegt, aber aktuell noch leer). Ein Admin kann sich selbst
|
// sind schon angelegt, aber aktuell noch leer). Ein Admin kann sich selbst
|
||||||
// weder soft- noch hard-löschen.
|
// weder soft- noch hard-löschen.
|
||||||
|
// loescheBenutzerEndgueltig: entfernt ein Konto samt seiner Verweise, in
|
||||||
|
// einer Transaktion. Auf Eriks Wunsch verschwindet die Person dabei aus den
|
||||||
|
// Projekten und damit aus der Auslastung — aber nur in der AKTUELLEN Sitzung
|
||||||
|
// (jüngste echte Woche) und in den Planung-Terminplänen. Ältere Sitzungen
|
||||||
|
// sind Historie (Verlauf im Wochenbericht) und bleiben unverändert.
|
||||||
|
// Welche Kennungen gemeint sind, bestimmt je Woche personenKennungenInWoche():
|
||||||
|
// users.person_id UND jede Kennung im Teilnehmerkreis dieser Woche mit
|
||||||
|
// demselben vollen Namen — Altbestand trägt Kurzkennungen (p_ap statt
|
||||||
|
// p_alexander_plum), an denen die Zuordnungen hängen; dieselbe Brücke
|
||||||
|
// über den Namen nutzt kapazitaetsFaktor() in public/js/load/load.js.
|
||||||
|
// Entfernt werden dort: Teilnehmerkreis
|
||||||
|
// (week_people/_roles), Anwesenheit, Projektzuordnungen (project_assign —
|
||||||
|
// daraus rechnet die Auslastung), Beteiligte des Terminplans (plan_people)
|
||||||
|
// und Aufgaben-Zuständigkeiten (plan_todo_assignees). Die betroffenen
|
||||||
|
// Wochen bekommen eine neue Revision, damit ein Browser mit altem Stand
|
||||||
|
// beim nächsten Speichern einen Konflikt bekommt und neu lädt, statt die
|
||||||
|
// Person wieder hineinzuschreiben.
|
||||||
|
// Dateien: Hochgeladene Dateien und Kommentare bleiben und erscheinen als
|
||||||
|
// „Gelöschter Benutzer" (LEFT JOIN + COALESCE in den Datei-Abfragen).
|
||||||
|
// Prüfaufträge an die Person werden entfernt und der Gesamtstatus der
|
||||||
|
// betroffenen, noch offenen Dateien neu berechnet (dieselbe Regel wie in
|
||||||
|
// POST /project-files/:id/review). Sitzungen, Rollen, Abwesenheiten und
|
||||||
|
// KI-Schlüssel fallen per ON DELETE CASCADE mit weg.
|
||||||
|
// Rückgabe: Zähler dessen, was entfernt wurde.
|
||||||
|
function personenKennungenInWoche(row, woche){
|
||||||
|
const ids = new Set();
|
||||||
|
if(row.person_id) ids.add(String(row.person_id));
|
||||||
|
const name = String(row.display_name || '').trim();
|
||||||
|
if(name){
|
||||||
|
db.prepare('SELECT person_id FROM week_people WHERE week_label = ? AND full_name = ?').all(woche, name)
|
||||||
|
.forEach(r => ids.add(r.person_id));
|
||||||
|
}
|
||||||
|
return [...ids].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loescheBenutzerEndgueltig(row){
|
||||||
|
const echte = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL).map(r => r.label)
|
||||||
|
.sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b)));
|
||||||
|
const wochen = [echte[echte.length - 1], SANDBOX_WEEK_LABEL].filter(Boolean);
|
||||||
|
const zaehler = { zuordnungen: 0, aufgaben: 0, pruefauftraege: 0 };
|
||||||
|
const jetzt = new Date().toISOString();
|
||||||
|
|
||||||
|
db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
wochen.forEach(w => {
|
||||||
|
const ids = personenKennungenInWoche(row, w);
|
||||||
|
if(!ids.length) return;
|
||||||
|
ids.forEach(pid => {
|
||||||
|
zaehler.zuordnungen += db.prepare('DELETE FROM project_assign WHERE week_label = ? AND person_id = ?').run(w, pid).changes;
|
||||||
|
zaehler.aufgaben += db.prepare('DELETE FROM plan_todo_assignees WHERE week_label = ? AND person_id = ?').run(w, pid).changes;
|
||||||
|
['plan_people', 'week_people_roles', 'week_people', 'attendance'].forEach(t => {
|
||||||
|
db.prepare(`DELETE FROM ${t} WHERE week_label = ? AND person_id = ?`).run(w, pid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, w);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateien = db.prepare('SELECT file_id FROM project_file_reviewers WHERE user_id = ?').all(row.id).map(r => r.file_id);
|
||||||
|
zaehler.pruefauftraege = db.prepare('DELETE FROM project_file_reviewers WHERE user_id = ?').run(row.id).changes;
|
||||||
|
dateien.forEach(fileId => {
|
||||||
|
const datei = db.prepare('SELECT status FROM project_files WHERE id = ?').get(fileId);
|
||||||
|
if(!datei || datei.status !== 'pending') return;
|
||||||
|
const entscheidungen = db.prepare('SELECT decision FROM project_file_reviewers WHERE file_id = ?').all(fileId).map(r => r.decision);
|
||||||
|
let status = 'pending';
|
||||||
|
if(entscheidungen.some(d => d === 'rejected')) status = 'rejected';
|
||||||
|
else if(entscheidungen.length > 0 && entscheidungen.every(d => d === 'approved')) status = 'approved';
|
||||||
|
if(status !== 'pending') db.prepare('UPDATE project_files SET status = ?, updated_at = ? WHERE id = ?').run(status, jetzt, fileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM users WHERE id = ?').run(row.id);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch(err){
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return zaehler;
|
||||||
|
}
|
||||||
|
|
||||||
app.delete('/admin/users/:id', requireRole('admin'), (req, res) => {
|
app.delete('/admin/users/:id', requireRole('admin'), (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||||
@@ -3224,17 +3394,9 @@ app.delete('/admin/users/:id', requireRole('admin'), (req, res) => {
|
|||||||
const hard = req.query.hard === '1';
|
const hard = req.query.hard === '1';
|
||||||
try {
|
try {
|
||||||
if(hard){
|
if(hard){
|
||||||
const referenced = db.prepare(`SELECT
|
const ergebnis = loescheBenutzerEndgueltig(row);
|
||||||
(SELECT COUNT(*) FROM project_files WHERE uploaded_by_user_id = ?) +
|
console.log('Benutzer endgültig gelöscht:', row.username, ergebnis);
|
||||||
(SELECT COUNT(*) FROM project_file_reviewers WHERE user_id = ?) +
|
return res.json(Object.assign({ ok: true, hard: true }, ergebnis));
|
||||||
(SELECT COUNT(*) FROM project_file_comments WHERE user_id = ?) AS c`)
|
|
||||||
.get(id, id, id);
|
|
||||||
if(referenced.c > 0){
|
|
||||||
return res.status(409).json({ ok: false, error: 'Benutzer wird noch referenziert und kann nicht endgültig gelöscht werden.' });
|
|
||||||
}
|
|
||||||
// user_sessions dieses Users werden durch ON DELETE CASCADE automatisch mitgelöscht.
|
|
||||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
|
||||||
return res.json({ ok: true, hard: true });
|
|
||||||
}
|
}
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
db.prepare('UPDATE users SET active = 0, updated_at = ? WHERE id = ?').run(now, id);
|
db.prepare('UPDATE users SET active = 0, updated_at = ? WHERE id = ?').run(now, id);
|
||||||
@@ -3311,9 +3473,30 @@ app.post('/save-week', requireAuth, (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Angelegte und gelöschte Projekte erkennen: /save-week schreibt immer
|
||||||
|
// die ganze Woche, beides ist also nur am Vergleich mit dem gespeicherten
|
||||||
|
// Stand zu sehen (projektlisteAenderungen()). Nur Projektleiter und
|
||||||
|
// Administratoren dürfen das (403 sonst, bevor irgendetwas geschrieben
|
||||||
|
// wird); gelöschte Projekte wandern in den Papierkorb
|
||||||
|
// (deleted_projects). Die Planung-Sandbox ist ausgenommen — ihre
|
||||||
|
// Terminpläne sind keine Projekte der Projektliste und werden dort von
|
||||||
|
// jedem angelegt und gelöscht.
|
||||||
|
const aenderung = week.label === SANDBOX_WEEK_LABEL
|
||||||
|
? { neu: [], entfernt: [] } : projektlisteAenderungen(week);
|
||||||
|
const entfernt = aenderung.entfernt;
|
||||||
|
if((aenderung.neu.length || entfernt.length) && !darfProjektlisteAendern(req.user)){
|
||||||
|
return res.status(403).json({
|
||||||
|
ok: false,
|
||||||
|
error: aenderung.neu.length
|
||||||
|
? 'Projekte anlegen dürfen nur Projektleiter und Administratoren.'
|
||||||
|
: 'Projekte löschen dürfen nur Projektleiter und Administratoren.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// `updatedAt` zurückgeben, damit der Client seine lokale Revisions-Basis
|
// `updatedAt` zurückgeben, damit der Client seine lokale Revisions-Basis
|
||||||
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
|
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
|
||||||
const updatedAt = saveWeekRelational(week);
|
const updatedAt = saveWeekRelational(week);
|
||||||
|
if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user);
|
||||||
console.log('Saved week:', week.label);
|
console.log('Saved week:', week.label);
|
||||||
return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
|
return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
|
||||||
} catch(err){
|
} catch(err){
|
||||||
@@ -3322,6 +3505,142 @@ app.post('/save-week', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// darfProjektlisteAendern: Systemrollen, die Projekte anlegen und aus der
|
||||||
|
// Projektliste löschen dürfen. Dieselbe Regel steht clientseitig in
|
||||||
|
// darfProjektlisteAendern() (public/js/auth.js), dort nur als UI-Gate.
|
||||||
|
function darfProjektlisteAendern(user){
|
||||||
|
return !!user && (user.role === 'admin' || user.role === 'projektleiter');
|
||||||
|
}
|
||||||
|
|
||||||
|
// projektlisteAenderungen: vergleicht die eingehende Woche mit dem
|
||||||
|
// gespeicherten Stand. Ein Projekt gilt als dasselbe, wenn seine ID ODER
|
||||||
|
// seine projectUid übereinstimmt. Rückgabe:
|
||||||
|
// neu — eingehende Projekte ohne Gegenstück (angelegt)
|
||||||
|
// entfernt — vollständige gespeicherte Projekt-Objekte (aus loadWeeks())
|
||||||
|
// ohne Gegenstück (gelöscht); der teure loadWeeks()-Aufruf läuft
|
||||||
|
// nur, wenn die günstige Vorprüfung überhaupt eines findet.
|
||||||
|
// Neue Sitzungen legt nur der Server selbst an (ensureCurrentWeekRollover(),
|
||||||
|
// ohne /save-week) — eine über /save-week neu eingehende Woche mit Projekten
|
||||||
|
// zählt deshalb ebenfalls als Anlegen.
|
||||||
|
function projektlisteAenderungen(week){
|
||||||
|
const label = String(week.label);
|
||||||
|
const gespeichert = db.prepare('SELECT project_id, project_uid FROM projects WHERE week_label = ?').all(label);
|
||||||
|
const eingehend = (Array.isArray(week.projects) ? week.projects : []).filter(Boolean);
|
||||||
|
|
||||||
|
const altIds = new Set(gespeichert.map(r => r.project_id));
|
||||||
|
const altUids = new Set(gespeichert.map(r => r.project_uid).filter(Boolean));
|
||||||
|
const neu = eingehend.filter(p => !altIds.has(String(p.id || '')) && !(p.projectUid && altUids.has(String(p.projectUid))));
|
||||||
|
|
||||||
|
const ids = new Set(eingehend.map(p => String(p.id || '')).filter(Boolean));
|
||||||
|
const uids = new Set(eingehend.map(p => String(p.projectUid || '')).filter(Boolean));
|
||||||
|
const fehlend = gespeichert.filter(r => !ids.has(r.project_id) && !(r.project_uid && uids.has(r.project_uid)));
|
||||||
|
if(!fehlend.length) return { neu, entfernt: [] };
|
||||||
|
const w = loadWeeks().find(x => x.label === label);
|
||||||
|
const fehlendIds = new Set(fehlend.map(r => r.project_id));
|
||||||
|
return { neu, entfernt: w ? (w.projects || []).filter(p => fehlendIds.has(String(p.id))) : [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// legeInPapierkorb: schreibt je gelöschtem Projekt einen Schnappschuss nach
|
||||||
|
// deleted_projects. Läuft NACH dem erfolgreichen Speichern der Woche — ein
|
||||||
|
// fehlgeschlagenes Speichern darf keinen Papierkorb-Eintrag für ein Projekt
|
||||||
|
// hinterlassen, das in Wahrheit noch da ist.
|
||||||
|
function legeInPapierkorb(projekte, weekLabel, user){
|
||||||
|
const ins = db.prepare(`INSERT INTO deleted_projects
|
||||||
|
(id, week_label, project_id, project_uid, nr, name, data, deleted_by_user_id, deleted_by_name, deleted_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)`);
|
||||||
|
const jetzt = new Date().toISOString();
|
||||||
|
projekte.forEach(p => {
|
||||||
|
try {
|
||||||
|
ins.run(crypto.randomUUID(), String(weekLabel), String(p.id || ''), String(p.projectUid || ''),
|
||||||
|
String(p.nr || ''), String(p.name || ''), JSON.stringify(p),
|
||||||
|
user ? Number(user.id) || 0 : 0, user ? String(user.displayName || user.username || '') : '', jetzt);
|
||||||
|
console.log('Projekt in den Papierkorb gelegt:', ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim());
|
||||||
|
} catch(err){
|
||||||
|
console.error('Papierkorb-Eintrag fehlgeschlagen:', err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route: Papierkorb — listet die gelöschten Projekte der letzten
|
||||||
|
// GELOESCHTE_PROJEKTE_TAGE Tage (ohne den Schnappschuss selbst).
|
||||||
|
app.get('/admin/deleted-projects', requireRole('admin'), (req, res) => {
|
||||||
|
try {
|
||||||
|
purgeGeloeschteProjekte();
|
||||||
|
const rows = db.prepare(`SELECT id, week_label, nr, name, deleted_by_name, deleted_at
|
||||||
|
FROM deleted_projects ORDER BY deleted_at DESC`).all();
|
||||||
|
const tagMs = 24 * 60 * 60 * 1000;
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
aufbewahrungTage: GELOESCHTE_PROJEKTE_TAGE,
|
||||||
|
projekte: rows.map(r => ({
|
||||||
|
id: r.id, weekLabel: r.week_label, nr: r.nr, name: r.name,
|
||||||
|
deletedBy: r.deleted_by_name, deletedAt: r.deleted_at,
|
||||||
|
endgueltigAm: new Date(Date.parse(r.deleted_at) + GELOESCHTE_PROJEKTE_TAGE * tagMs).toISOString()
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch(err){
|
||||||
|
console.error('Papierkorb laden fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Papierkorb — stellt ein gelöschtes Projekt wieder her. Ziel ist
|
||||||
|
// immer die aktuelle (jüngste echte) Sitzung, nicht die, aus der es gelöscht
|
||||||
|
// wurde: Ältere Sitzungen sind gesperrte Historie. Eine Nummern-Kollision mit
|
||||||
|
// einem inzwischen neu angelegten Projekt wird mit 409 abgewiesen (Dateien
|
||||||
|
// und Protokolle hängen an der Nummer und gehörten sonst zwei Projekten).
|
||||||
|
app.post('/admin/deleted-projects/:id/restore', requireRole('admin'), (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT * FROM deleted_projects WHERE id = ?').get(String(req.params.id));
|
||||||
|
if(!row) return res.status(404).json({ ok: false, error: 'Eintrag nicht gefunden (evtl. schon wiederhergestellt oder abgelaufen).' });
|
||||||
|
const echte = loadWeeks().filter(w => w.label !== SANDBOX_WEEK_LABEL)
|
||||||
|
.sort((a, b) => weekSortKeyServer(a.label).localeCompare(weekSortKeyServer(b.label)));
|
||||||
|
const ziel = echte[echte.length - 1];
|
||||||
|
if(!ziel) return res.status(409).json({ ok: false, error: 'Es gibt keine Sitzung, in die das Projekt zurückkehren könnte.' });
|
||||||
|
|
||||||
|
const projekt = JSON.parse(row.data);
|
||||||
|
const projekte = Array.isArray(ziel.projects) ? ziel.projects : (ziel.projects = []);
|
||||||
|
const nr = String(projekt.nr || '').trim();
|
||||||
|
if(nr && projekte.some(p => String(p.nr || '').trim() === nr)){
|
||||||
|
return res.status(409).json({ ok: false, error: 'In der Sitzung "' + ziel.label + '" gibt es bereits ein Projekt mit der Nummer ' + nr + '.' });
|
||||||
|
}
|
||||||
|
if(projekt.projectUid && projekte.some(p => p.projectUid === projekt.projectUid)){
|
||||||
|
return res.status(409).json({ ok: false, error: 'Dieses Projekt steht bereits in der Sitzung "' + ziel.label + '".' });
|
||||||
|
}
|
||||||
|
// Neue ID, falls die alte in der Zielsitzung schon vergeben ist (nach
|
||||||
|
// einem Sitzungswechsel praktisch ausgeschlossen, aber billig abzusichern).
|
||||||
|
if(!projekt.id || projekte.some(p => p.id === projekt.id)) projekt.id = crypto.randomUUID();
|
||||||
|
// Ans Ende seiner Gruppe — die alte Position kann inzwischen belegt sein.
|
||||||
|
const maxOrder = projekte.filter(p => p.group === projekt.group)
|
||||||
|
.reduce((m, p) => Math.max(m, Number(p.order) || 0), -1);
|
||||||
|
projekt.order = maxOrder + 1;
|
||||||
|
// Der Sitzungskommentar gehört zur Sitzung, aus der das Projekt stammt.
|
||||||
|
if(row.week_label !== ziel.label){
|
||||||
|
projekt.status = ''; projekt.statusAuthor = ''; projekt.statusAt = '';
|
||||||
|
projekt.besprochen = false; delete projekt.statusState;
|
||||||
|
}
|
||||||
|
projekte.push(projekt);
|
||||||
|
|
||||||
|
const updatedAt = saveWeekRelational(ziel);
|
||||||
|
db.prepare('DELETE FROM deleted_projects WHERE id = ?').run(row.id);
|
||||||
|
console.log('Projekt aus dem Papierkorb wiederhergestellt:', ((projekt.nr ? projekt.nr + ' ' : '') + (projekt.name || '')).trim(), '→', ziel.label);
|
||||||
|
res.json({ ok: true, weekLabel: ziel.label, updatedAt });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Wiederherstellen fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Papierkorb — entfernt einen Eintrag sofort endgültig.
|
||||||
|
app.delete('/admin/deleted-projects/:id', requireRole('admin'), (req, res) => {
|
||||||
|
try {
|
||||||
|
db.prepare('DELETE FROM deleted_projects WHERE id = ?').run(String(req.params.id));
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch(err){
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Route: Löscht eine Woche (fehlende Woche wird toleriert)
|
// Route: Löscht eine Woche (fehlende Woche wird toleriert)
|
||||||
app.post('/delete-week', requireRole('admin'), (req, res) => {
|
app.post('/delete-week', requireRole('admin'), (req, res) => {
|
||||||
const { label } = req.body || {};
|
const { label } = req.body || {};
|
||||||
@@ -3620,7 +3939,7 @@ app.get('/my-dashboard', requireAuth, (req, res) => {
|
|||||||
const pendingReviews = db.prepare(`
|
const pendingReviews = db.prepare(`
|
||||||
SELECT f.id AS fileId, f.project_nr AS projectNr,
|
SELECT f.id AS fileId, f.project_nr AS projectNr,
|
||||||
f.project_name AS projectName, f.original_filename AS originalFilename,
|
f.project_name AS projectName, f.original_filename AS originalFilename,
|
||||||
COALESCE(NULLIF(u.display_name, ''), u.username) AS uploadedBy,
|
COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS uploadedBy,
|
||||||
f.created_at AS createdAt
|
f.created_at AS createdAt
|
||||||
FROM project_file_reviewers r
|
FROM project_file_reviewers r
|
||||||
JOIN project_files f ON f.id = r.file_id
|
JOIN project_files f ON f.id = r.file_id
|
||||||
@@ -3873,14 +4192,14 @@ app.get('/project-files', requireAuth, (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const projectNr = String(req.query.projectNr || '');
|
const projectNr = String(req.query.projectNr || '');
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT f.*, COALESCE(NULLIF(u.display_name, ''), u.username) AS uploadedBy
|
SELECT f.*, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS uploadedBy
|
||||||
FROM project_files f
|
FROM project_files f
|
||||||
LEFT JOIN users u ON u.id = f.uploaded_by_user_id
|
LEFT JOIN users u ON u.id = f.uploaded_by_user_id
|
||||||
WHERE f.project_nr = ?
|
WHERE f.project_nr = ?
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY f.created_at DESC
|
||||||
`).all(projectNr);
|
`).all(projectNr);
|
||||||
const reviewerStmt = db.prepare(`
|
const reviewerStmt = db.prepare(`
|
||||||
SELECT r.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username) AS displayName, r.decision AS decision
|
SELECT r.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS displayName, r.decision AS decision
|
||||||
FROM project_file_reviewers r
|
FROM project_file_reviewers r
|
||||||
LEFT JOIN users u ON u.id = r.user_id
|
LEFT JOIN users u ON u.id = r.user_id
|
||||||
WHERE r.file_id = ?
|
WHERE r.file_id = ?
|
||||||
@@ -3964,7 +4283,7 @@ app.post('/project-files/:id/review', requireAuth, (req, res) => {
|
|||||||
app.get('/project-files/:id/comments', requireAuth, (req, res) => {
|
app.get('/project-files/:id/comments', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT c.id AS id, c.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username) AS displayName,
|
SELECT c.id AS id, c.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS displayName,
|
||||||
c.body AS body, c.created_at AS createdAt
|
c.body AS body, c.created_at AS createdAt
|
||||||
FROM project_file_comments c
|
FROM project_file_comments c
|
||||||
LEFT JOIN users u ON u.id = c.user_id
|
LEFT JOIN users u ON u.id = c.user_id
|
||||||
@@ -6558,6 +6877,8 @@ const server = app.listen(PORT, () => {
|
|||||||
// Reihenfolge weiter oben.
|
// Reihenfolge weiter oben.
|
||||||
ensureCurrentWeekRollover();
|
ensureCurrentWeekRollover();
|
||||||
setInterval(ensureCurrentWeekRollover, 60 * 60 * 1000);
|
setInterval(ensureCurrentWeekRollover, 60 * 60 * 1000);
|
||||||
|
// Papierkorb gelöschter Projekte: Einträge nach 30 Tagen entfernen.
|
||||||
|
setInterval(purgeGeloeschteProjekte, 60 * 60 * 1000);
|
||||||
});
|
});
|
||||||
server.on('error', (err) => {
|
server.on('error', (err) => {
|
||||||
console.error('Server konnte nicht starten:', err.message);
|
console.error('Server konnte nicht starten:', err.message);
|
||||||
|
|||||||
Reference in new issue
Block a user