Version 1.5

This commit is contained in:
Erik Thiele
2026-05-22 15:14:13 +02:00
parent 9afc6c39a6
commit 820753f089
16 changed files with 6151 additions and 0 deletions

52
AGENTS.md Normal file
View 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
View 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
View 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
View 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.

View File

@@ -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
- CSV-Export und CSV-Import für Benutzerdaten inklusive geleisteter Stunden
- 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
- 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
- Benutzerdaten 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
- Alle Arbeitsstunden auf 0 zurücksetzen
- Logansicht mit 50 Einträgen pro Seite
@@ -115,6 +118,24 @@ Beispiel-Kopfzeile:
`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
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.
## Handouts
Für die Weitergabe an Anwender stehen folgende Anleitungen bereit:
- `ANLEITUNG_MITGLIEDER.md`
- `ANLEITUNG_BEARBEITER.md`
- `ANLEITUNG_ADMIN.md`
## Dateien
- `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/views.php` Tabler-UI
- `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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

20
reset-install.php Normal file
View 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
View 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

File diff suppressed because one or more lines are too long