Version 1.6 - Strato Verzeichnis unterhalb tc-ingelfingen

This commit is contained in:
Erik Thiele
2026-05-23 23:32:00 +02:00
parent 54ae12771e
commit 7c5790e856
6 changed files with 69 additions and 32 deletions

View File

@@ -23,27 +23,31 @@ Strato-taugliche PHP-Webanwendung für die Arbeitszeiterfassung des TC Ingelfing
- Separater CSV-Export für alle Stundenbuchungen
- SQL-Dump-Export und SQL-Dump-Wiederherstellung für vollständige App-Backups
- Separater CSV-Export der Logansicht für Administratoren
- Admin-Funktion zum Löschen der Audit-Logeinträge
- Admin-Funktion zum Zurücksetzen aller Arbeitsstunden auf 0
- Tabler CSS via CDN für UI, Cards, Tabellen und Formulare
## Strato-Setup
1. Inhalt von `httpdocs/` auf das Strato-Webverzeichnis hochladen
2. Optional diese Umgebungsvariablen oder Konfigurationswerte setzen:
1. Inhalt von `httpdocs/` in das Webverzeichnis der App hochladen
2. Wenn die App in einem Unterordner wie `/arbeitsstunden/` läuft, muss genau dieser Ordner das Webroot der App enthalten
3. `config.php` nicht in `httpdocs/`, sondern eine Ebene darüber ablegen
4. Optional diese Umgebungsvariablen oder Konfigurationswerte setzen:
- `DB_HOST`
- `DB_NAME`
- `DB_USER`
- `DB_PASS`
- `SETUP_KEY` optional, schützt die Ersteinrichtung
3. PHP 8.1+ und `pdo_mysql` aktivieren
5. PHP 8.1+ und `pdo_mysql` aktivieren
## Strato Schritte
1. DB in Strato anlegen
2. `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASS` setzen
3. `httpdocs/install.php` öffnen und erstes Admin-Konto anlegen
4. Danach mit dem neuen Admin einloggen
5. Falls keine DB konfiguriert ist, nutzt die App Demo-Zugänge nur zum Anzeigen
2. `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASS` setzen oder `config.php` oberhalb des Webroots anlegen
3. Dateien aus `httpdocs/` in den Zielordner der App hochladen, z. B. `https://tc-ingelfingen.de/arbeitsstunden/`
4. `install.php` über den App-Pfad öffnen, z. B. `/arbeitsstunden/install.php`, und erstes Admin-Konto anlegen
5. Danach mit dem neuen Admin einloggen
6. Falls keine DB konfiguriert ist, nutzt die App Demo-Zugänge nur zum Anzeigen
## Datenbank
@@ -88,6 +92,7 @@ Die Anwendung ist so aufgebaut, dass sie mit oder ohne DB läuft. Bei gesetzter
- Alle Arbeitsstunden auf 0 zurücksetzen
- Logansicht mit 50 Einträgen pro Seite
- Logansicht als CSV exportieren
- Audit-Logeinträge direkt in der Administration löschen
- Audit-Log für Verwaltungsaktionen sowie Login und Logout
## Listen Und Paginierung
@@ -166,6 +171,7 @@ Hinweis:
- Die aktuelle Implementierung nutzt PHP `mail()` fuer den Versand.
- Wenn das Hosting E-Mails direkt versenden kann, funktioniert der Passwort-Reset auch ohne vollstaendige SMTP-Implementierung.
- Die Datei `config.php` sollte nicht in `httpdocs/`, sondern im Projektwurzelverzeichnis liegen.
- Bei einem Deployment in einem Unterordner wie `/arbeitsstunden/` liegt `config.php` eine Ebene über diesem Webroot.
## Ersteinrichtung
@@ -185,6 +191,7 @@ Für die Weitergabe an Anwender stehen folgende Anleitungen bereit:
- `httpdocs/install.php` Ersteinrichtung
- `httpdocs/app/bootstrap.php` DB, Login und Business-Logik
- `httpdocs/app/views.php` Tabler-UI
- `config.php` lokale Konfiguration oberhalb des Webroots
- `config.php.example` Vorlage für DB- und Mail-Konfiguration
- `ANLEITUNG_MITGLIEDER.md` Handout für Mitglieder
- `ANLEITUNG_BEARBEITER.md` Handout für Bearbeiter

