Version 1.5
This commit is contained in:
52
AGENTS.md
Normal file
52
AGENTS.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# AGENTS
|
||||||
|
|
||||||
|
## Stack And Entry Points
|
||||||
|
- This repo is a plain PHP app for Strato-style hosting. There is no Node, Composer, container, or build step.
|
||||||
|
- Live web root is `httpdocs/`.
|
||||||
|
- Main app entrypoint: `httpdocs/index.php`.
|
||||||
|
- First-time setup entrypoint: `httpdocs/install.php`.
|
||||||
|
- App wiring and request handling live in `httpdocs/app/bootstrap.php`.
|
||||||
|
- All HTML/UI rendering lives in `httpdocs/app/views.php`.
|
||||||
|
|
||||||
|
## Deployment Reality
|
||||||
|
- Changes in this repo do nothing until the contents of `httpdocs/` are uploaded to the server.
|
||||||
|
- If the user says “nothing changed”, verify whether the live site is serving the current workspace contents before changing logic again.
|
||||||
|
- The live site can be checked directly at `http://arbeitsstunden.tc-ingelfingen.de`.
|
||||||
|
|
||||||
|
## Runtime Behavior
|
||||||
|
- The app supports two modes:
|
||||||
|
- With DB: MySQL/MariaDB via `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASS`.
|
||||||
|
- Without DB: demo mode with in-code users from `findUserByEmail()` and in-code member/hour data from `fetchUsers()` / `fetchLogs()`.
|
||||||
|
- `index.php` redirects to `install.php` only when a DB connection exists and no users are installed.
|
||||||
|
- `install.php` only works with a DB connection; without DB it only shows an error.
|
||||||
|
|
||||||
|
## Roles And UI Rules
|
||||||
|
- Roles are `member`, `editor`, `admin`.
|
||||||
|
- `member` should only see their own hours on the dashboard.
|
||||||
|
- `editor` and `admin` should see their own hours plus all members in the dashboard table.
|
||||||
|
- Admin-only menu item is `Vereinskonfiguration`.
|
||||||
|
- Header should keep club name on the left and current user + logout on the right.
|
||||||
|
- Navigation is implemented as a left sidebar inside `renderAppShell()`.
|
||||||
|
|
||||||
|
## Persistence Gotchas
|
||||||
|
- Club settings are persisted in DB table `settings` and read into `$config` in `bootstrap.php`.
|
||||||
|
- If settings appear not to save, check DB mode first. In demo mode there is no DB persistence.
|
||||||
|
- Schema is created in `initSchema()` at runtime. Keep `schema.sql` aligned with runtime-created tables.
|
||||||
|
- `schema.sql` currently contains seed password hashes that may drift from runtime-generated demo passwords; trust runtime behavior in `bootstrap.php` over static SQL prose.
|
||||||
|
|
||||||
|
## Session And Auth Gotchas
|
||||||
|
- Session handling is configured manually in `bootstrap.php` with `session_set_cookie_params()` before `session_start()`.
|
||||||
|
- Login redirects to `/?page=dashboard` after setting `$_SESSION['user']`.
|
||||||
|
- If auth appears broken, inspect `currentUser()` and the session flow in `bootstrap.php` before changing UI conditionals.
|
||||||
|
|
||||||
|
## Editing Guidance
|
||||||
|
- Most app behavior changes require editing `httpdocs/app/views.php` and sometimes `httpdocs/app/bootstrap.php` together.
|
||||||
|
- Prefer minimal inline changes over adding new abstractions; the app is intentionally small and file-local.
|
||||||
|
- When changing page layout, update only `renderAppShell()` unless the login or install flow is involved.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- There is no automated test suite in the repo.
|
||||||
|
- Best available verification is:
|
||||||
|
- read the affected PHP files,
|
||||||
|
- check role-gated branches in `views.php` and action handling in `bootstrap.php`,
|
||||||
|
- optionally fetch the live URL to compare deployed output versus workspace output.
|
||||||
153
ANLEITUNG_ADMIN.md
Normal file
153
ANLEITUNG_ADMIN.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# TC Ingelfingen
|
||||||
|
|
||||||
|
## Arbeitsstundenverwaltung
|
||||||
|
|
||||||
|
### Benutzeranleitung Für Administratoren
|
||||||
|
|
||||||
|
Diese Anleitung beschreibt die wichtigsten Funktionen der Arbeitsstundenverwaltung aus Sicht eines Administrators.
|
||||||
|
|
||||||
|
Administratoren verwalten die Anwendung, pflegen Vereinswerte und führen Sicherungs- und Wiederherstellungsaufgaben durch.
|
||||||
|
|
||||||
|
## 1. Anmeldung
|
||||||
|
|
||||||
|
Melden Sie sich mit Ihrer E-Mail-Adresse und Ihrem Passwort an.
|
||||||
|
|
||||||
|
Falls Sie Ihr Passwort vergessen haben, können Sie den Passwort-Reset über die Anmeldeseite nutzen.
|
||||||
|
|
||||||
|
## 2. Dashboard
|
||||||
|
|
||||||
|
Als Administrator sehen Sie im Dashboard die Gesamtübersicht über alle Mitglieder.
|
||||||
|
|
||||||
|
Angezeigt werden insbesondere:
|
||||||
|
|
||||||
|
- Pflichtstunden gesamt
|
||||||
|
- geleistete Stunden gesamt
|
||||||
|
- noch offene Stunden gesamt
|
||||||
|
- die jeweiligen Werte in EUR
|
||||||
|
- der Fortschritt der geleisteten Arbeitsstunden in Prozent
|
||||||
|
|
||||||
|
Zusätzlich wird ein Informationskasten angezeigt, sofern dieser vom Verein gepflegt wurde.
|
||||||
|
|
||||||
|
## 3. Profil
|
||||||
|
|
||||||
|
Über Ihren Namen oben rechts gelangen Sie in Ihr Profil.
|
||||||
|
|
||||||
|
Dort können Sie:
|
||||||
|
|
||||||
|
- Ihren Vornamen ändern
|
||||||
|
- Ihren Nachnamen ändern
|
||||||
|
- Ihr eigenes Passwort ändern
|
||||||
|
|
||||||
|
## 4. Benutzerverwaltung
|
||||||
|
|
||||||
|
Im Bereich `Benutzerverwaltung` können Sie:
|
||||||
|
|
||||||
|
- neue Benutzer anlegen
|
||||||
|
- Benutzer löschen
|
||||||
|
- Benutzerpasswörter zurücksetzen
|
||||||
|
- Benutzerrollen ändern
|
||||||
|
|
||||||
|
Die Benutzerliste ist nach Nachnamen sortiert und auf 50 Einträge pro Seite paginiert.
|
||||||
|
|
||||||
|
## 5. Stundenbuchung
|
||||||
|
|
||||||
|
Im Bereich `Stundenbuchung` können Sie:
|
||||||
|
|
||||||
|
- Stunden für Mitglieder buchen
|
||||||
|
- alle Stundenbuchungen einsehen
|
||||||
|
- einzelne Stundenbuchungen löschen
|
||||||
|
|
||||||
|
Die Liste der Stundenbuchungen ist auf 50 Einträge pro Seite paginiert.
|
||||||
|
|
||||||
|
## 6. Vereinskonfiguration
|
||||||
|
|
||||||
|
Im Bereich `Vereinskonfiguration` können Sie:
|
||||||
|
|
||||||
|
- Pflichtstunden pro Mitglied festlegen
|
||||||
|
- den Wert pro Stunde festlegen
|
||||||
|
- den Informationskasten im Dashboard pflegen
|
||||||
|
- Benutzerdaten als CSV exportieren
|
||||||
|
- Stundenbuchungen als CSV exportieren
|
||||||
|
|
||||||
|
## 7. Administration
|
||||||
|
|
||||||
|
Im Bereich `Administration` finden Sie erweiterte Verwaltungsfunktionen.
|
||||||
|
|
||||||
|
Dazu gehören:
|
||||||
|
|
||||||
|
- SQL-Dump herunterladen
|
||||||
|
- SQL-Dump wiederherstellen
|
||||||
|
- Benutzerdaten per CSV importieren
|
||||||
|
- alle Arbeitsstunden auf 0 zurücksetzen
|
||||||
|
- Logansicht aller Verwaltungsaktionen
|
||||||
|
|
||||||
|
Die Logansicht ist auf 50 Einträge pro Seite paginiert.
|
||||||
|
|
||||||
|
## 8. SQL-Backup Und Wiederherstellung
|
||||||
|
|
||||||
|
Für vollständige Datensicherungen steht ein SQL-Dump zur Verfügung.
|
||||||
|
|
||||||
|
Empfohlene Vorgehensweise:
|
||||||
|
|
||||||
|
1. Laden Sie vor größeren Änderungen einen SQL-Dump herunter.
|
||||||
|
2. Bewahren Sie die Datei an einem sicheren Ort auf.
|
||||||
|
3. Verwenden Sie für eine Wiederherstellung ausschließlich vertrauenswürdige SQL-Dateien.
|
||||||
|
|
||||||
|
Wichtiger Hinweis:
|
||||||
|
|
||||||
|
- Die Wiederherstellung eines SQL-Dumps überschreibt die bestehenden App-Daten.
|
||||||
|
|
||||||
|
## 9. CSV-Import
|
||||||
|
|
||||||
|
Der CSV-Import dient zur Übernahme von Benutzerdaten.
|
||||||
|
|
||||||
|
Erwartet werden folgende Spalten:
|
||||||
|
|
||||||
|
- `firstname`
|
||||||
|
- `lastname`
|
||||||
|
- `email`
|
||||||
|
- `role`
|
||||||
|
- `hours_worked`
|
||||||
|
|
||||||
|
Benutzer werden anhand der E-Mail-Adresse erkannt.
|
||||||
|
|
||||||
|
## 10. Arbeitsstunden Zurücksetzen
|
||||||
|
|
||||||
|
Mit dieser Funktion werden alle gebuchten Arbeitsstunden aller Mitglieder auf 0 gesetzt.
|
||||||
|
|
||||||
|
Diese Funktion ist insbesondere für den Jahreswechsel vorgesehen und sollte nur mit entsprechender Sorgfalt verwendet werden.
|
||||||
|
|
||||||
|
## 11. Logansicht
|
||||||
|
|
||||||
|
In der Logansicht werden unter anderem folgende Aktionen protokolliert:
|
||||||
|
|
||||||
|
- Benutzer anlegen
|
||||||
|
- Benutzer löschen
|
||||||
|
- Rollen ändern
|
||||||
|
- Passwort zurücksetzen
|
||||||
|
- eigenes Passwort ändern
|
||||||
|
- Passwort per Reset-Link neu setzen
|
||||||
|
- Speichern von Vereinswerten
|
||||||
|
- CSV-Exporte
|
||||||
|
- CSV-Import
|
||||||
|
- Arbeitsstunden auf 0 setzen
|
||||||
|
- SQL-Dump exportieren und wiederherstellen
|
||||||
|
|
||||||
|
## 12. Passwort Vergessen
|
||||||
|
|
||||||
|
Falls Sie Ihr Passwort vergessen haben, können Sie den Reset-Link über die Anmeldeseite anfordern und anschließend ein neues Passwort vergeben.
|
||||||
|
|
||||||
|
## 13. Darkmode
|
||||||
|
|
||||||
|
Über den Schalter links neben `Abmelden` kann zwischen hellem und dunklem Farbschema gewechselt werden.
|
||||||
|
|
||||||
|
## 14. Abmelden
|
||||||
|
|
||||||
|
Bitte melden Sie sich nach Abschluss Ihrer Arbeiten stets über `Abmelden` ab.
|
||||||
|
|
||||||
|
## 15. Empfehlungen Für Den Betrieb
|
||||||
|
|
||||||
|
- Erstellen Sie regelmäßig SQL-Backups.
|
||||||
|
- Nutzen Sie CSV-Exporte für Auswertungen und Weitergaben.
|
||||||
|
- Prüfen Sie Importe vor der Ausführung sorgfältig.
|
||||||
|
- Dokumentieren Sie größere organisatorische oder technische Änderungen zusätzlich außerhalb der Anwendung.
|
||||||
146
ANLEITUNG_BEARBEITER.md
Normal file
146
ANLEITUNG_BEARBEITER.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# TC Ingelfingen
|
||||||
|
|
||||||
|
## Arbeitsstundenverwaltung
|
||||||
|
|
||||||
|
### Benutzeranleitung Für Bearbeiter
|
||||||
|
|
||||||
|
Diese Anleitung beschreibt die wichtigsten Funktionen der Arbeitsstundenverwaltung aus Sicht eines Bearbeiters.
|
||||||
|
|
||||||
|
Bearbeiter unterstützen den Verein insbesondere bei der Erfassung von Arbeitsstunden und bei der Pflege von Benutzerdaten.
|
||||||
|
|
||||||
|
## 1. Anmeldung
|
||||||
|
|
||||||
|
Rufen Sie die Arbeitsstundenverwaltung über den Vereinslink auf und melden Sie sich mit Ihrer E-Mail-Adresse und Ihrem Passwort an.
|
||||||
|
|
||||||
|
Falls Sie Ihr Passwort vergessen haben, können Sie über `Passwort vergessen?` auf der Anmeldeseite einen Reset-Link anfordern.
|
||||||
|
|
||||||
|
## 2. Dashboard
|
||||||
|
|
||||||
|
Nach der Anmeldung gelangen Sie auf das Dashboard.
|
||||||
|
|
||||||
|
Als Bearbeiter sehen Sie dort nicht nur persönliche Daten, sondern die Gesamtübersicht über alle Mitglieder.
|
||||||
|
|
||||||
|
Angezeigt werden insbesondere:
|
||||||
|
|
||||||
|
- Pflichtstunden gesamt
|
||||||
|
- geleistete Stunden gesamt
|
||||||
|
- noch offene Stunden gesamt
|
||||||
|
- die jeweiligen Werte in EUR
|
||||||
|
|
||||||
|
Zusätzlich wird ein Fortschrittsbalken angezeigt, der die geleisteten Arbeitsstunden in Prozent darstellt.
|
||||||
|
|
||||||
|
Falls vom Verein gepflegt, erscheint außerdem ein Informationskasten mit aktuellen Mitteilungen.
|
||||||
|
|
||||||
|
## 3. Profil
|
||||||
|
|
||||||
|
Über Ihren Namen oben rechts gelangen Sie in den Bereich `Profil`.
|
||||||
|
|
||||||
|
Dort können Sie:
|
||||||
|
|
||||||
|
- Ihren Vornamen ändern
|
||||||
|
- Ihren Nachnamen ändern
|
||||||
|
- Ihr Passwort ändern
|
||||||
|
|
||||||
|
Die hinterlegte E-Mail-Adresse wird angezeigt, kann jedoch nicht geändert werden.
|
||||||
|
|
||||||
|
## 4. Benutzerverwaltung
|
||||||
|
|
||||||
|
Über den Menüpunkt `Benutzerverwaltung` können Sie Benutzer verwalten.
|
||||||
|
|
||||||
|
Zu Ihren Aufgaben gehören dort insbesondere:
|
||||||
|
|
||||||
|
- neue Benutzer anlegen
|
||||||
|
- die Benutzerliste einsehen
|
||||||
|
- Passwörter von Benutzern zurücksetzen
|
||||||
|
- Benutzer löschen
|
||||||
|
|
||||||
|
Die Benutzerliste ist nach Nachnamen sortiert und in Seiten mit jeweils 50 Einträgen gegliedert.
|
||||||
|
|
||||||
|
Hinweis:
|
||||||
|
|
||||||
|
- Das Ändern von Rollen ist ausschließlich Administratoren vorbehalten.
|
||||||
|
|
||||||
|
## 5. Stundenbuchung
|
||||||
|
|
||||||
|
Über den Menüpunkt `Stundenbuchung` können Sie Arbeitsstunden für Mitglieder erfassen.
|
||||||
|
|
||||||
|
Für jede Buchung werden erfasst:
|
||||||
|
|
||||||
|
- der Benutzer
|
||||||
|
- die Anzahl der Stunden
|
||||||
|
- eine kurze Notiz
|
||||||
|
|
||||||
|
Unterhalb des Formulars sehen Sie die vollständige Liste der Stundenbuchungen.
|
||||||
|
|
||||||
|
Dort werden angezeigt:
|
||||||
|
|
||||||
|
- Datum
|
||||||
|
- Mitglied
|
||||||
|
- gebucht von
|
||||||
|
- Stunden
|
||||||
|
- Notiz
|
||||||
|
|
||||||
|
Die Liste ist in Seiten mit jeweils 50 Einträgen aufgeteilt.
|
||||||
|
|
||||||
|
Hinweis:
|
||||||
|
|
||||||
|
- Das Löschen von Stundenbuchungen ist ausschließlich Administratoren möglich.
|
||||||
|
|
||||||
|
## 6. Vereinskonfiguration
|
||||||
|
|
||||||
|
Als Bearbeiter haben Sie Zugriff auf den Bereich `Vereinskonfiguration`.
|
||||||
|
|
||||||
|
Dort können Sie:
|
||||||
|
|
||||||
|
- die Pflichtstunden pro Mitglied anpassen
|
||||||
|
- den Wert pro Stunde festlegen
|
||||||
|
- den Informationskasten für das Dashboard pflegen
|
||||||
|
- Benutzerdaten als CSV exportieren
|
||||||
|
- Stundenbuchungen als CSV exportieren
|
||||||
|
|
||||||
|
Der Informationskasten im Dashboard unterstützt eine einfache Markdown-Formatierung, zum Beispiel:
|
||||||
|
|
||||||
|
- Absätze
|
||||||
|
- Listen mit `- `
|
||||||
|
- `*kursiv*`
|
||||||
|
- `**fett**`
|
||||||
|
|
||||||
|
## 7. Nicht Verfügbare Funktionen Für Bearbeiter
|
||||||
|
|
||||||
|
Der Bereich `Administration` ist nur für Administratoren vorgesehen.
|
||||||
|
|
||||||
|
Dort befinden sich Funktionen wie:
|
||||||
|
|
||||||
|
- SQL-Datensicherung
|
||||||
|
- SQL-Wiederherstellung
|
||||||
|
- CSV-Import
|
||||||
|
- Zurücksetzen aller Arbeitsstunden auf 0
|
||||||
|
- Logansicht der Verwaltungsaktionen
|
||||||
|
|
||||||
|
## 8. Passwort Vergessen
|
||||||
|
|
||||||
|
Wenn Sie Ihr Passwort vergessen haben, gehen Sie bitte wie folgt vor:
|
||||||
|
|
||||||
|
1. Klicken Sie auf `Passwort vergessen?`
|
||||||
|
2. Geben Sie Ihre E-Mail-Adresse ein
|
||||||
|
3. Fordern Sie den Reset-Link an
|
||||||
|
4. Öffnen Sie die E-Mail des Systems
|
||||||
|
5. Vergeben Sie ein neues Passwort
|
||||||
|
6. Melden Sie sich anschließend erneut an
|
||||||
|
|
||||||
|
## 9. Darkmode
|
||||||
|
|
||||||
|
Über den Schalter links neben `Abmelden` können Sie zwischen hellem und dunklem Farbschema wechseln.
|
||||||
|
|
||||||
|
Die Auswahl wird im Browser gespeichert.
|
||||||
|
|
||||||
|
## 10. Abmelden
|
||||||
|
|
||||||
|
Bitte melden Sie sich nach Abschluss Ihrer Arbeiten über den Button `Abmelden` oben rechts ordnungsgemäß ab.
|
||||||
|
|
||||||
|
## 11. Hinweise Für Die Praxis
|
||||||
|
|
||||||
|
- Achten Sie bei der Stundenbuchung auf nachvollziehbare und kurze Notizen.
|
||||||
|
- Prüfen Sie neue Benutzer sorgfältig auf korrekte Schreibweise von Namen und E-Mail-Adressen.
|
||||||
|
- Nutzen Sie die Exportfunktionen regelmäßig für Auswertungen oder organisatorische Weitergaben.
|
||||||
|
- Informieren Sie die Vereinsadministration bei Unklarheiten oder bei größeren Änderungen.
|
||||||
85
ANLEITUNG_MITGLIEDER.md
Normal file
85
ANLEITUNG_MITGLIEDER.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# TC Ingelfingen
|
||||||
|
|
||||||
|
## Arbeitsstundenverwaltung
|
||||||
|
|
||||||
|
### Benutzeranleitung Für Mitglieder
|
||||||
|
|
||||||
|
Diese Anleitung dient als kurze Einführung in die Nutzung der Arbeitsstundenverwaltung für Mitglieder.
|
||||||
|
|
||||||
|
## 1. Anmeldung
|
||||||
|
|
||||||
|
Rufen Sie die Arbeitsstundenverwaltung über den vom Verein bereitgestellten Link auf.
|
||||||
|
|
||||||
|
Für die Anmeldung benötigen Sie:
|
||||||
|
|
||||||
|
- Ihre hinterlegte E-Mail-Adresse
|
||||||
|
- Ihr persönliches Passwort
|
||||||
|
|
||||||
|
Geben Sie beide Angaben auf der Anmeldeseite ein und klicken Sie anschließend auf `Anmelden`.
|
||||||
|
|
||||||
|
## 2. Dashboard
|
||||||
|
|
||||||
|
Nach der Anmeldung gelangen Sie direkt auf Ihr persönliches Dashboard.
|
||||||
|
|
||||||
|
Dort erhalten Sie einen Überblick über:
|
||||||
|
|
||||||
|
- Ihre Pflichtstunden
|
||||||
|
- Ihre bereits geleisteten Arbeitsstunden
|
||||||
|
- Ihre noch offenen Arbeitsstunden
|
||||||
|
- den rechnerischen Geldwert der noch offenen Stunden
|
||||||
|
|
||||||
|
Zusätzlich kann auf dem Dashboard ein Informationskasten des Vereins angezeigt werden. In diesem Bereich veröffentlicht der Verein wichtige Hinweise, organisatorische Informationen oder aktuelle Mitteilungen.
|
||||||
|
|
||||||
|
## 3. Stundenbuchungen Einsehen
|
||||||
|
|
||||||
|
Über den Menüpunkt `Stundenbuchung` können Sie Ihre eigenen gebuchten Arbeitsstunden einsehen.
|
||||||
|
|
||||||
|
In der Übersicht werden unter anderem folgende Informationen angezeigt:
|
||||||
|
|
||||||
|
- Datum der Buchung
|
||||||
|
- Anzahl der gebuchten Stunden
|
||||||
|
- Notiz zur Buchung
|
||||||
|
- Person, die die Buchung eingetragen hat
|
||||||
|
|
||||||
|
Als Mitglied sehen Sie ausschließlich Ihre eigenen Stundenbuchungen.
|
||||||
|
|
||||||
|
## 4. Persönliche Daten Und Passwort
|
||||||
|
|
||||||
|
Oben rechts in der Kopfzeile finden Sie Ihren Namen. Über diesen Button gelangen Sie in den Bereich `Profil`.
|
||||||
|
|
||||||
|
Dort können Sie:
|
||||||
|
|
||||||
|
- Ihren Vornamen ändern
|
||||||
|
- Ihren Nachnamen ändern
|
||||||
|
- Ihr Passwort ändern
|
||||||
|
|
||||||
|
Ihre E-Mail-Adresse wird dort ebenfalls angezeigt, kann jedoch nicht geändert werden.
|
||||||
|
|
||||||
|
## 5. Passwort Vergessen
|
||||||
|
|
||||||
|
Wenn Sie Ihr Passwort nicht mehr kennen, nutzen Sie auf der Anmeldeseite den Link `Passwort vergessen?`.
|
||||||
|
|
||||||
|
Gehen Sie dabei bitte wie folgt vor:
|
||||||
|
|
||||||
|
1. Klicken Sie auf `Passwort vergessen?`
|
||||||
|
2. Geben Sie Ihre E-Mail-Adresse ein
|
||||||
|
3. Fordern Sie den Reset-Link an
|
||||||
|
4. Öffnen Sie die E-Mail des Systems
|
||||||
|
5. Vergeben Sie über den enthaltenen Link ein neues Passwort
|
||||||
|
6. Melden Sie sich anschließend mit dem neuen Passwort erneut an
|
||||||
|
|
||||||
|
## 6. Darkmode
|
||||||
|
|
||||||
|
Links neben dem Button `Abmelden` befindet sich ein Schalter für den Darkmode.
|
||||||
|
|
||||||
|
Damit können Sie zwischen einer hellen und einer dunklen Darstellung der Anwendung wechseln. Die gewählte Einstellung wird auf Ihrem Gerät gespeichert.
|
||||||
|
|
||||||
|
## 7. Abmelden
|
||||||
|
|
||||||
|
Wenn Sie Ihre Arbeit beendet haben, melden Sie sich bitte über den Button `Abmelden` oben rechts wieder ab.
|
||||||
|
|
||||||
|
Dadurch wird Ihre Sitzung sicher beendet.
|
||||||
|
|
||||||
|
## 8. Unterstützung
|
||||||
|
|
||||||
|
Falls Ihnen Daten fehlen, Stunden nicht korrekt angezeigt werden oder Sie keinen Zugriff auf Ihr Konto haben, wenden Sie sich bitte an einen Bearbeiter oder an die Vereinsadministration.
|
||||||
32
README.md
32
README.md
@@ -21,6 +21,7 @@ Strato-taugliche PHP-Webanwendung für die Arbeitszeiterfassung des TC Ingelfing
|
|||||||
- Audit-Log für Verwaltungsaktionen wie Benutzer anlegen, löschen, Rollen ändern und Passwortaktionen
|
- Audit-Log für Verwaltungsaktionen wie Benutzer anlegen, löschen, Rollen ändern und Passwortaktionen
|
||||||
- CSV-Export und CSV-Import für Benutzerdaten inklusive geleisteter Stunden
|
- CSV-Export und CSV-Import für Benutzerdaten inklusive geleisteter Stunden
|
||||||
- Separater CSV-Export für alle Stundenbuchungen
|
- Separater CSV-Export für alle Stundenbuchungen
|
||||||
|
- SQL-Dump-Export und SQL-Dump-Wiederherstellung für vollständige App-Backups
|
||||||
- Admin-Funktion zum Zurücksetzen aller Arbeitsstunden auf 0
|
- Admin-Funktion zum Zurücksetzen aller Arbeitsstunden auf 0
|
||||||
- Tabler CSS via CDN für UI, Cards, Tabellen und Formulare
|
- Tabler CSS via CDN für UI, Cards, Tabellen und Formulare
|
||||||
|
|
||||||
@@ -79,6 +80,8 @@ Die Anwendung ist so aufgebaut, dass sie mit oder ohne DB läuft. Bei gesetzter
|
|||||||
- Informationskasten für das Dashboard pflegen
|
- Informationskasten für das Dashboard pflegen
|
||||||
- Benutzerdaten als CSV exportieren
|
- Benutzerdaten als CSV exportieren
|
||||||
- Alle Stundenbuchungen als CSV exportieren
|
- Alle Stundenbuchungen als CSV exportieren
|
||||||
|
- SQL-Dump für vollständige Datensicherung herunterladen
|
||||||
|
- SQL-Dump zur Wiederherstellung hochladen
|
||||||
- Benutzerdaten aus CSV importieren
|
- Benutzerdaten aus CSV importieren
|
||||||
- Alle Arbeitsstunden auf 0 zurücksetzen
|
- Alle Arbeitsstunden auf 0 zurücksetzen
|
||||||
- Logansicht mit 50 Einträgen pro Seite
|
- Logansicht mit 50 Einträgen pro Seite
|
||||||
@@ -115,6 +118,24 @@ Beispiel-Kopfzeile:
|
|||||||
|
|
||||||
`firstname,lastname,email,role,hours_worked`
|
`firstname,lastname,email,role,hours_worked`
|
||||||
|
|
||||||
|
## Backup Und Wiederherstellung
|
||||||
|
|
||||||
|
Zusätzlich zu den CSV-Exporten gibt es in der Vereinskonfiguration einen SQL-Dump für vollständige Backups der App-Datenbank.
|
||||||
|
|
||||||
|
Enthalten sind die App-Tabellen:
|
||||||
|
|
||||||
|
- `users`
|
||||||
|
- `work_logs`
|
||||||
|
- `settings`
|
||||||
|
- `audit_logs`
|
||||||
|
- `password_resets`
|
||||||
|
|
||||||
|
Hinweise:
|
||||||
|
|
||||||
|
- Der SQL-Dump ist für vollständige Sicherungen und Wiederherstellung gedacht.
|
||||||
|
- Die SQL-Wiederherstellung überschreibt die bestehenden App-Daten.
|
||||||
|
- CSV-Exporte bleiben zusätzlich für Auswertungen und Teilimporte erhalten.
|
||||||
|
|
||||||
## Passwort-Reset Und Konfiguration
|
## Passwort-Reset Und Konfiguration
|
||||||
|
|
||||||
Für den Passwort-Reset per E-Mail wird eine `config.php` im Projektwurzelverzeichnis verwendet. Eine Vorlage liegt als `config.php.example` vor.
|
Für den Passwort-Reset per E-Mail wird eine `config.php` im Projektwurzelverzeichnis verwendet. Eine Vorlage liegt als `config.php.example` vor.
|
||||||
@@ -147,6 +168,14 @@ Hinweis:
|
|||||||
|
|
||||||
Wenn noch kein Benutzer vorhanden ist, leite `index.php` automatisch auf `install.php` weiter. Dort kannst du ein erstes Admin-Konto anlegen.
|
Wenn noch kein Benutzer vorhanden ist, leite `index.php` automatisch auf `install.php` weiter. Dort kannst du ein erstes Admin-Konto anlegen.
|
||||||
|
|
||||||
|
## Handouts
|
||||||
|
|
||||||
|
Für die Weitergabe an Anwender stehen folgende Anleitungen bereit:
|
||||||
|
|
||||||
|
- `ANLEITUNG_MITGLIEDER.md`
|
||||||
|
- `ANLEITUNG_BEARBEITER.md`
|
||||||
|
- `ANLEITUNG_ADMIN.md`
|
||||||
|
|
||||||
## Dateien
|
## Dateien
|
||||||
|
|
||||||
- `httpdocs/index.php` Einstieg
|
- `httpdocs/index.php` Einstieg
|
||||||
@@ -154,3 +183,6 @@ Wenn noch kein Benutzer vorhanden ist, leite `index.php` automatisch auf `instal
|
|||||||
- `httpdocs/app/bootstrap.php` DB, Login und Business-Logik
|
- `httpdocs/app/bootstrap.php` DB, Login und Business-Logik
|
||||||
- `httpdocs/app/views.php` Tabler-UI
|
- `httpdocs/app/views.php` Tabler-UI
|
||||||
- `config.php.example` Vorlage für DB- und Mail-Konfiguration
|
- `config.php.example` Vorlage für DB- und Mail-Konfiguration
|
||||||
|
- `ANLEITUNG_MITGLIEDER.md` Handout für Mitglieder
|
||||||
|
- `ANLEITUNG_BEARBEITER.md` Handout für Bearbeiter
|
||||||
|
- `ANLEITUNG_ADMIN.md` Handout für Administratoren
|
||||||
|
|||||||
17
config.php
Normal file
17
config.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
return [
|
||||||
|
'author' => 'OpenCode',
|
||||||
|
'copyright' => 'Copyright 2026 TC-Ingelfingen',
|
||||||
|
'app_version' => '1.0.0',
|
||||||
|
'db' => [
|
||||||
|
'host' => 'database-5020507124.webspace-host.com',
|
||||||
|
'name' => 'dbs15701183',
|
||||||
|
'user' => 'dbu747436',
|
||||||
|
'pass' => 'KuTq0PHto5izfQ',
|
||||||
|
],
|
||||||
|
'smtp' => [
|
||||||
|
'from_email' => 'webmaster@tc-ingelfingen.de',
|
||||||
|
'from_name' => 'TC-Ingelfingen',
|
||||||
|
],
|
||||||
|
];
|
||||||
13
config.php.old
Normal file
13
config.php.old
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'db' => [
|
||||||
|
'host' => 'database-5020507124.webspace-host.com',
|
||||||
|
'name' => 'dbs15701183',
|
||||||
|
'user' => 'dbu747436',
|
||||||
|
'pass' => 'KuTq0PHto5izfQ',
|
||||||
|
],
|
||||||
|
'hours_target' => 12,
|
||||||
|
'hourly_rate_eur' => 10,
|
||||||
|
];
|
||||||
909
httpdocs/app/bootstrap.php
Normal file
909
httpdocs/app/bootstrap.php
Normal file
@@ -0,0 +1,909 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$localConfig = [];
|
||||||
|
$localConfigFile = dirname(__DIR__, 2) . '/config.php';
|
||||||
|
if (is_file($localConfigFile)) {
|
||||||
|
$loaded = require $localConfigFile;
|
||||||
|
if (is_array($loaded)) {
|
||||||
|
$localConfig = $loaded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = [
|
||||||
|
'app_name' => 'TC Ingelfingen Arbeitszeiterfassung',
|
||||||
|
'app_version' => (string)($localConfig['app_version'] ?? '1.0.0'),
|
||||||
|
'author' => (string)($localConfig['author'] ?? 'OpenCode'),
|
||||||
|
'copyright' => (string)($localConfig['copyright'] ?? 'Copyright 2026 TC-Ingelfingen'),
|
||||||
|
'hours_target' => (int)($localConfig['hours_target'] ?? (getenv('HOURS_TARGET') ?: 12)),
|
||||||
|
'hourly_rate_eur' => (float)($localConfig['hourly_rate_eur'] ?? (getenv('HOURLY_RATE_EUR') ?: 10)),
|
||||||
|
'dashboard_info' => (string)($localConfig['dashboard_info'] ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
$smtpConfig = [
|
||||||
|
'host' => (string)($localConfig['smtp']['host'] ?? ''),
|
||||||
|
'port' => (int)($localConfig['smtp']['port'] ?? 587),
|
||||||
|
'username' => (string)($localConfig['smtp']['username'] ?? ''),
|
||||||
|
'password' => (string)($localConfig['smtp']['password'] ?? ''),
|
||||||
|
'encryption' => (string)($localConfig['smtp']['encryption'] ?? 'tls'),
|
||||||
|
'from_email' => (string)($localConfig['smtp']['from_email'] ?? ''),
|
||||||
|
'from_name' => (string)($localConfig['smtp']['from_name'] ?? $config['app_name']),
|
||||||
|
];
|
||||||
|
|
||||||
|
$dbConfig = [
|
||||||
|
'host' => (string)($localConfig['db']['host'] ?? (getenv('DB_HOST') ?: '')),
|
||||||
|
'name' => (string)($localConfig['db']['name'] ?? (getenv('DB_NAME') ?: '')),
|
||||||
|
'user' => (string)($localConfig['db']['user'] ?? (getenv('DB_USER') ?: '')),
|
||||||
|
'pass' => (string)($localConfig['db']['pass'] ?? (getenv('DB_PASS') ?: '')),
|
||||||
|
];
|
||||||
|
|
||||||
|
$pdo = null;
|
||||||
|
if ($dbConfig['host'] !== '' && $dbConfig['name'] !== '' && $dbConfig['user'] !== '') {
|
||||||
|
try {
|
||||||
|
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $dbConfig['host'], $dbConfig['name']);
|
||||||
|
$pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$pdo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(float $value): string
|
||||||
|
{
|
||||||
|
return number_format($value, 2, ',', '.') . ' EUR';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(string $value): string
|
||||||
|
{
|
||||||
|
$timestamp = strtotime($value);
|
||||||
|
if ($timestamp === false) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
return date('d.m.Y H:i', $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSimpleMarkdown(string $text): string
|
||||||
|
{
|
||||||
|
$escaped = htmlspecialchars(trim($text), ENT_QUOTES, 'UTF-8');
|
||||||
|
if ($escaped === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$escaped = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $escaped);
|
||||||
|
$escaped = preg_replace('/\*(.+?)\*/s', '<em>$1</em>', $escaped);
|
||||||
|
$blocks = preg_split('/\R{2,}/', $escaped) ?: [];
|
||||||
|
$html = [];
|
||||||
|
foreach ($blocks as $block) {
|
||||||
|
$block = trim($block);
|
||||||
|
if ($block === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^(?:- .*(?:\R|$))+$/', $block)) {
|
||||||
|
$items = preg_split('/\R/', $block) ?: [];
|
||||||
|
$listItems = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$item = trim($item);
|
||||||
|
if (str_starts_with($item, '- ')) {
|
||||||
|
$listItems[] = '<li>' . substr($item, 2) . '</li>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($listItems) {
|
||||||
|
$html[] = '<ul>' . implode('', $listItems) . '</ul>';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$html[] = '<p>' . nl2br($block) . '</p>';
|
||||||
|
}
|
||||||
|
return implode('', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appUrl(): string
|
||||||
|
{
|
||||||
|
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
return $scheme . '://' . $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendPasswordResetMail(array $smtpConfig, string $toEmail, string $resetUrl): bool
|
||||||
|
{
|
||||||
|
if (($smtpConfig['from_email'] ?? '') === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subject = 'Passwort zuruecksetzen';
|
||||||
|
$message = "Hallo,\n\n" .
|
||||||
|
"bitte nutzen Sie den folgenden Link, um Ihr Passwort zurueckzusetzen:\n\n" .
|
||||||
|
$resetUrl . "\n\n" .
|
||||||
|
"Der Link ist 60 Minuten gueltig.\n";
|
||||||
|
$headers = [
|
||||||
|
'From: ' . ($smtpConfig['from_name'] !== '' ? $smtpConfig['from_name'] . ' <' . $smtpConfig['from_email'] . '>' : $smtpConfig['from_email']),
|
||||||
|
'Content-Type: text/plain; charset=UTF-8',
|
||||||
|
];
|
||||||
|
|
||||||
|
return mail($toEmail, $subject, $message, implode("\r\n", $headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageTitle(string $page): string
|
||||||
|
{
|
||||||
|
return match ($page) {
|
||||||
|
'login' => 'Anmeldung',
|
||||||
|
'reset_password' => 'Passwort zuruecksetzen',
|
||||||
|
'profile' => 'Profil',
|
||||||
|
'members' => 'Mitglieder',
|
||||||
|
'booking' => 'Buchung',
|
||||||
|
'admin' => 'Vereinskonfiguration',
|
||||||
|
'administration' => 'Administration',
|
||||||
|
default => 'Dashboard',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUser(): ?array
|
||||||
|
{
|
||||||
|
return $_SESSION['user'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRole(array $user, array $roles): bool
|
||||||
|
{
|
||||||
|
return in_array($user['role'], $roles, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCreateMembers(array $user): bool
|
||||||
|
{
|
||||||
|
return hasRole($user, ['editor', 'admin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManageUsers(array $user): bool
|
||||||
|
{
|
||||||
|
return $user['role'] === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManageMembers(array $user): bool
|
||||||
|
{
|
||||||
|
return hasRole($user, ['editor', 'admin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(array $user): string
|
||||||
|
{
|
||||||
|
return trim(((string)($user['firstname'] ?? '')) . ' ' . ((string)($user['lastname'] ?? '')));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(string $role): string
|
||||||
|
{
|
||||||
|
return match ($role) {
|
||||||
|
'member' => 'Mitglied',
|
||||||
|
'editor' => 'Bearbeiter',
|
||||||
|
'admin' => 'Admin',
|
||||||
|
default => $role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInstalled(?PDO $pdo): bool
|
||||||
|
{
|
||||||
|
if (!$pdo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn() > 0;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSchema(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$pdo->exec('CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
firstname VARCHAR(100) NOT NULL,
|
||||||
|
lastname VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(190) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role ENUM("member", "editor", "admin") NOT NULL DEFAULT "member",
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||||||
|
|
||||||
|
$columns = $pdo->query('SHOW COLUMNS FROM users')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
if (!in_array('firstname', $columns, true)) {
|
||||||
|
$pdo->exec('ALTER TABLE users ADD COLUMN firstname VARCHAR(100) NOT NULL DEFAULT "" AFTER id');
|
||||||
|
}
|
||||||
|
if (!in_array('lastname', $columns, true)) {
|
||||||
|
$pdo->exec('ALTER TABLE users ADD COLUMN lastname VARCHAR(100) NOT NULL DEFAULT "" AFTER firstname');
|
||||||
|
}
|
||||||
|
if (in_array('name', $columns, true)) {
|
||||||
|
$stmt = $pdo->query('SELECT id, name, firstname, lastname FROM users');
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
if (trim((string)$row['firstname']) !== '' || trim((string)$row['lastname']) !== '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$parts = preg_split('/\s+/', trim((string)$row['name'])) ?: [];
|
||||||
|
$lastname = array_pop($parts) ?: '';
|
||||||
|
$firstname = trim(implode(' ', $parts));
|
||||||
|
$update = $pdo->prepare('UPDATE users SET firstname = ?, lastname = ? WHERE id = ?');
|
||||||
|
$update->execute([$firstname, $lastname, (int)$row['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec('CREATE TABLE IF NOT EXISTS work_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
member_id INT NOT NULL,
|
||||||
|
actor_id INT NOT NULL,
|
||||||
|
hours DECIMAL(5,2) NOT NULL,
|
||||||
|
note VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX (member_id),
|
||||||
|
INDEX (actor_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||||||
|
|
||||||
|
$pdo->exec('CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
setting_key VARCHAR(100) PRIMARY KEY,
|
||||||
|
setting_value TEXT NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||||||
|
|
||||||
|
$pdo->exec('CREATE TABLE IF NOT EXISTS password_resets (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX (user_id),
|
||||||
|
INDEX (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||||||
|
|
||||||
|
$pdo->exec('CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
actor_id INT NOT NULL,
|
||||||
|
action VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX (actor_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||||||
|
|
||||||
|
$settingColumns = $pdo->query('SHOW COLUMNS FROM settings')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
foreach ($settingColumns as $settingColumn) {
|
||||||
|
if (($settingColumn['Field'] ?? '') === 'setting_value' && stripos((string)($settingColumn['Type'] ?? ''), 'text') === false) {
|
||||||
|
$pdo->exec('ALTER TABLE settings MODIFY setting_value TEXT NOT NULL');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pdo) {
|
||||||
|
initSchema($pdo);
|
||||||
|
|
||||||
|
$hoursTarget = $pdo->query('SELECT setting_value FROM settings WHERE setting_key = "hours_target" LIMIT 1')->fetchColumn();
|
||||||
|
$hourlyRate = $pdo->query('SELECT setting_value FROM settings WHERE setting_key = "hourly_rate_eur" LIMIT 1')->fetchColumn();
|
||||||
|
$dashboardInfo = $pdo->query('SELECT setting_value FROM settings WHERE setting_key = "dashboard_info" LIMIT 1')->fetchColumn();
|
||||||
|
|
||||||
|
if ($hoursTarget !== false) {
|
||||||
|
$config['hours_target'] = (int)$hoursTarget;
|
||||||
|
}
|
||||||
|
if ($hourlyRate !== false) {
|
||||||
|
$config['hourly_rate_eur'] = (float)$hourlyRate;
|
||||||
|
}
|
||||||
|
if ($dashboardInfo !== false) {
|
||||||
|
$config['dashboard_info'] = (string)$dashboardInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchUsers(?PDO $pdo): array
|
||||||
|
{
|
||||||
|
if (!$pdo) {
|
||||||
|
return [
|
||||||
|
['id' => 1, 'firstname' => 'Max', 'lastname' => 'Mustermann', 'name' => 'Max Mustermann', 'email' => 'max@example.com', 'role' => 'admin', 'hours_worked' => 8.5],
|
||||||
|
['id' => 2, 'firstname' => 'Lisa', 'lastname' => 'Beispiel', 'name' => 'Lisa Beispiel', 'email' => 'lisa@example.com', 'role' => 'editor', 'hours_worked' => 12.0],
|
||||||
|
['id' => 3, 'firstname' => 'Tom', 'lastname' => 'Mitglied', 'name' => 'Tom Mitglied', 'email' => 'tom@example.com', 'role' => 'member', 'hours_worked' => 6.0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pdo->query('SELECT u.id, u.firstname, u.lastname, CONCAT(u.firstname, " ", u.lastname) AS name, u.email, u.role, COALESCE(SUM(w.hours), 0) AS hours_worked FROM users u LEFT JOIN work_logs w ON w.member_id = u.id GROUP BY u.id, u.firstname, u.lastname, u.email, u.role ORDER BY u.lastname, u.firstname')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchUsersPage(?PDO $pdo, int $page = 1, int $perPage = 50): array
|
||||||
|
{
|
||||||
|
$page = max(1, $page);
|
||||||
|
$perPage = max(1, $perPage);
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
if (!$pdo) {
|
||||||
|
$rows = fetchUsers($pdo);
|
||||||
|
return [
|
||||||
|
'entries' => array_slice($rows, $offset, $perPage),
|
||||||
|
'total' => count($rows),
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total_pages' => max(1, (int)ceil(count($rows) / $perPage)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||||
|
$stmt = $pdo->prepare('SELECT u.id, u.firstname, u.lastname, CONCAT(u.firstname, " ", u.lastname) AS name, u.email, u.role, COALESCE(SUM(w.hours), 0) AS hours_worked FROM users u LEFT JOIN work_logs w ON w.member_id = u.id GROUP BY u.id, u.firstname, u.lastname, u.email, u.role ORDER BY u.lastname, u.firstname LIMIT ? OFFSET ?');
|
||||||
|
$stmt->bindValue(1, $perPage, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(2, $offset, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'entries' => $stmt->fetchAll(PDO::FETCH_ASSOC),
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total_pages' => max(1, (int)ceil($total / $perPage)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchLogs(?PDO $pdo, int $page = 1, int $perPage = 50): array
|
||||||
|
{
|
||||||
|
$page = max(1, $page);
|
||||||
|
$perPage = max(1, $perPage);
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
if (!$pdo) {
|
||||||
|
$rows = [
|
||||||
|
['ts' => '2026-05-19 14:00', 'actor' => 'Admin', 'action' => 'Lisa Beispiel als Bearbeiter angelegt'],
|
||||||
|
['ts' => '2026-05-18 09:30', 'actor' => 'Lisa Beispiel', 'action' => '2.0 Stunden für Tom Mitglied gebucht'],
|
||||||
|
['ts' => '2026-05-17 18:15', 'actor' => 'Admin', 'action' => 'Stundensatz auf 10 EUR gesetzt'],
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
'entries' => array_slice($rows, $offset, $perPage),
|
||||||
|
'total' => count($rows),
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total_pages' => max(1, (int)ceil(count($rows) / $perPage)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$countSql = 'SELECT COUNT(*) FROM ((SELECT w.id FROM work_logs w) UNION ALL (SELECT l.id FROM audit_logs l)) combined_logs';
|
||||||
|
$total = (int)$pdo->query($countSql)->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('(SELECT w.created_at AS ts, CONCAT(a.firstname, " ", a.lastname) AS actor, CONCAT(w.hours, " Stunden für ", m.firstname, " ", m.lastname, " gebucht: ", w.note) AS action FROM work_logs w JOIN users a ON a.id = w.actor_id JOIN users m ON m.id = w.member_id) UNION ALL (SELECT l.created_at AS ts, CONCAT(a.firstname, " ", a.lastname) AS actor, l.action FROM audit_logs l JOIN users a ON a.id = l.actor_id) ORDER BY ts DESC LIMIT ? OFFSET ?');
|
||||||
|
$stmt->bindValue(1, $perPage, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(2, $offset, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'entries' => $stmt->fetchAll(PDO::FETCH_ASSOC),
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total_pages' => max(1, (int)ceil($total / $perPage)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAuditLog(?PDO $pdo, int $actorId, string $action): void
|
||||||
|
{
|
||||||
|
if (!$pdo || $actorId <= 0 || trim($action) === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO audit_logs (actor_id, action) VALUES (?, ?)');
|
||||||
|
$stmt->execute([$actorId, $action]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqlValue(PDO $pdo, mixed $value): string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return 'NULL';
|
||||||
|
}
|
||||||
|
return $pdo->quote((string)$value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchBookings(?PDO $pdo, array $user): array
|
||||||
|
{
|
||||||
|
if (!$pdo) {
|
||||||
|
$rows = [
|
||||||
|
['id' => 1, 'ts' => '2026-05-19 14:00', 'member_id' => 3, 'member' => 'Tom Mitglied', 'actor' => 'Lisa Beispiel', 'hours' => 2.0, 'note' => 'Fruehjahrsputz'],
|
||||||
|
['id' => 2, 'ts' => '2026-05-18 09:30', 'member_id' => 2, 'member' => 'Lisa Beispiel', 'actor' => 'Max Mustermann', 'hours' => 1.5, 'note' => 'Platzpflege'],
|
||||||
|
['id' => 3, 'ts' => '2026-05-17 18:15', 'member_id' => 3, 'member' => 'Tom Mitglied', 'actor' => 'Max Mustermann', 'hours' => 2.5, 'note' => 'Turnierhilfe'],
|
||||||
|
];
|
||||||
|
if (($user['role'] ?? '') === 'member') {
|
||||||
|
return array_values(array_filter($rows, static fn (array $row): bool => (int)$row['member_id'] === (int)($user['id'] ?? 0)));
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($user['role'] ?? '') === 'member') {
|
||||||
|
$stmt = $pdo->prepare('SELECT w.id, w.created_at AS ts, w.member_id, CONCAT(m.firstname, " ", m.lastname) AS member, CONCAT(a.firstname, " ", a.lastname) AS actor, w.hours, w.note FROM work_logs w JOIN users a ON a.id = w.actor_id JOIN users m ON m.id = w.member_id WHERE w.member_id = ? ORDER BY w.created_at DESC');
|
||||||
|
$stmt->execute([(int)($user['id'] ?? 0)]);
|
||||||
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pdo->query('SELECT w.id, w.created_at AS ts, w.member_id, CONCAT(m.firstname, " ", m.lastname) AS member, CONCAT(a.firstname, " ", a.lastname) AS actor, w.hours, w.note FROM work_logs w JOIN users a ON a.id = w.actor_id JOIN users m ON m.id = w.member_id ORDER BY w.created_at DESC')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchBookingsPage(?PDO $pdo, array $user, int $page = 1, int $perPage = 50): array
|
||||||
|
{
|
||||||
|
$page = max(1, $page);
|
||||||
|
$perPage = max(1, $perPage);
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
if (!$pdo) {
|
||||||
|
$rows = fetchBookings($pdo, $user);
|
||||||
|
return [
|
||||||
|
'entries' => array_slice($rows, $offset, $perPage),
|
||||||
|
'total' => count($rows),
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total_pages' => max(1, (int)ceil(count($rows) / $perPage)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($user['role'] ?? '') === 'member') {
|
||||||
|
$countStmt = $pdo->prepare('SELECT COUNT(*) FROM work_logs WHERE member_id = ?');
|
||||||
|
$countStmt->execute([(int)($user['id'] ?? 0)]);
|
||||||
|
$total = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT w.id, w.created_at AS ts, w.member_id, CONCAT(m.firstname, " ", m.lastname) AS member, CONCAT(a.firstname, " ", a.lastname) AS actor, w.hours, w.note FROM work_logs w JOIN users a ON a.id = w.actor_id JOIN users m ON m.id = w.member_id WHERE w.member_id = ? ORDER BY w.created_at DESC LIMIT ? OFFSET ?');
|
||||||
|
$stmt->bindValue(1, (int)($user['id'] ?? 0), PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(2, $perPage, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(3, $offset, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
} else {
|
||||||
|
$total = (int)$pdo->query('SELECT COUNT(*) FROM work_logs')->fetchColumn();
|
||||||
|
$stmt = $pdo->prepare('SELECT w.id, w.created_at AS ts, w.member_id, CONCAT(m.firstname, " ", m.lastname) AS member, CONCAT(a.firstname, " ", a.lastname) AS actor, w.hours, w.note FROM work_logs w JOIN users a ON a.id = w.actor_id JOIN users m ON m.id = w.member_id ORDER BY w.created_at DESC LIMIT ? OFFSET ?');
|
||||||
|
$stmt->bindValue(1, $perPage, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(2, $offset, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'entries' => $stmt->fetchAll(PDO::FETCH_ASSOC),
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total_pages' => max(1, (int)ceil($total / $perPage)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUserByEmail(?PDO $pdo, string $email): ?array
|
||||||
|
{
|
||||||
|
if (!$pdo) {
|
||||||
|
$demoUsers = [
|
||||||
|
['id' => 1, 'firstname' => 'Max', 'lastname' => 'Mustermann', 'name' => 'Max Mustermann', 'email' => 'max@example.com', 'password_hash' => password_hash('admin123', PASSWORD_DEFAULT), 'role' => 'admin'],
|
||||||
|
['id' => 2, 'firstname' => 'Lisa', 'lastname' => 'Beispiel', 'name' => 'Lisa Beispiel', 'email' => 'lisa@example.com', 'password_hash' => password_hash('editor123', PASSWORD_DEFAULT), 'role' => 'editor'],
|
||||||
|
['id' => 3, 'firstname' => 'Tom', 'lastname' => 'Mitglied', 'name' => 'Tom Mitglied', 'email' => 'tom@example.com', 'password_hash' => password_hash('member123', PASSWORD_DEFAULT), 'role' => 'member'],
|
||||||
|
];
|
||||||
|
foreach ($demoUsers as $demoUser) {
|
||||||
|
if (strcasecmp($demoUser['email'], $email) === 0) {
|
||||||
|
return $demoUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ? LIMIT 1');
|
||||||
|
$stmt->execute([$email]);
|
||||||
|
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
return $user ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = null;
|
||||||
|
$notice = null;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
$user = currentUser();
|
||||||
|
|
||||||
|
if ($action === 'login') {
|
||||||
|
$found = findUserByEmail($pdo, trim((string)($_POST['email'] ?? '')));
|
||||||
|
if ($found && password_verify((string)($_POST['password'] ?? ''), $found['password_hash'])) {
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$_SESSION['user'] = ['id' => (int)$found['id'], 'firstname' => $found['firstname'] ?? '', 'lastname' => $found['lastname'] ?? '', 'name' => displayName($found), 'email' => $found['email'], 'role' => $found['role']];
|
||||||
|
header('Location: /?page=dashboard');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$error = 'Login fehlgeschlagen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'request_password_reset') {
|
||||||
|
$email = trim((string)($_POST['email'] ?? ''));
|
||||||
|
if (!$pdo) {
|
||||||
|
$error = 'Passwort-Reset ist ohne Datenbank nicht verfuegbar.';
|
||||||
|
} elseif (($smtpConfig['from_email'] ?? '') === '') {
|
||||||
|
$error = 'SMTP/Absender ist noch nicht konfiguriert.';
|
||||||
|
} else {
|
||||||
|
$found = findUserByEmail($pdo, $email);
|
||||||
|
if ($found) {
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$tokenHash = password_hash($token, PASSWORD_DEFAULT);
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', time() + 3600);
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM password_resets WHERE user_id = ?');
|
||||||
|
$stmt->execute([(int)$found['id']]);
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES (?, ?, ?)');
|
||||||
|
$stmt->execute([(int)$found['id'], $tokenHash, $expiresAt]);
|
||||||
|
$resetUrl = appUrl() . '/?page=reset_password&token=' . urlencode($token);
|
||||||
|
sendPasswordResetMail($smtpConfig, (string)$found['email'], $resetUrl);
|
||||||
|
}
|
||||||
|
$notice = 'Wenn die E-Mail-Adresse vorhanden ist, wurde ein Link zum Zuruecksetzen versendet.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'reset_password') {
|
||||||
|
$token = trim((string)($_POST['token'] ?? ''));
|
||||||
|
$newPassword = (string)($_POST['new_password'] ?? '');
|
||||||
|
$confirmPassword = (string)($_POST['confirm_password'] ?? '');
|
||||||
|
if (!$pdo) {
|
||||||
|
$error = 'Passwort-Reset ist ohne Datenbank nicht verfuegbar.';
|
||||||
|
} elseif ($newPassword === '' || strlen($newPassword) < 6) {
|
||||||
|
$error = 'Das neue Passwort muss mindestens 6 Zeichen lang sein.';
|
||||||
|
} elseif ($newPassword !== $confirmPassword) {
|
||||||
|
$error = 'Die neuen Passwoerter stimmen nicht ueberein.';
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->query('SELECT id, user_id, token_hash, expires_at FROM password_resets ORDER BY id DESC');
|
||||||
|
$resetRow = null;
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $candidate) {
|
||||||
|
if (strtotime((string)$candidate['expires_at']) < time()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (password_verify($token, (string)$candidate['token_hash'])) {
|
||||||
|
$resetRow = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$resetRow) {
|
||||||
|
$error = 'Der Reset-Link ist ungueltig oder abgelaufen.';
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
|
||||||
|
$stmt->execute([password_hash($newPassword, PASSWORD_DEFAULT), (int)$resetRow['user_id']]);
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM password_resets WHERE user_id = ?');
|
||||||
|
$stmt->execute([(int)$resetRow['user_id']]);
|
||||||
|
addAuditLog($pdo, (int)$resetRow['user_id'], 'Passwort über Reset-Link neu gesetzt.');
|
||||||
|
$_SESSION['login_notice'] = 'Passwort erfolgreich zurueckgesetzt. Bitte anmelden.';
|
||||||
|
header('Location: /?page=login');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'create_member' && canCreateMembers($user) && $pdo) {
|
||||||
|
$firstname = trim((string)$_POST['firstname']);
|
||||||
|
$lastname = trim((string)$_POST['lastname']);
|
||||||
|
$email = trim((string)$_POST['email']);
|
||||||
|
$role = trim((string)($_POST['role'] ?? 'member'));
|
||||||
|
if (!in_array($role, ['member', 'editor', 'admin'], true)) {
|
||||||
|
$role = 'member';
|
||||||
|
}
|
||||||
|
if (!canManageUsers($user)) {
|
||||||
|
$role = 'member';
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO users (firstname, lastname, email, password_hash, role) VALUES (?, ?, ?, ?, ?)');
|
||||||
|
$stmt->execute([$firstname, $lastname, $email, password_hash('mitglied123', PASSWORD_DEFAULT), $role]);
|
||||||
|
addAuditLog($pdo, (int)$user['id'], trim($firstname . ' ' . $lastname) . ' (' . $email . ') als ' . roleLabel($role) . ' angelegt.');
|
||||||
|
$notice = $role === 'admin' ? 'Benutzer als Admin angelegt.' : ($role === 'editor' ? 'Benutzer als Bearbeiter angelegt.' : 'Mitglied angelegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'update_user_role' && canManageUsers($user) && $pdo) {
|
||||||
|
$userId = (int)($_POST['user_id'] ?? 0);
|
||||||
|
$role = trim((string)($_POST['role'] ?? 'member'));
|
||||||
|
if (in_array($role, ['member', 'editor', 'admin'], true)) {
|
||||||
|
$stmt = $pdo->prepare('SELECT firstname, lastname FROM users WHERE id = ? LIMIT 1');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$targetUser = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||||
|
$stmt = $pdo->prepare('UPDATE users SET role = ? WHERE id = ?');
|
||||||
|
$stmt->execute([$role, $userId]);
|
||||||
|
if ($targetUser) {
|
||||||
|
addAuditLog($pdo, (int)$user['id'], trim(displayName($targetUser)) . ' Rolle auf ' . roleLabel($role) . ' gesetzt.');
|
||||||
|
}
|
||||||
|
$notice = 'Rolle gespeichert.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'reset_user_password' && canManageMembers($user) && $pdo) {
|
||||||
|
$userId = (int)($_POST['user_id'] ?? 0);
|
||||||
|
$stmt = $pdo->prepare('SELECT firstname, lastname FROM users WHERE id = ? LIMIT 1');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$targetUser = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||||
|
$stmt = $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ? AND role <> "admin"');
|
||||||
|
$stmt->execute([password_hash('mitglied123', PASSWORD_DEFAULT), $userId]);
|
||||||
|
$targetName = $targetUser ? displayName($targetUser) : 'Der Benutzer';
|
||||||
|
addAuditLog($pdo, (int)$user['id'], trim($targetName) . ' Passwort zurückgesetzt.');
|
||||||
|
$notice = trim($targetName) . ' wurde auf das Passwort mitglied123 zurueckgesetzt.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'delete_user' && canManageMembers($user) && $pdo) {
|
||||||
|
$userId = (int)($_POST['user_id'] ?? 0);
|
||||||
|
$stmt = $pdo->prepare('SELECT firstname, lastname, email FROM users WHERE id = ? LIMIT 1');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$targetUser = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM work_logs WHERE member_id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM users WHERE id = ? AND role <> "admin"');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
if ($targetUser) {
|
||||||
|
addAuditLog($pdo, (int)$user['id'], trim(displayName($targetUser)) . ' (' . (string)$targetUser['email'] . ') gelöscht.');
|
||||||
|
}
|
||||||
|
$notice = 'Benutzer geloescht.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'book_hours' && hasRole($user, ['editor', 'admin']) && $pdo) {
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO work_logs (member_id, actor_id, hours, note) VALUES (?, ?, ?, ?)');
|
||||||
|
$stmt->execute([(int)$_POST['member_id'], (int)$user['id'], (float)$_POST['hours'], trim((string)$_POST['note'])]);
|
||||||
|
$notice = 'Stunden gebucht.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'delete_booking' && ($user['role'] ?? '') === 'admin' && $pdo) {
|
||||||
|
$bookingId = (int)($_POST['booking_id'] ?? 0);
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM work_logs WHERE id = ?');
|
||||||
|
$stmt->execute([$bookingId]);
|
||||||
|
$notice = 'Stundenbuchung geloescht.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'update_profile') {
|
||||||
|
$firstname = trim((string)($_POST['firstname'] ?? ''));
|
||||||
|
$lastname = trim((string)($_POST['lastname'] ?? ''));
|
||||||
|
|
||||||
|
if ($firstname === '' || $lastname === '') {
|
||||||
|
$error = 'Vorname und Nachname sind erforderlich.';
|
||||||
|
} elseif ($pdo) {
|
||||||
|
$stmt = $pdo->prepare('UPDATE users SET firstname = ?, lastname = ? WHERE id = ?');
|
||||||
|
$stmt->execute([$firstname, $lastname, (int)$user['id']]);
|
||||||
|
$_SESSION['user']['firstname'] = $firstname;
|
||||||
|
$_SESSION['user']['lastname'] = $lastname;
|
||||||
|
$_SESSION['user']['name'] = trim($firstname . ' ' . $lastname);
|
||||||
|
$notice = 'Profil gespeichert.';
|
||||||
|
} else {
|
||||||
|
$_SESSION['user']['firstname'] = $firstname;
|
||||||
|
$_SESSION['user']['lastname'] = $lastname;
|
||||||
|
$_SESSION['user']['name'] = trim($firstname . ' ' . $lastname);
|
||||||
|
$notice = 'Profil im Demo-Modus nur fuer diese Sitzung aktualisiert.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'change_password') {
|
||||||
|
$currentPassword = (string)($_POST['current_password'] ?? '');
|
||||||
|
$newPassword = (string)($_POST['new_password'] ?? '');
|
||||||
|
$confirmPassword = (string)($_POST['confirm_password'] ?? '');
|
||||||
|
|
||||||
|
if ($newPassword === '' || strlen($newPassword) < 6) {
|
||||||
|
$error = 'Das neue Passwort muss mindestens 6 Zeichen lang sein.';
|
||||||
|
} elseif ($newPassword !== $confirmPassword) {
|
||||||
|
$error = 'Die neuen Passwoerter stimmen nicht ueberein.';
|
||||||
|
} elseif ($pdo) {
|
||||||
|
$stmt = $pdo->prepare('SELECT password_hash FROM users WHERE id = ? LIMIT 1');
|
||||||
|
$stmt->execute([(int)$user['id']]);
|
||||||
|
$passwordHash = $stmt->fetchColumn();
|
||||||
|
if (!$passwordHash || !password_verify($currentPassword, (string)$passwordHash)) {
|
||||||
|
$error = 'Das aktuelle Passwort ist nicht korrekt.';
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
|
||||||
|
$stmt->execute([password_hash($newPassword, PASSWORD_DEFAULT), (int)$user['id']]);
|
||||||
|
addAuditLog($pdo, (int)$user['id'], 'Eigenes Passwort geändert.');
|
||||||
|
$notice = 'Passwort geaendert.';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$demoUser = findUserByEmail($pdo, (string)$user['email']);
|
||||||
|
if (!$demoUser || !password_verify($currentPassword, (string)$demoUser['password_hash'])) {
|
||||||
|
$error = 'Das aktuelle Passwort ist nicht korrekt.';
|
||||||
|
} else {
|
||||||
|
$notice = 'Im Demo-Modus kann das Passwort nicht dauerhaft gespeichert werden.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'update_settings' && hasRole($user, ['editor', 'admin'])) {
|
||||||
|
$previousHoursTarget = (int)$config['hours_target'];
|
||||||
|
$previousHourlyRate = (float)$config['hourly_rate_eur'];
|
||||||
|
$previousDashboardInfo = trim((string)$config['dashboard_info']);
|
||||||
|
$hoursTarget = max(0, (int)($_POST['hours_target'] ?? $config['hours_target']));
|
||||||
|
$hourlyRate = max(0, (float)($_POST['hourly_rate_eur'] ?? $config['hourly_rate_eur']));
|
||||||
|
$dashboardInfo = trim((string)($_POST['dashboard_info'] ?? $config['dashboard_info']));
|
||||||
|
if ($pdo) {
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)');
|
||||||
|
$stmt->execute(['hours_target', (string)$hoursTarget]);
|
||||||
|
$stmt->execute(['hourly_rate_eur', (string)$hourlyRate]);
|
||||||
|
$stmt->execute(['dashboard_info', $dashboardInfo]);
|
||||||
|
$config['hours_target'] = $hoursTarget;
|
||||||
|
$config['hourly_rate_eur'] = $hourlyRate;
|
||||||
|
$config['dashboard_info'] = $dashboardInfo;
|
||||||
|
if ($previousHoursTarget !== $hoursTarget || abs($previousHourlyRate - $hourlyRate) > 0.0001) {
|
||||||
|
addAuditLog($pdo, (int)$user['id'], 'Vereinswerte gespeichert. Pflichtstunden: ' . $hoursTarget . ', Stundenwert: ' . number_format($hourlyRate, 2, ',', '.') . ' EUR.');
|
||||||
|
}
|
||||||
|
if ($previousDashboardInfo !== $dashboardInfo) {
|
||||||
|
addAuditLog($pdo, (int)$user['id'], 'Informationskasten im Dashboard aktualisiert.');
|
||||||
|
}
|
||||||
|
$notice = 'Vereinswerte gespeichert.';
|
||||||
|
} else {
|
||||||
|
$error = 'Vereinswerte koennen ohne Datenbank nicht gespeichert werden.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'reset_all_hours' && $user['role'] === 'admin') {
|
||||||
|
if ($pdo) {
|
||||||
|
$pdo->exec('DELETE FROM work_logs');
|
||||||
|
addAuditLog($pdo, (int)$user['id'], 'Alle Arbeitsstunden auf 0 zurückgesetzt.');
|
||||||
|
$notice = 'Alle Arbeitsstunden wurden auf 0 zurueckgesetzt.';
|
||||||
|
} else {
|
||||||
|
$error = 'Arbeitsstunden koennen ohne Datenbank nicht zurueckgesetzt werden.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'export_csv' && hasRole($user, ['editor', 'admin'])) {
|
||||||
|
if ($pdo) {
|
||||||
|
addAuditLog($pdo, (int)$user['id'], 'Benutzerdaten als CSV exportiert.');
|
||||||
|
$filename = 'arbeitsstunden-export-' . date('Y-m-d-His') . '.csv';
|
||||||
|
header('Content-Type: text/csv; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
$output = fopen('php://output', 'wb');
|
||||||
|
if ($output !== false) {
|
||||||
|
fwrite($output, "\xEF\xBB\xBF");
|
||||||
|
$stmt = $pdo->query('SELECT u.id, u.firstname, u.lastname, u.email, u.role, COALESCE(SUM(w.hours), 0) AS hours_worked, u.created_at FROM users u LEFT JOIN work_logs w ON w.member_id = u.id GROUP BY u.id, u.firstname, u.lastname, u.email, u.role, u.created_at ORDER BY u.lastname, u.firstname');
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
if ($rows) {
|
||||||
|
fputcsv($output, array_keys($rows[0]));
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
fputcsv($output, $row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fclose($output);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$error = 'CSV-Export ist ohne Datenbank nicht verfuegbar.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'export_bookings_csv' && hasRole($user, ['editor', 'admin'])) {
|
||||||
|
if ($pdo) {
|
||||||
|
addAuditLog($pdo, (int)$user['id'], 'Stundenbuchungen als CSV exportiert.');
|
||||||
|
$filename = 'stundenbuchungen-export-' . date('Y-m-d-His') . '.csv';
|
||||||
|
header('Content-Type: text/csv; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
$output = fopen('php://output', 'wb');
|
||||||
|
if ($output !== false) {
|
||||||
|
fwrite($output, "\xEF\xBB\xBF");
|
||||||
|
$stmt = $pdo->query('SELECT w.id, CONCAT(m.lastname, ", ", m.firstname) AS member, CONCAT(a.lastname, ", ", a.firstname) AS actor, w.hours, w.note, w.created_at FROM work_logs w JOIN users a ON a.id = w.actor_id JOIN users m ON m.id = w.member_id ORDER BY w.created_at DESC');
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
if ($rows) {
|
||||||
|
fputcsv($output, array_keys($rows[0]));
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
fputcsv($output, $row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fclose($output);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$error = 'CSV-Export ist ohne Datenbank nicht verfuegbar.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'export_sql_dump' && $user['role'] === 'admin') {
|
||||||
|
if ($pdo) {
|
||||||
|
addAuditLog($pdo, (int)$user['id'], 'SQL-Dump exportiert.');
|
||||||
|
$filename = 'arbeitsstunden-backup-' . date('Y-m-d-His') . '.sql';
|
||||||
|
header('Content-Type: application/sql; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
$output = fopen('php://output', 'wb');
|
||||||
|
if ($output !== false) {
|
||||||
|
fwrite($output, "-- Arbeitsstunden SQL Dump\n");
|
||||||
|
fwrite($output, '-- Erstellt am ' . date('Y-m-d H:i:s') . "\n\n");
|
||||||
|
$tables = ['users', 'work_logs', 'settings', 'audit_logs', 'password_resets'];
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$createStmt = $pdo->query('SHOW CREATE TABLE ' . $table)->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$createSql = $createStmt['Create Table'] ?? '';
|
||||||
|
fwrite($output, 'DROP TABLE IF EXISTS `' . $table . '`;' . "\n");
|
||||||
|
fwrite($output, $createSql . ';' . "\n\n");
|
||||||
|
$rows = $pdo->query('SELECT * FROM ' . $table)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$columns = array_map(static fn (string $column): string => '`' . $column . '`', array_keys($row));
|
||||||
|
$values = array_map(static fn ($value): string => sqlValue($pdo, $value), array_values($row));
|
||||||
|
fwrite($output, 'INSERT INTO `' . $table . '` (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $values) . ');' . "\n");
|
||||||
|
}
|
||||||
|
fwrite($output, "\n");
|
||||||
|
}
|
||||||
|
fclose($output);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$error = 'SQL-Dump ist ohne Datenbank nicht verfuegbar.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'import_sql_dump' && $user['role'] === 'admin') {
|
||||||
|
if (!$pdo) {
|
||||||
|
$error = 'SQL-Wiederherstellung ist ohne Datenbank nicht verfuegbar.';
|
||||||
|
} elseif (!isset($_FILES['sql_file']) || (int)($_FILES['sql_file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
|
$error = 'Bitte eine gueltige SQL-Datei auswaehlen.';
|
||||||
|
} else {
|
||||||
|
$sql = file_get_contents((string)$_FILES['sql_file']['tmp_name']);
|
||||||
|
if ($sql === false || trim($sql) === '') {
|
||||||
|
$error = 'Die SQL-Datei konnte nicht gelesen werden.';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$pdo->exec('SET FOREIGN_KEY_CHECKS=0');
|
||||||
|
$statements = preg_split('/;\s*(?:\R|$)/', $sql) ?: [];
|
||||||
|
foreach ($statements as $statement) {
|
||||||
|
$statement = trim($statement);
|
||||||
|
if ($statement === '' || str_starts_with($statement, '--')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$pdo->exec($statement);
|
||||||
|
}
|
||||||
|
$pdo->exec('SET FOREIGN_KEY_CHECKS=1');
|
||||||
|
$pdo->commit();
|
||||||
|
addAuditLog($pdo, (int)$user['id'], 'SQL-Dump importiert und Datenbank wiederhergestellt.');
|
||||||
|
$notice = 'SQL-Dump erfolgreich importiert.';
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
$pdo->exec('SET FOREIGN_KEY_CHECKS=1');
|
||||||
|
$error = 'Die SQL-Datei konnte nicht importiert werden.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'import_csv' && $user['role'] === 'admin') {
|
||||||
|
if (!$pdo) {
|
||||||
|
$error = 'CSV-Import ist ohne Datenbank nicht verfuegbar.';
|
||||||
|
} elseif (!isset($_FILES['csv_file']) || (int)($_FILES['csv_file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
|
$error = 'Bitte eine gueltige CSV-Datei auswaehlen.';
|
||||||
|
} else {
|
||||||
|
$handle = fopen((string)$_FILES['csv_file']['tmp_name'], 'rb');
|
||||||
|
if ($handle === false) {
|
||||||
|
$error = 'Die CSV-Datei konnte nicht gelesen werden.';
|
||||||
|
} else {
|
||||||
|
$header = fgetcsv($handle);
|
||||||
|
$requiredColumns = ['firstname', 'lastname', 'email', 'role', 'hours_worked'];
|
||||||
|
if (!$header || array_diff($requiredColumns, $header)) {
|
||||||
|
$error = 'Die CSV-Datei hat nicht das erwartete Format.';
|
||||||
|
} else {
|
||||||
|
$columns = array_flip($header);
|
||||||
|
$created = 0;
|
||||||
|
$updated = 0;
|
||||||
|
while (($row = fgetcsv($handle)) !== false) {
|
||||||
|
$firstname = trim((string)($row[$columns['firstname']] ?? ''));
|
||||||
|
$lastname = trim((string)($row[$columns['lastname']] ?? ''));
|
||||||
|
$email = trim((string)($row[$columns['email']] ?? ''));
|
||||||
|
$role = trim((string)($row[$columns['role']] ?? 'member'));
|
||||||
|
$hoursWorked = (float)str_replace(',', '.', trim((string)($row[$columns['hours_worked']] ?? '0')));
|
||||||
|
if ($firstname === '' || $lastname === '' || $email === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!in_array($role, ['member', 'editor', 'admin'], true)) {
|
||||||
|
$role = 'member';
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ? LIMIT 1');
|
||||||
|
$stmt->execute([$email]);
|
||||||
|
$existingId = (int)($stmt->fetchColumn() ?: 0);
|
||||||
|
|
||||||
|
if ($existingId > 0) {
|
||||||
|
$stmt = $pdo->prepare('UPDATE users SET firstname = ?, lastname = ?, role = ? WHERE id = ?');
|
||||||
|
$stmt->execute([$firstname, $lastname, $role, $existingId]);
|
||||||
|
$userId = $existingId;
|
||||||
|
$updated++;
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO users (firstname, lastname, email, password_hash, role) VALUES (?, ?, ?, ?, ?)');
|
||||||
|
$stmt->execute([$firstname, $lastname, $email, password_hash('mitglied123', PASSWORD_DEFAULT), $role]);
|
||||||
|
$userId = (int)$pdo->lastInsertId();
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM work_logs WHERE member_id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
if ($hoursWorked > 0) {
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO work_logs (member_id, actor_id, hours, note) VALUES (?, ?, ?, ?)');
|
||||||
|
$stmt->execute([$userId, (int)$user['id'], $hoursWorked, 'CSV-Import']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addAuditLog($pdo, (int)$user['id'], $created . ' Benutzer importiert, ' . $updated . ' Benutzer per CSV aktualisiert.');
|
||||||
|
$notice = $created . ' Benutzer importiert, ' . $updated . ' Benutzer aktualisiert.';
|
||||||
|
}
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user && $action === 'logout') {
|
||||||
|
session_destroy();
|
||||||
|
header('Location: /?page=login');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
689
httpdocs/app/views.php
Normal file
689
httpdocs/app/views.php
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function renderHeader(string $title): void
|
||||||
|
{
|
||||||
|
global $config;
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= htmlspecialchars($config['app_name'] . ' - ' . $title) ?></title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/css/tabler.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--tci-blue: #206bc4;
|
||||||
|
--tci-blue-2: #1a5aa5;
|
||||||
|
--tci-blue-3: #5b9def;
|
||||||
|
--tci-sand: #f6f8fb;
|
||||||
|
--tci-cream: #ffffff;
|
||||||
|
--tci-border: #dce1e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #f5f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-wrapper .navbar {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 1px solid #e6ebf1;
|
||||||
|
min-height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 114px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-mark {
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-mark img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .navbar-brand,
|
||||||
|
.navbar .nav-link,
|
||||||
|
.navbar .text-secondary,
|
||||||
|
.navbar .btn {
|
||||||
|
color: #182433 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.05;
|
||||||
|
margin-left: 18px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand-text span:last-child {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .nav-link.active {
|
||||||
|
background: rgba(32, 107, 196, 0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-color: var(--tci-border);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.card-md {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header.bg-primary-subtle {
|
||||||
|
background: linear-gradient(90deg, rgba(15, 76, 129, 0.12), rgba(45, 110, 163, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-success {
|
||||||
|
background: var(--tci-blue);
|
||||||
|
border-color: var(--tci-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover,
|
||||||
|
.btn-success:hover {
|
||||||
|
background: var(--tci-blue-2);
|
||||||
|
border-color: var(--tci-blue-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-secondary {
|
||||||
|
color: #182433;
|
||||||
|
border-color: #cdd6e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main .btn-outline-secondary {
|
||||||
|
color: var(--tci-blue);
|
||||||
|
border-color: rgba(15, 76, 129, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main .btn-outline-secondary:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--tci-blue);
|
||||||
|
border-color: var(--tci-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main .btn-outline-primary {
|
||||||
|
color: var(--tci-blue);
|
||||||
|
border-color: rgba(15, 76, 129, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main .btn-outline-primary:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--tci-blue);
|
||||||
|
border-color: var(--tci-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main .btn-outline-danger {
|
||||||
|
color: #b42318;
|
||||||
|
border-color: rgba(180, 35, 24, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main .btn-outline-danger:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: #b42318;
|
||||||
|
border-color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .btn {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.bg-blue-lt,
|
||||||
|
.text-blue {
|
||||||
|
background: rgba(32, 107, 196, 0.1) !important;
|
||||||
|
color: var(--tci-blue) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
background: linear-gradient(90deg, var(--tci-blue), var(--tci-blue-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: var(--tci-blue) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hero {
|
||||||
|
border: 1px solid var(--tci-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f7f9fc 100%);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hero .section-title,
|
||||||
|
.login-hero .text-secondary {
|
||||||
|
color: #182433 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--tci-blue);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title i {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 250px;
|
||||||
|
background: #f5f7fb;
|
||||||
|
border-right: 1px solid #e6ebf1;
|
||||||
|
padding: 18px 14px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #667382;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #182433;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a i {
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a:hover {
|
||||||
|
background: rgba(32, 107, 196, 0.06);
|
||||||
|
transform: translateX(2px);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a.active {
|
||||||
|
background: rgba(32, 107, 196, 0.1);
|
||||||
|
color: var(--tci-blue);
|
||||||
|
font-weight: 700;
|
||||||
|
border-color: rgba(32, 107, 196, 0.12);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--tci-border);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #667382;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .app-footer {
|
||||||
|
border-top-color: rgba(148, 163, 184, 0.16);
|
||||||
|
color: #9fb0c3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .stat-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: rgba(15, 76, 129, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .stat-card .stat-icon {
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-note {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] body {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e5edf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .page-wrapper .navbar {
|
||||||
|
background: #111827;
|
||||||
|
border-bottom-color: rgba(148, 163, 184, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .navbar .navbar-brand,
|
||||||
|
[data-bs-theme="dark"] .navbar .nav-link,
|
||||||
|
[data-bs-theme="dark"] .navbar .text-secondary,
|
||||||
|
[data-bs-theme="dark"] .navbar .btn {
|
||||||
|
color: #e5edf7 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .card {
|
||||||
|
background: #111827;
|
||||||
|
border-color: rgba(148, 163, 184, 0.14);
|
||||||
|
box-shadow: 0 1px 2px rgba(2, 6, 23, 0.22), 0 8px 24px rgba(2, 6, 23, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .login-hero,
|
||||||
|
[data-bs-theme="dark"] .soft-note {
|
||||||
|
background: #111827;
|
||||||
|
border-color: rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .sidebar {
|
||||||
|
background: #0f172a;
|
||||||
|
border-right-color: rgba(148, 163, 184, 0.16);
|
||||||
|
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .sidebar-title {
|
||||||
|
color: rgba(191, 219, 254, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .sidebar-nav a {
|
||||||
|
color: #dbe7f5;
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .sidebar-nav a:hover {
|
||||||
|
background: rgba(30, 41, 59, 0.72);
|
||||||
|
box-shadow: 0 6px 16px rgba(2, 6, 23, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .section-title,
|
||||||
|
[data-bs-theme="dark"] .text-primary {
|
||||||
|
color: #8fc0f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .text-secondary,
|
||||||
|
[data-bs-theme="dark"] .form-label,
|
||||||
|
[data-bs-theme="dark"] .table,
|
||||||
|
[data-bs-theme="dark"] .table th,
|
||||||
|
[data-bs-theme="dark"] .table td,
|
||||||
|
[data-bs-theme="dark"] .list-group-item,
|
||||||
|
[data-bs-theme="dark"] .card-title,
|
||||||
|
[data-bs-theme="dark"] .h1,
|
||||||
|
[data-bs-theme="dark"] strong,
|
||||||
|
[data-bs-theme="dark"] code {
|
||||||
|
color: #dbe7f5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .table thead th,
|
||||||
|
[data-bs-theme="dark"] .list-group-item,
|
||||||
|
[data-bs-theme="dark"] hr {
|
||||||
|
border-color: rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .table tbody tr:nth-child(odd) td {
|
||||||
|
background: rgba(15, 23, 42, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .table tbody tr:nth-child(even) td {
|
||||||
|
background: rgba(30, 41, 59, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .table tbody tr:hover td {
|
||||||
|
background: rgba(59, 130, 246, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .form-control,
|
||||||
|
[data-bs-theme="dark"] .form-select {
|
||||||
|
background: rgba(15, 23, 42, 0.88);
|
||||||
|
border-color: rgba(148, 163, 184, 0.18);
|
||||||
|
color: #e5edf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .form-control::placeholder {
|
||||||
|
color: rgba(219, 231, 245, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .app-main .btn-outline-secondary,
|
||||||
|
[data-bs-theme="dark"] .app-main .btn-outline-primary {
|
||||||
|
color: #b9d7f4;
|
||||||
|
border-color: rgba(143, 192, 240, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .app-main .btn-outline-secondary:hover,
|
||||||
|
[data-bs-theme="dark"] .app-main .btn-outline-primary:hover {
|
||||||
|
color: #08131f;
|
||||||
|
background: #8fc0f0;
|
||||||
|
border-color: #8fc0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .badge.bg-blue-lt,
|
||||||
|
[data-bs-theme="dark"] .text-blue {
|
||||||
|
background: rgba(143, 192, 240, 0.16) !important;
|
||||||
|
color: #dbe7f5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app-shell {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--tci-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var savedTheme = localStorage.getItem('tci-theme');
|
||||||
|
var theme = savedTheme === 'dark' ? 'dark' : 'light';
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFooter(): void
|
||||||
|
{
|
||||||
|
?>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var button = document.getElementById('theme-toggle');
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||||
|
localStorage.setItem('tci-theme', theme);
|
||||||
|
button.innerHTML = theme === 'dark' ? '<i class="ti ti-sun"></i>' : '<i class="ti ti-moon-stars"></i>';
|
||||||
|
button.setAttribute('aria-label', theme === 'dark' ? 'Lightmode umschalten' : 'Darkmode umschalten');
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme(document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light');
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
applyTheme(document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'light' : 'dark');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoginPage(): void
|
||||||
|
{
|
||||||
|
global $error, $notice, $pdo;
|
||||||
|
if (!$notice && isset($_SESSION['login_notice'])) {
|
||||||
|
$notice = (string)$_SESSION['login_notice'];
|
||||||
|
unset($_SESSION['login_notice']);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="container-tight py-4">
|
||||||
|
<div class="login-hero text-center mb-4">
|
||||||
|
<div class="mb-3"><img src="/logo%20neu.png" alt="TC Ingelfingen Logo" style="max-width: 140px; width: 100%; height: auto;"></div>
|
||||||
|
<h1 class="mb-1 section-title justify-content-center"><i class="ti ti-tennis"></i><span>TC Ingelfingen</span></h1>
|
||||||
|
<p class="text-secondary mb-0">Arbeitsstundenverwaltung</p>
|
||||||
|
</div>
|
||||||
|
<?php if (!$pdo): ?><div class="alert alert-warning">Keine Datenbankverbindung. Die App läuft nur im Demo-Modus, bis DB_HOST, DB_NAME, DB_USER und DB_PASS gesetzt sind.</div><?php endif; ?>
|
||||||
|
<?php if ($notice): ?><div class="alert alert-success"><?= htmlspecialchars($notice) ?></div><?php endif; ?>
|
||||||
|
<?php if ($error): ?><div class="alert alert-danger"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
|
<div class="card card-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="login">
|
||||||
|
<div class="mb-3"><label class="form-label">E-Mail</label><input class="form-control" name="email" type="email" required></div>
|
||||||
|
<div class="mb-3"><label class="form-label">Passwort</label><input class="form-control" name="password" type="password" required></div>
|
||||||
|
<button class="btn btn-primary w-100" type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
<div class="text-center mt-3"><a href="#forgot-password" data-bs-toggle="collapse" role="button" aria-expanded="false" aria-controls="forgot-password">Passwort vergessen?</a></div>
|
||||||
|
<div class="collapse mt-3" id="forgot-password"><form method="post"><input type="hidden" name="action" value="request_password_reset"><div class="mb-3"><label class="form-label">E-Mail für Reset-Link</label><input class="form-control" name="email" type="email" placeholder="E-Mail" required></div><button class="btn btn-outline-primary w-100" type="submit">Reset-Link anfordern</button></form></div>
|
||||||
|
<div class="text-secondary mt-3 d-flex align-items-center justify-content-center gap-2"><?php if ($pdo): ?><span class="text-success d-inline-flex align-items-center gap-2"><i class="ti ti-database"></i><span>Mit Datenbank</span></span><?php else: ?><span class="text-danger d-inline-flex align-items-center gap-2"><i class="ti ti-database-off"></i><span>Ohne Datenbank</span></span><?php endif; ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResetPasswordPage(): void
|
||||||
|
{
|
||||||
|
global $error, $notice;
|
||||||
|
$token = trim((string)($_GET['token'] ?? ''));
|
||||||
|
?>
|
||||||
|
<div class="container-tight py-4">
|
||||||
|
<?php if ($notice): ?><div class="alert alert-success"><?= htmlspecialchars($notice) ?></div><?php endif; ?>
|
||||||
|
<?php if ($error): ?><div class="alert alert-danger"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
|
<div class="card card-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="section-title mb-3"><i class="ti ti-key"></i><span>Passwort zurücksetzen</span></h2>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="reset_password">
|
||||||
|
<input type="hidden" name="token" value="<?= htmlspecialchars($token) ?>">
|
||||||
|
<div class="mb-3"><label class="form-label">Neues Passwort</label><input class="form-control" name="new_password" type="password" minlength="6" required></div>
|
||||||
|
<div class="mb-3"><label class="form-label">Neues Passwort wiederholen</label><input class="form-control" name="confirm_password" type="password" minlength="6" required></div>
|
||||||
|
<button class="btn btn-primary w-100" type="submit">Passwort speichern</button>
|
||||||
|
</form>
|
||||||
|
<div class="text-center mt-3"><a href="/?page=login">Zurück zur Anmeldung</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAppShell(string $page): void
|
||||||
|
{
|
||||||
|
global $config, $notice, $user, $pdo;
|
||||||
|
$current = currentUser() ?? [];
|
||||||
|
$users = fetchUsers($pdo);
|
||||||
|
$memberPage = max(1, (int)($_GET['member_page'] ?? 1));
|
||||||
|
$memberUsers = fetchUsersPage($pdo, $memberPage, 50);
|
||||||
|
$logPage = max(1, (int)($_GET['log_page'] ?? 1));
|
||||||
|
$logs = fetchLogs($pdo, $logPage, 50);
|
||||||
|
$bookingPage = max(1, (int)($_GET['booking_page'] ?? 1));
|
||||||
|
$bookings = fetchBookingsPage($pdo, $current, $bookingPage, 50);
|
||||||
|
$totalWorked = 0.0;
|
||||||
|
foreach ($users as $item) {
|
||||||
|
if ((int)$item['id'] === (int)($current['id'] ?? 0)) {
|
||||||
|
$totalWorked = (float)$item['hours_worked'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$missingHours = max(0, $config['hours_target'] - $totalWorked);
|
||||||
|
$chargeAmount = $missingHours * $config['hourly_rate_eur'];
|
||||||
|
$canSeeAllMembers = hasRole($current, ['editor', 'admin']);
|
||||||
|
$dashboardTargetHours = $canSeeAllMembers ? ((float)$config['hours_target'] * count($users)) : (float)$config['hours_target'];
|
||||||
|
$dashboardWorkedHours = $canSeeAllMembers ? array_sum(array_map(static fn (array $item): float => (float)$item['hours_worked'], $users)) : $totalWorked;
|
||||||
|
$dashboardMissingHours = 0.0;
|
||||||
|
foreach ($users as $item) {
|
||||||
|
if (!$canSeeAllMembers && (int)$item['id'] !== (int)($current['id'] ?? 0)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$dashboardMissingHours += max(0, $config['hours_target'] - (float)$item['hours_worked']);
|
||||||
|
}
|
||||||
|
$dashboardWorkedAmount = $dashboardWorkedHours * $config['hourly_rate_eur'];
|
||||||
|
$dashboardMissingAmount = $dashboardMissingHours * $config['hourly_rate_eur'];
|
||||||
|
$dashboardScopeLabel = $canSeeAllMembers ? 'Alle Mitglieder' : (trim(((string)($current['firstname'] ?? '')) . ' ' . ((string)($current['lastname'] ?? ''))) ?: 'Mein Konto');
|
||||||
|
$dashboardProgressPercent = min(100, ($dashboardWorkedHours / max(1, $dashboardTargetHours)) * 100);
|
||||||
|
?>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="navbar navbar-expand-md d-print-none">
|
||||||
|
<div class="container-xl">
|
||||||
|
<a class="navbar-brand navbar-brand-wrap" href="/">
|
||||||
|
<span class="navbar-mark" aria-hidden="true"><img src="/logo%20neu.png" alt=""></span>
|
||||||
|
<span class="navbar-brand-text"><span>Arbeitsstunden</span><span>Dashboard</span></span>
|
||||||
|
</a>
|
||||||
|
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap justify-content-end">
|
||||||
|
<a class="btn btn-outline-secondary d-inline-flex align-items-center gap-2 text-decoration-none" href="/?page=profile" style="height: 38px;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 12a5 5 0 1 0-5-5a5 5 0 0 0 5 5"/><path d="M4 20a8 8 0 0 1 16 0"/></svg>
|
||||||
|
<span class="d-flex flex-column align-items-start justify-content-center lh-1"><span class="text-truncate"><?= htmlspecialchars(trim(((string)($current['firstname'] ?? '')) . ' ' . ((string)($current['lastname'] ?? ''))) ?: ($current['name'] ?? 'guest')) ?></span><span class="small opacity-75"><?= htmlspecialchars(roleLabel((string)($current['role'] ?? ''))) ?></span></span>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="theme-toggle" aria-label="Darkmode umschalten"><i class="ti ti-moon-stars"></i></button>
|
||||||
|
<form method="post"><input type="hidden" name="action" value="logout"><button class="btn btn-outline-secondary" type="submit">Abmelden</button></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-title">Menü</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a class="<?= $page === 'dashboard' ? 'active' : '' ?>" href="/?page=dashboard"><i class="ti ti-layout-dashboard"></i><span>Dashboard</span></a>
|
||||||
|
<a class="<?= $page === 'profile' ? 'active' : '' ?>" href="/?page=profile"><i class="ti ti-user-circle"></i><span>Profil</span></a>
|
||||||
|
<?php if (($current['role'] ?? '') !== 'member'): ?><a class="<?= $page === 'members' ? 'active' : '' ?>" href="/?page=members"><i class="ti ti-user-plus"></i><span>Benutzerverwaltung</span></a><?php endif; ?>
|
||||||
|
<a class="<?= $page === 'booking' ? 'active' : '' ?>" href="/?page=booking"><i class="ti ti-clock-plus"></i><span>Stundenbuchung</span></a>
|
||||||
|
<?php if (($current['role'] ?? '') !== 'member'): ?><a class="<?= $page === 'admin' ? 'active' : '' ?>" href="/?page=admin"><i class="ti ti-settings"></i><span>Vereinskonfiguration</span></a><?php endif; ?>
|
||||||
|
<?php if (($current['role'] ?? '') === 'admin'): ?><a class="<?= $page === 'administration' ? 'active' : '' ?>" href="/?page=administration"><i class="ti ti-tool"></i><span>Administration</span></a><?php endif; ?>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main class="app-main page-body">
|
||||||
|
<div class="container-xl py-4">
|
||||||
|
<?php if ($notice): ?><div class="alert alert-success"><?= htmlspecialchars($notice) ?></div><?php endif; ?>
|
||||||
|
<?php if ($error): ?><div class="alert alert-danger"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
|
<?php if ($page === 'dashboard'): ?>
|
||||||
|
<div class="row row-cards mb-3">
|
||||||
|
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><i class="ti ti-target stat-icon"></i><div class="text-secondary small mb-1"><?= htmlspecialchars($dashboardScopeLabel) ?></div><div class="h1 mb-1"><?= number_format($dashboardTargetHours, 1, ',', '.') ?> h</div><div class="text-secondary"><?= $canSeeAllMembers ? 'Pflichtstunden gesamt' : 'Pflichtstunden' ?></div></div></div></div>
|
||||||
|
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><i class="ti ti-currency-euro stat-icon"></i><div class="text-secondary small mb-1">Pro Stunde</div><div class="h1 mb-1"><?= money((float)$config['hourly_rate_eur']) ?></div><div class="text-secondary">Stundensatz</div></div></div></div>
|
||||||
|
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><i class="ti ti-bolt stat-icon"></i><div class="text-secondary small mb-1"><?= htmlspecialchars($dashboardScopeLabel) ?></div><div class="h1 mb-1"><?= number_format($dashboardWorkedHours, 1, ',', '.') ?> h / <?= money($dashboardWorkedAmount) ?></div><div class="text-secondary"><?= $canSeeAllMembers ? 'Geleistet gesamt' : 'Geleistet' ?></div></div></div></div>
|
||||||
|
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><i class="ti ti-hourglass stat-icon"></i><div class="text-secondary small mb-1"><?= htmlspecialchars($dashboardScopeLabel) ?></div><div class="h1 mb-1"><?= number_format($dashboardMissingHours, 1, ',', '.') ?> h / <?= money($dashboardMissingAmount) ?></div><div class="text-secondary"><?= $canSeeAllMembers ? 'Noch offen gesamt' : 'Noch offen' ?></div></div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="row row-cards mb-3">
|
||||||
|
<div class="col-lg-12"><div class="card"><div class="card-body"><div class="d-flex align-items-start justify-content-between gap-3 mb-2"><h3 class="section-title mb-0"><i class="ti ti-chart-donut-2"></i><span>Dashboard</span></h3><div class="text-secondary d-inline-flex align-items-center gap-2"><i class="ti ti-chart-line"></i><span><?= number_format($dashboardProgressPercent, 0, ',', '.') ?>%</span></div></div><div class="progress mb-3"><div class="progress-bar" style="width: <?= $dashboardProgressPercent ?>%"></div></div><p class="text-secondary">Geleistete Arbeitsstunden in %.</p></div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-lg-12"><div class="card"><div class="card-body"><h3 class="section-title"><i class="ti ti-list-details"></i><span>Meine Stunden / Mitglieder</span></h3><div class="table-responsive"><table class="table table-vcenter"><thead><tr><th>Name</th><th>Rolle</th><th>Soll</th><th>Ist</th><th>Rest</th><th>Betrag</th></tr></thead><tbody><?php foreach ($users as $item): ?><?php $worked = (float)$item['hours_worked']; $rest = max(0, $config['hours_target'] - $worked); $amount = $rest * $config['hourly_rate_eur']; $isOwn = (int)$item['id'] === (int)($current['id'] ?? 0); if (!$canSeeAllMembers && !$isOwn) { continue; } ?><tr><td><?= htmlspecialchars(trim(((string)($item['firstname'] ?? '')) . ' ' . ((string)($item['lastname'] ?? ''))) ?: (string)$item['name']) ?></td><td><?= htmlspecialchars(roleLabel((string)$item['role'])) ?></td><td><?= (int)$config['hours_target'] ?> h</td><td><?= number_format($worked, 1, ',', '.') ?> h</td><td><?= number_format($rest, 1, ',', '.') ?> h</td><td><?= money($amount) ?></td></tr><?php endforeach; ?></tbody></table></div></div></div></div>
|
||||||
|
</div>
|
||||||
|
<?php if (trim((string)$config['dashboard_info']) !== ''): ?>
|
||||||
|
<div class="row row-cards mt-3">
|
||||||
|
<div class="col-lg-12"><div class="card"><div class="card-body"><h3 class="section-title"><i class="ti ti-info-circle"></i><span>Information</span></h3><div class="text-secondary"><?= renderSimpleMarkdown((string)$config['dashboard_info']) ?></div></div></div></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($page === 'profile'): ?>
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-lg-6"><div class="card"><div class="card-body"><h3 class="section-title"><i class="ti ti-user-circle"></i><span>Profil</span></h3><form method="post"><input type="hidden" name="action" value="update_profile"><div class="mb-3"><label class="form-label">Vorname</label><input class="form-control" name="firstname" value="<?= htmlspecialchars((string)($current['firstname'] ?? '')) ?>" required></div><div class="mb-3"><label class="form-label">Nachname</label><input class="form-control" name="lastname" value="<?= htmlspecialchars((string)($current['lastname'] ?? '')) ?>" required></div><div class="mb-3"><label class="form-label">E-Mail</label><input class="form-control" value="<?= htmlspecialchars((string)($current['email'] ?? '')) ?>" disabled></div><button class="btn btn-primary" type="submit">Profil speichern</button></form></div></div></div>
|
||||||
|
<div class="col-lg-6"><div class="card"><div class="card-body"><h3 class="section-title"><i class="ti ti-lock"></i><span>Passwort ändern</span></h3><form method="post"><input type="hidden" name="action" value="change_password"><div class="mb-3"><label class="form-label">Aktuelles Passwort</label><input class="form-control" name="current_password" type="password" required></div><div class="mb-3"><label class="form-label">Neues Passwort</label><input class="form-control" name="new_password" type="password" minlength="6" required></div><div class="mb-3"><label class="form-label">Neues Passwort wiederholen</label><input class="form-control" name="confirm_password" type="password" minlength="6" required></div><button class="btn btn-primary" type="submit">Passwort speichern</button></form></div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="row row-cards mt-3">
|
||||||
|
<div class="col-lg-12"><div class="card soft-note"><div class="card-body"><h3 class="section-title"><i class="ti ti-shield-lock"></i><span>Hinweis</span></h3><p class="text-secondary mb-2">Jeder Benutzer kann Vorname, Nachname und das eigene Passwort selbst ändern.</p><p class="text-secondary mb-0">Die E-Mail-Adresse wird angezeigt, kann aber nicht bearbeitet werden. Das neue Passwort muss mindestens 6 Zeichen lang sein.</p></div></div></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($page === 'members'): ?>
|
||||||
|
<div class="row row-cards mb-3">
|
||||||
|
<div class="col-lg-8"><div class="card"><div class="card-body"><h3 class="section-title"><i class="ti ti-user-plus"></i><span>Benutzer anlegen</span></h3><form method="post"><input type="hidden" name="action" value="create_member"><div class="row"><div class="col-md-6 mb-3"><label class="form-label">Vorname</label><input class="form-control" name="firstname" placeholder="Vorname" required></div><div class="col-md-6 mb-3"><label class="form-label">Nachname</label><input class="form-control" name="lastname" placeholder="Nachname" required></div></div><div class="mb-3"><label class="form-label">E-Mail</label><input class="form-control" name="email" type="email" placeholder="E-Mail" required></div><?php if (($current['role'] ?? '') === 'admin'): ?><div class="mb-3"><label class="form-label">Rolle</label><select class="form-select" name="role"><option value="member">Mitglied</option><option value="editor">Bearbeiter</option><option value="admin">Admin</option></select></div><?php endif; ?><div class="text-secondary small mb-3">Benutzer werden mit dem Startpasswort <strong>mitglied123</strong> angelegt.</div><button class="btn btn-primary w-100" type="submit">Benutzer speichern</button></form></div></div></div>
|
||||||
|
<div class="col-lg-4"><div class="card soft-note"><div class="card-body"><h3 class="section-title"><i class="ti ti-info-circle"></i><span>Hinweis</span></h3><p class="text-secondary mb-2">Nur Admins dürfen Rollen setzen oder ändern.</p><p class="text-secondary mb-0">Neue Benutzer erhalten automatisch das Startpasswort <strong>mitglied123</strong>.</p></div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-lg-12"><div class="card"><div class="card-body"><h3 class="section-title"><i class="ti ti-users"></i><span>Benutzerliste</span></h3><div class="table-responsive"><table class="table table-vcenter"><thead><tr><th>Nachname</th><th>Vorname</th><th>E-Mail</th><th>Rolle</th><th>Geleistet</th><th class="text-end">Aktionen</th></tr></thead><tbody><?php foreach (($memberUsers['entries'] ?? []) as $item): ?><tr><td><?= htmlspecialchars((string)($item['lastname'] ?? '')) ?></td><td><?= htmlspecialchars((string)($item['firstname'] ?? '')) ?></td><td><?= htmlspecialchars($item['email']) ?></td><td><?php if (($current['role'] ?? '') === 'admin'): ?><form method="post" class="d-flex gap-2 align-items-center justify-content-start"><input type="hidden" name="action" value="update_user_role"><input type="hidden" name="user_id" value="<?= (int)$item['id'] ?>"><select class="form-select form-select-sm" name="role"<?= ((int)$item['id'] === (int)($current['id'] ?? 0)) ? ' disabled' : '' ?>><option value="member" <?= (string)$item['role'] === 'member' ? 'selected' : '' ?>>Mitglied</option><option value="editor" <?= (string)$item['role'] === 'editor' ? 'selected' : '' ?>>Bearbeiter</option><option value="admin" <?= (string)$item['role'] === 'admin' ? 'selected' : '' ?>>Admin</option></select><?php if ((int)$item['id'] !== (int)($current['id'] ?? 0)): ?><button class="btn btn-sm btn-outline-primary" type="submit">Speichern</button><?php endif; ?></form><?php else: ?><span class="badge bg-secondary-lt"><?= htmlspecialchars(roleLabel((string)$item['role'])) ?></span><?php endif; ?></td><td><?= number_format((float)$item['hours_worked'], 1, ',', '.') ?> h</td><td class="text-end"><div class="d-inline-flex gap-2 flex-wrap justify-content-end"><?php if (canManageMembers($current) && (string)$item['role'] !== 'admin'): ?><form method="post" class="m-0"><input type="hidden" name="action" value="reset_user_password"><input type="hidden" name="user_id" value="<?= (int)$item['id'] ?>"><button class="btn btn-sm btn-outline-secondary" type="submit">Passwort zurücksetzen</button></form><?php endif; ?><?php if (canManageMembers($current) && (string)$item['role'] !== 'admin'): ?><form method="post" class="m-0" onsubmit="return confirm('Benutzer wirklich löschen?');"><input type="hidden" name="action" value="delete_user"><input type="hidden" name="user_id" value="<?= (int)$item['id'] ?>"><button class="btn btn-sm btn-outline-danger" type="submit">Löschen</button></form><?php endif; ?><?php if (!canManageMembers($current) || (string)$item['role'] === 'admin'): ?><span class="text-secondary small">Keine Aktionen</span><?php endif; ?></div></td></tr><?php endforeach; ?><?php if (empty($memberUsers['entries'])): ?><tr><td colspan="6" class="text-secondary text-center">Keine Benutzer vorhanden.</td></tr><?php endif; ?></tbody></table></div><?php if (($memberUsers['total_pages'] ?? 1) > 1): ?><div class="d-flex align-items-center justify-content-between gap-3 mt-3"><a class="btn btn-outline-secondary<?= ($memberUsers['page'] ?? 1) <= 1 ? ' disabled' : '' ?>" href="/?page=members&member_page=<?= max(1, (int)($memberUsers['page'] ?? 1) - 1) ?>">Zurück</a><div class="text-secondary small">Seite <?= (int)($memberUsers['page'] ?? 1) ?> von <?= (int)($memberUsers['total_pages'] ?? 1) ?>, insgesamt <?= (int)($memberUsers['total'] ?? 0) ?> Einträge</div><a class="btn btn-outline-secondary<?= ($memberUsers['page'] ?? 1) >= ($memberUsers['total_pages'] ?? 1) ? ' disabled' : '' ?>" href="/?page=members&member_page=<?= min((int)($memberUsers['total_pages'] ?? 1), (int)($memberUsers['page'] ?? 1) + 1) ?>">Weiter</a></div><?php endif; ?></div></div></div></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($page === 'booking'): ?>
|
||||||
|
<div class="row row-cards mb-3">
|
||||||
|
<?php if (($current['role'] ?? '') !== 'member'): ?>
|
||||||
|
<div class="col-lg-8"><div class="card"><div class="card-body"><h3 class="section-title"><i class="ti ti-clock-plus"></i><span>Stunden für Benutzer buchen</span></h3><form method="post"><input type="hidden" name="action" value="book_hours"><div class="mb-3"><label class="form-label">Benutzer</label><select class="form-select" name="member_id" required><?php foreach ($users as $item): ?><option value="<?= (int)$item['id'] ?>"><?= htmlspecialchars(trim(((string)($item['firstname'] ?? '')) . ' ' . ((string)($item['lastname'] ?? ''))) ?: (string)$item['email']) ?> (<?= htmlspecialchars(roleLabel((string)$item['role'])) ?>)</option><?php endforeach; ?></select></div><div class="mb-3"><label class="form-label">Stunden</label><input class="form-control" name="hours" type="number" step="0.25" min="0.25" placeholder="Stunden" required></div><div class="mb-3"><label class="form-label">Notiz</label><input class="form-control" name="note" placeholder="Notiz" required></div><button class="btn btn-primary w-100" type="submit">Stunden buchen</button></form></div></div></div>
|
||||||
|
<div class="col-lg-4"><div class="card soft-note"><div class="card-body"><h3 class="section-title"><i class="ti ti-info-circle"></i><span>Hinweis</span></h3><p class="text-secondary mb-0">Nur Bearbeiter und Admins können Stunden buchen.</p></div></div></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="col-lg-12"><div class="card soft-note"><div class="card-body"><h3 class="section-title"><i class="ti ti-info-circle"></i><span>Meine Stundenbuchungen</span></h3><p class="text-secondary mb-0">Hier sehen Sie Ihre eigenen Stundenbuchungen.</p></div></div></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-lg-12"><div class="card"><div class="card-body"><h3 class="section-title"><i class="ti ti-list-details"></i><span>Stundenbuchungen</span></h3><div class="table-responsive"><table class="table table-vcenter"><thead><tr><th>Datum</th><th>Mitglied</th><th>Gebucht von</th><th>Stunden</th><th>Notiz</th><th class="text-end">Aktionen</th></tr></thead><tbody><?php foreach (($bookings['entries'] ?? []) as $booking): ?><tr><td><?= htmlspecialchars(formatDateTime((string)$booking['ts'])) ?></td><td><?= htmlspecialchars((string)$booking['member']) ?></td><td><?= htmlspecialchars((string)$booking['actor']) ?></td><td><?= number_format((float)$booking['hours'], 2, ',', '.') ?> h</td><td><?= htmlspecialchars((string)$booking['note']) ?></td><td class="text-end"><?php if (($current['role'] ?? '') === 'admin'): ?><form method="post" class="m-0 d-inline-block" onsubmit="return confirm('Stundenbuchung wirklich löschen?');"><input type="hidden" name="action" value="delete_booking"><input type="hidden" name="booking_id" value="<?= (int)$booking['id'] ?>"><button class="btn btn-sm btn-outline-danger" type="submit">Löschen</button></form><?php else: ?><span class="text-secondary small">Keine Aktionen</span><?php endif; ?></td></tr><?php endforeach; ?><?php if (empty($bookings['entries'])): ?><tr><td colspan="6" class="text-secondary text-center">Keine Stundenbuchungen vorhanden.</td></tr><?php endif; ?></tbody></table></div><?php if (($bookings['total_pages'] ?? 1) > 1): ?><div class="d-flex align-items-center justify-content-between gap-3 mt-3"><a class="btn btn-outline-secondary<?= ($bookings['page'] ?? 1) <= 1 ? ' disabled' : '' ?>" href="/?page=booking&booking_page=<?= max(1, (int)($bookings['page'] ?? 1) - 1) ?>">Zurück</a><div class="text-secondary small">Seite <?= (int)($bookings['page'] ?? 1) ?> von <?= (int)($bookings['total_pages'] ?? 1) ?>, insgesamt <?= (int)($bookings['total'] ?? 0) ?> Einträge</div><a class="btn btn-outline-secondary<?= ($bookings['page'] ?? 1) >= ($bookings['total_pages'] ?? 1) ? ' disabled' : '' ?>" href="/?page=booking&booking_page=<?= min((int)($bookings['total_pages'] ?? 1), (int)($bookings['page'] ?? 1) + 1) ?>">Weiter</a></div><?php endif; ?></div></div></div></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($page === 'admin' && ($current['role'] ?? '') !== 'member'): ?>
|
||||||
|
<div class="card mt-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="section-title"><i class="ti ti-settings"></i><span>Vereinskonfiguration</span></h3>
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-lg-12"><div class="card"><div class="card-body admin-actions"><h3 class="section-title"><i class="ti ti-adjustments"></i><span>Vereinswerte</span></h3><form method="post"><input type="hidden" name="action" value="update_settings"><div class="mb-3"><label class="form-label">Pflichtstunden pro Mitglied</label><input class="form-control" name="hours_target" type="number" min="0" step="1" value="<?= (int)$config['hours_target'] ?>"></div><div class="mb-3"><label class="form-label">Wert pro Stunde</label><input class="form-control" name="hourly_rate_eur" type="number" min="0" step="0.5" value="<?= htmlspecialchars((string)$config['hourly_rate_eur']) ?>"></div><button class="btn btn-success" type="submit">Werte speichern</button></form></div></div></div>
|
||||||
|
<div class="col-lg-12"><div class="card"><div class="card-body admin-actions"><h3 class="section-title"><i class="ti ti-info-circle"></i><span>Informationskasten im Dashboard</span></h3><form method="post"><input type="hidden" name="action" value="update_settings"><input type="hidden" name="hours_target" value="<?= (int)$config['hours_target'] ?>"><input type="hidden" name="hourly_rate_eur" value="<?= htmlspecialchars((string)$config['hourly_rate_eur']) ?>"><div class="mb-3"><textarea class="form-control" name="dashboard_info" rows="8" placeholder="Markdown wird unterstützt"><?= htmlspecialchars((string)$config['dashboard_info']) ?></textarea><div class="form-hint">Unterstützt Absätze, <code>- Listen</code>, <code>*kursiv*</code> und <code>**fett**</code>.</div></div><button class="btn btn-success" type="submit">Informationskasten speichern</button></form></div></div></div>
|
||||||
|
<div class="col-lg-12"><div class="card"><div class="card-body admin-actions"><h3 class="section-title"><i class="ti ti-database-export"></i><span>Exporte</span></h3><p class="text-secondary">Exportfunktionen für Benutzerdaten und Stundenbuchungen.</p><form method="post" class="mb-3"><input type="hidden" name="action" value="export_csv"><button class="btn btn-primary" type="submit">Benutzerdaten als CSV exportieren</button></form><form method="post"><input type="hidden" name="action" value="export_bookings_csv"><button class="btn btn-primary" type="submit">Stundenbuchungen als CSV exportieren</button></form></div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($page === 'administration' && ($current['role'] ?? '') === 'admin'): ?>
|
||||||
|
<div class="card mt-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="section-title"><i class="ti ti-tool"></i><span>Administration</span></h3>
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-lg-12"><div class="card"><div class="card-body admin-actions"><h3 class="section-title"><i class="ti ti-database"></i><span>SQL-Backup</span></h3><p class="text-secondary">Vollständige Datensicherung und Wiederherstellung der App-Datenbank.</p><form method="post" class="mb-3"><input type="hidden" name="action" value="export_sql_dump"><button class="btn btn-primary" type="submit">SQL-Dump herunterladen</button></form><form method="post" enctype="multipart/form-data" onsubmit="return confirm('Die Wiederherstellung überschreibt die bestehenden App-Daten. Fortfahren?');"><input type="hidden" name="action" value="import_sql_dump"><div class="mb-3"><input class="form-control" name="sql_file" type="file" accept=".sql,application/sql,text/plain" required></div><button class="btn btn-outline-danger" type="submit">SQL-Dump wiederherstellen</button></form></div></div></div>
|
||||||
|
<div class="col-lg-12"><div class="card"><div class="card-body admin-actions"><h3 class="section-title"><i class="ti ti-file-import"></i><span>CSV-Import</span></h3><p class="text-secondary">Importiert Benutzerdaten aus einer CSV-Datei mit den Spalten <strong>firstname</strong>, <strong>lastname</strong>, <strong>email</strong>, <strong>role</strong> und <strong>hours_worked</strong>.</p><div class="text-secondary small mb-3"><strong>Beispiel-Kopfzeile:</strong> <code>firstname,lastname,email,role,hours_worked</code></div><form method="post" enctype="multipart/form-data"><input type="hidden" name="action" value="import_csv"><div class="mb-3"><input class="form-control" name="csv_file" type="file" accept=".csv,text/csv" required></div><button class="btn btn-primary" type="submit">Benutzerdaten aus CSV importieren</button></form></div></div></div>
|
||||||
|
<div class="col-lg-12"><div class="card"><div class="card-body admin-actions"><h3 class="section-title"><i class="ti ti-rotate-clockwise-2"></i><span>Arbeitsstunden zurücksetzen</span></h3><p class="text-secondary">Setzt alle gebuchten Arbeitsstunden für alle Mitglieder auf 0. Diese Aktion ist für den Jahreswechsel gedacht.</p><form method="post" onsubmit="return confirm('Wirklich alle Arbeitsstunden auf 0 zurücksetzen?');"><input type="hidden" name="action" value="reset_all_hours"><button class="btn btn-outline-danger" type="submit">Alle Arbeitsstunden auf 0 setzen</button></form></div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="row row-cards mt-2"><div class="col-lg-12"><div class="card"><div class="card-body"><h3 class="section-title"><i class="ti ti-history"></i><span>Logansicht</span></h3><ul class="list-group list-group-flush"><?php foreach (($logs['entries'] ?? []) as $entry): ?><li class="list-group-item px-0"><strong><?= htmlspecialchars($entry['ts']) ?></strong><br><?= htmlspecialchars($entry['actor']) ?>: <?= htmlspecialchars($entry['action']) ?></li><?php endforeach; ?><?php if (empty($logs['entries'])): ?><li class="list-group-item px-0 text-secondary">Keine Logeinträge vorhanden.</li><?php endif; ?></ul><?php if (($logs['total_pages'] ?? 1) > 1): ?><div class="d-flex align-items-center justify-content-between gap-3 mt-3"><a class="btn btn-outline-secondary<?= ($logs['page'] ?? 1) <= 1 ? ' disabled' : '' ?>" href="/?page=administration&log_page=<?= max(1, (int)($logs['page'] ?? 1) - 1) ?>">Zurück</a><div class="text-secondary small">Seite <?= (int)($logs['page'] ?? 1) ?> von <?= (int)($logs['total_pages'] ?? 1) ?>, insgesamt <?= (int)($logs['total'] ?? 0) ?> Einträge</div><a class="btn btn-outline-secondary<?= ($logs['page'] ?? 1) >= ($logs['total_pages'] ?? 1) ? ' disabled' : '' ?>" href="/?page=administration&log_page=<?= min((int)($logs['total_pages'] ?? 1), (int)($logs['page'] ?? 1) + 1) ?>">Weiter</a></div><?php endif; ?></div></div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<footer class="app-footer d-flex flex-wrap justify-content-between gap-3">
|
||||||
|
<div>Author: <?= htmlspecialchars((string)$config['author']) ?> | <?= htmlspecialchars((string)$config['copyright']) ?></div>
|
||||||
|
<div>Host: <?= htmlspecialchars((string)($_SERVER['HTTP_HOST'] ?? 'localhost')) ?> | Datenbank: <?= $pdo ? 'verbunden' : 'nicht verbunden' ?> | Version: <?= htmlspecialchars((string)$config['app_version']) ?></div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
47
httpdocs/debug.php
Normal file
47
httpdocs/debug.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
|
||||||
|
$configFile = dirname(__DIR__) . '/config.php';
|
||||||
|
|
||||||
|
echo "Arbeitsstunden Debug\n";
|
||||||
|
echo "===================\n\n";
|
||||||
|
|
||||||
|
echo 'PHP Version: ' . PHP_VERSION . "\n";
|
||||||
|
echo 'Config gefunden: ' . (is_file($configFile) ? 'ja' : 'nein') . "\n";
|
||||||
|
echo 'PDO geladen: ' . (extension_loaded('pdo') ? 'ja' : 'nein') . "\n";
|
||||||
|
echo 'PDO MySQL geladen: ' . (extension_loaded('pdo_mysql') ? 'ja' : 'nein') . "\n\n";
|
||||||
|
|
||||||
|
if (!is_file($configFile)) {
|
||||||
|
echo "config.php fehlt neben httpdocs.\n";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = require $configFile;
|
||||||
|
$db = $config['db'] ?? [];
|
||||||
|
|
||||||
|
echo 'DB Host: ' . (($db['host'] ?? '') !== '' ? $db['host'] : '(leer)') . "\n";
|
||||||
|
echo 'DB Name: ' . (($db['name'] ?? '') !== '' ? $db['name'] : '(leer)') . "\n";
|
||||||
|
echo 'DB User: ' . (($db['user'] ?? '') !== '' ? $db['user'] : '(leer)') . "\n";
|
||||||
|
echo 'DB Passwort gesetzt: ' . (($db['pass'] ?? '') !== '' ? 'ja' : 'nein') . "\n\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', (string)($db['host'] ?? ''), (string)($db['name'] ?? ''));
|
||||||
|
$pdo = new PDO($dsn, (string)($db['user'] ?? ''), (string)($db['pass'] ?? ''), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||||
|
echo "DB Verbindung: ok\n";
|
||||||
|
|
||||||
|
$version = $pdo->query('SELECT VERSION()')->fetchColumn();
|
||||||
|
echo 'DB Version: ' . $version . "\n";
|
||||||
|
|
||||||
|
$hasUsers = $pdo->query("SHOW TABLES LIKE 'users'")->fetchColumn();
|
||||||
|
echo 'Tabelle users vorhanden: ' . ($hasUsers ? 'ja' : 'nein') . "\n";
|
||||||
|
|
||||||
|
if ($hasUsers) {
|
||||||
|
$count = $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||||
|
echo 'Benutzer in users: ' . $count . "\n";
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
echo "DB Verbindung: fehlgeschlagen\n";
|
||||||
|
echo 'Fehler: ' . $e->getMessage() . "\n";
|
||||||
|
}
|
||||||
36
httpdocs/index.php
Normal file
36
httpdocs/index.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/app/bootstrap.php';
|
||||||
|
require __DIR__ . '/app/views.php';
|
||||||
|
|
||||||
|
if (!function_exists('renderHeader') || !function_exists('renderAppShell')) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo 'Application bootstrap failed.';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pdo && !isInstalled($pdo)) {
|
||||||
|
header('Location: /install.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = $_GET['page'] ?? 'dashboard';
|
||||||
|
if (!currentUser() && !in_array($page, ['login', 'reset_password'], true)) {
|
||||||
|
$page = 'login';
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = currentUser();
|
||||||
|
if ($current && ($current['role'] ?? '') === 'member' && in_array($page, ['members', 'admin', 'administration'], true)) {
|
||||||
|
$page = 'dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader(pageTitle($page));
|
||||||
|
if ($page === 'login') {
|
||||||
|
renderLoginPage();
|
||||||
|
} elseif ($page === 'reset_password') {
|
||||||
|
renderResetPasswordPage();
|
||||||
|
} else {
|
||||||
|
renderAppShell($page);
|
||||||
|
}
|
||||||
|
renderFooter();
|
||||||
71
httpdocs/install.php
Normal file
71
httpdocs/install.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/app/bootstrap.php';
|
||||||
|
require __DIR__ . '/app/views.php';
|
||||||
|
|
||||||
|
if ($pdo && isInstalled($pdo)) {
|
||||||
|
header('Location: /');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$pdo) {
|
||||||
|
renderHeader('Installation');
|
||||||
|
?>
|
||||||
|
<div class="container-tight py-4">
|
||||||
|
<div class="alert alert-danger">Keine Datenbankverbindung vorhanden. Prüfe DB_HOST, DB_NAME, DB_USER und DB_PASS.</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
renderFooter();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$installError = null;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'setup_admin' && $pdo) {
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO users (firstname, lastname, email, password_hash, role) VALUES (?, ?, ?, ?, "admin")');
|
||||||
|
$stmt->execute([
|
||||||
|
trim((string)$_POST['firstname']),
|
||||||
|
trim((string)$_POST['lastname']),
|
||||||
|
trim((string)$_POST['email']),
|
||||||
|
password_hash((string)$_POST['password'], PASSWORD_DEFAULT),
|
||||||
|
]);
|
||||||
|
header('Location: /?page=login');
|
||||||
|
exit;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$installError = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader('Installation');
|
||||||
|
?>
|
||||||
|
<div class="container-tight py-4">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h1>Installation</h1>
|
||||||
|
<p class="text-secondary">Erstes Admin-Konto anlegen</p>
|
||||||
|
</div>
|
||||||
|
<?php if ($installError): ?>
|
||||||
|
<div class="alert alert-danger"><?= htmlspecialchars($installError) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!$pdo): ?>
|
||||||
|
<div class="alert alert-danger">Keine Datenbankverbindung vorhanden. Prüfe DB_HOST, DB_NAME, DB_USER und DB_PASS.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="card card-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="setup_admin">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3"><label class="form-label">Vorname</label><input class="form-control" name="firstname" required></div>
|
||||||
|
<div class="col-md-6 mb-3"><label class="form-label">Nachname</label><input class="form-control" name="lastname" required></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3"><label class="form-label">E-Mail</label><input class="form-control" name="email" type="email" required></div>
|
||||||
|
<div class="mb-3"><label class="form-label">Passwort</label><input class="form-control" name="password" type="password" required></div>
|
||||||
|
<button class="btn btn-success w-100" type="submit">Admin anlegen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
renderFooter();
|
||||||
BIN
httpdocs/logo neu.png
Normal file
BIN
httpdocs/logo neu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
20
reset-install.php
Normal file
20
reset-install.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/app/bootstrap.php';
|
||||||
|
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
|
||||||
|
if (!$pdo) {
|
||||||
|
echo "Keine Datenbankverbindung vorhanden.\n";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->exec('DELETE FROM work_logs');
|
||||||
|
$pdo->exec('DELETE FROM users');
|
||||||
|
echo "Installation zurueckgesetzt. users und work_logs wurden geleert.\n";
|
||||||
|
echo "Jetzt install.php aufrufen.\n";
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
echo 'Fehler: ' . $e->getMessage() . "\n";
|
||||||
|
}
|
||||||
49
schema.sql
Normal file
49
schema.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
firstname VARCHAR(100) NOT NULL,
|
||||||
|
lastname VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(190) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role ENUM('member', 'editor', 'admin') NOT NULL DEFAULT 'member',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS work_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
member_id INT NOT NULL,
|
||||||
|
actor_id INT NOT NULL,
|
||||||
|
hours DECIMAL(5,2) NOT NULL,
|
||||||
|
note VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX (member_id),
|
||||||
|
INDEX (actor_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
setting_key VARCHAR(100) PRIMARY KEY,
|
||||||
|
setting_value TEXT NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS password_resets (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX (user_id),
|
||||||
|
INDEX (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
actor_id INT NOT NULL,
|
||||||
|
action VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX (actor_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
INSERT INTO users (firstname, lastname, email, password_hash, role)
|
||||||
|
VALUES
|
||||||
|
('Max', 'Mustermann', 'max@example.com', '$2y$10$Q4NnU8X3uGQfM4p4RkWnOe7d5Vw5O3p5uT4Q2a5W0uX1fJ1cR5h3C', 'admin'),
|
||||||
|
('Lisa', 'Beispiel', 'lisa@example.com', '$2y$10$Q4NnU8X3uGQfM4p4RkWnOe7d5Vw5O3p5uT4Q2a5W0uX1fJ1cR5h3C', 'editor'),
|
||||||
|
('Tom', 'Mitglied', 'tom@example.com', '$2y$10$Q4NnU8X3uGQfM4p4RkWnOe7d5Vw5O3p5uT4Q2a5W0uX1fJ1cR5h3C', 'member');
|
||||||
3832
session-ses_1b8d.md
Normal file
3832
session-ses_1b8d.md
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user