View File

@@ -1,9 +1,9 @@
<?php
declare(strict_types=1);
return [
'author' => 'OpenCode',
'author' => 'Erik Thiele',
'copyright' => 'Copyright 2026 TC-Ingelfingen',
'app_version' => '1.0.0',
'app_version' => '1.6.0',
'db' => [
'host' => 'database-5020507124.webspace-host.com',
'name' => 'dbs15701183',

View File

@@ -115,6 +115,26 @@ function appUrl(): string
return $scheme . '://' . $host;
}
function basePath(): string
{
$scriptName = $_SERVER['SCRIPT_NAME'] ?? '/index.php';
$dir = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');
return $dir === '' ? '' : $dir;
}
function appPath(string $path = ''): string
{
$base = basePath();
if ($path === '') {
return $base === '' ? '/' : $base . '/';
}
if ($path[0] === '?') {
return ($base === '' ? '/' : $base . '/') . $path;
}
$path = ltrim($path, '/');
return $base === '' ? '/' . $path : $base . '/' . $path;
}
function sendPasswordResetMail(array $smtpConfig, string $toEmail, string $resetUrl): bool
{
if (($smtpConfig['from_email'] ?? '') === '') {
@@ -497,7 +517,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
addAuditLog($pdo, (int)$found['id'], 'Anmeldung erfolgreich.');
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');
header('Location: ' . appPath('?page=home'));
exit;
}
$error = 'Login fehlgeschlagen.';
@@ -557,7 +577,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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');
header('Location: ' . appPath('?page=login'));
exit;
}
}
@@ -827,6 +847,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$error = 'Log-Export ist ohne Datenbank nicht verfuegbar.';
}
if ($user && $action === 'clear_audit_logs' && $user['role'] === 'admin') {
if ($pdo) {
$pdo->exec('TRUNCATE TABLE audit_logs');
addAuditLog($pdo, (int)$user['id'], 'Audit-Log geleert.');
$notice = 'Logeintraege wurden geloescht.';
} else {
$error = 'Logeintraege koennen ohne Datenbank nicht geloescht werden.';
}
}
if ($user && $action === 'import_sql_dump' && $user['role'] === 'admin') {
if (!$pdo) {
$error = 'SQL-Wiederherstellung ist ohne Datenbank nicht verfuegbar.';
@@ -928,7 +958,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($user && $action === 'logout') {
addAuditLog($pdo, (int)$user['id'], 'Abmeldung erfolgt.');
session_destroy();
header('Location: /?page=login');
header('Location: ' . appPath('?page=login'));
exit;
}
}

View File

@@ -477,7 +477,7 @@ function renderLoginPage(): void
?>
<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>
<div class="mb-3"><img src="<?= htmlspecialchars(appPath('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>
@@ -519,7 +519,7 @@ function renderResetPasswordPage(): void
<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 class="text-center mt-3"><a href="<?= htmlspecialchars(appPath('?page=login')) ?>">Zurück zur Anmeldung</a></div>
</div>
</div>
</div>
@@ -565,12 +565,12 @@ function renderAppShell(string $page): void
<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>
<a class="navbar-brand navbar-brand-wrap" href="<?= htmlspecialchars(appPath('')) ?>">
<span class="navbar-mark" aria-hidden="true"><img src="<?= htmlspecialchars(appPath('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;">
<a class="btn btn-outline-secondary d-inline-flex align-items-center gap-2 text-decoration-none" href="<?= htmlspecialchars(appPath('?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>
@@ -583,19 +583,19 @@ function renderAppShell(string $page): void
<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; ?>
<a class="<?= $page === 'home' ? 'active' : '' ?>" href="<?= htmlspecialchars(appPath('?page=home')) ?>"><i class="ti ti-layout-dashboard"></i><span>Dashboard</span></a>
<a class="<?= $page === 'profile' ? 'active' : '' ?>" href="<?= htmlspecialchars(appPath('?page=profile')) ?>"><i class="ti ti-user-circle"></i><span>Profil</span></a>
<?php if (($current['role'] ?? '') !== 'member'): ?><a class="<?= $page === 'members' ? 'active' : '' ?>" href="<?= htmlspecialchars(appPath('?page=members')) ?>"><i class="ti ti-user-plus"></i><span>Benutzerverwaltung</span></a><?php endif; ?>
<a class="<?= $page === 'booking' ? 'active' : '' ?>" href="<?= htmlspecialchars(appPath('?page=booking')) ?>"><i class="ti ti-clock-plus"></i><span>Stundenbuchung</span></a>
<?php if (($current['role'] ?? '') !== 'member'): ?><a class="<?= $page === 'admin' ? 'active' : '' ?>" href="<?= htmlspecialchars(appPath('?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="<?= htmlspecialchars(appPath('?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'): ?>
<?php if ($page === 'home'): ?>
<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>
@@ -631,7 +631,7 @@ function renderAppShell(string $page): void
<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 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><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 else: ?><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="<?= htmlspecialchars(appPath('?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="<?= htmlspecialchars(appPath('?page=members&member_page=' . min((int)($memberUsers['total_pages'] ?? 1), (int)($memberUsers['page'] ?? 1) + 1))) ?>">Weiter</a></div><?php endif; ?></div></div></div>
</div>
<?php endif; ?>
@@ -645,7 +645,7 @@ function renderAppShell(string $page): void
<?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 (hasRole($current, ['editor', '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 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 (hasRole($current, ['editor', '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="<?= htmlspecialchars(appPath('?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="<?= htmlspecialchars(appPath('?page=booking&booking_page=' . min((int)($bookings['total_pages'] ?? 1), (int)($bookings['page'] ?? 1) + 1))) ?>">Weiter</a></div><?php endif; ?></div></div></div>
</div>
<?php endif; ?>
@@ -671,7 +671,7 @@ function renderAppShell(string $page): void
<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 admin-actions"><h3 class="section-title"><i class="ti ti-history"></i><span>Logansicht</span></h3><form method="post" class="mb-3"><input type="hidden" name="action" value="export_logs_csv"><button class="btn btn-primary" type="submit">Log als CSV exportieren</button></form><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 class="row row-cards mt-2"><div class="col-lg-12"><div class="card"><div class="card-body admin-actions"><h3 class="section-title"><i class="ti ti-history"></i><span>Logansicht</span></h3><form method="post" class="mb-3"><input type="hidden" name="action" value="export_logs_csv"><button class="btn btn-primary" type="submit">Log als CSV exportieren</button></form><form method="post" class="mb-3" onsubmit="return confirm('Wirklich alle Logeinträge löschen?');"><input type="hidden" name="action" value="clear_audit_logs"><button class="btn btn-outline-danger" type="submit">Logeinträge löschen</button></form><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="<?= htmlspecialchars(appPath('?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="<?= htmlspecialchars(appPath('?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; ?>

View File

@@ -11,18 +11,18 @@ if (!function_exists('renderHeader') || !function_exists('renderAppShell')) {
}
if ($pdo && !isInstalled($pdo)) {
header('Location: /install.php');
header('Location: ' . appPath('install.php'));
exit;
}
$page = $_GET['page'] ?? 'dashboard';
$page = $_GET['page'] ?? 'home';
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';
$page = 'home';
}
renderHeader(pageTitle($page));

View File

@@ -5,7 +5,7 @@ require __DIR__ . '/app/bootstrap.php';
require __DIR__ . '/app/views.php';
if ($pdo && isInstalled($pdo)) {
header('Location: /');
header('Location: ' . appPath(''));
exit;
}
@@ -31,7 +31,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'setup
trim((string)$_POST['email']),
password_hash((string)$_POST['password'], PASSWORD_DEFAULT),
]);
header('Location: /?page=login');
header('Location: ' . appPath('?page=login'));
exit;
} catch (Throwable $e) {
$installError = $e->getMessage();