design anpassung

This commit is contained in:
2026-06-23 00:06:31 +02:00
parent 1891ec0a51
commit cbea23083d
30 changed files with 785 additions and 547 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
APP_NAME="Kaffeekasse SaaS" APP_NAME="Kaffeeliste"
APP_ENV=production APP_ENV=production
APP_DEBUG=0 APP_DEBUG=0
APP_URL=http://localhost:8080 APP_URL=http://localhost:8080
@@ -17,14 +17,14 @@ DB_PASS=
MAIL_FROM=noreply@example.com MAIL_FROM=noreply@example.com
MAIL_REPLY_TO= MAIL_REPLY_TO=
MAIL_SUBJECT_PREFIX="[Kaffeekasse] " MAIL_SUBJECT_PREFIX="[Kaffeeliste] "
MAIL_LOG_FALLBACK=1 MAIL_LOG_FALLBACK=1
MAIL_LOG_FILE= MAIL_LOG_FILE=
PASSWORD_RESET_URL=http://localhost:8080/reset-password?token={{token}} PASSWORD_RESET_URL=http://localhost:8080/reset-password?token={{token}}
PASSWORD_RESET_TTL_MINUTES=60 PASSWORD_RESET_TTL_MINUTES=60
PASSWORD_RESET_MAX_ACTIVE_TOKENS=3 PASSWORD_RESET_MAX_ACTIVE_TOKENS=3
PASSWORD_RESET_SUBJECT="Passwort zuruecksetzen" PASSWORD_RESET_SUBJECT="Passwort zurücksetzen"
RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5 RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5
RATE_LIMIT_LOGIN_WINDOW_SECONDS=900 RATE_LIMIT_LOGIN_WINDOW_SECONDS=900
+7 -7
View File
@@ -49,7 +49,7 @@ final class PlatformController
} }
$this->view->render('home/index', [ $this->view->render('home/index', [
'title' => 'Kaffeekasse fuer Teams, Vereine und Coworking', 'title' => 'Kaffeeliste für Teams, Vereine und Büroküchen',
'setupComplete' => $setup->isInstalled(), 'setupComplete' => $setup->isInstalled(),
'stats' => $stats, 'stats' => $stats,
'allowPublicRegistration' => $this->config['allow_public_registration'] ?? false, 'allowPublicRegistration' => $this->config['allow_public_registration'] ?? false,
@@ -77,7 +77,7 @@ final class PlatformController
try { try {
$setup->install($_POST); $setup->install($_POST);
$this->session->flash('success', 'Die Installation wurde abgeschlossen. Jetzt kannst du dich als Plattform-Admin anmelden.'); $this->session->flash('success', 'Die Installation wurde abgeschlossen. Sie können sich jetzt als Plattform-Administrator anmelden.');
Response::redirect('/admin/login'); Response::redirect('/admin/login');
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
remember_old_input($_POST); remember_old_input($_POST);
@@ -93,12 +93,12 @@ final class PlatformController
} }
if (!$this->allowsPublicRegistration()) { if (!$this->allowsPublicRegistration()) {
$this->session->flash('error', 'Die offene Registrierung ist derzeit deaktiviert. Fuer einen Pilotzugang bitte direkt den Betreiber kontaktieren.'); $this->session->flash('error', 'Die offene Registrierung ist derzeit deaktiviert. Bitte kontaktieren Sie den Betreiber.');
Response::redirect('/'); Response::redirect('/');
} }
$this->view->render('home/register', [ $this->view->render('home/register', [
'title' => 'Mandant registrieren', 'title' => 'Kaffeeliste registrieren',
]); ]);
} }
@@ -122,7 +122,7 @@ final class PlatformController
$service = new TenantRegistrationService($pdo, $audit); $service = new TenantRegistrationService($pdo, $audit);
$slug = $service->register($_POST); $slug = $service->register($_POST);
$this->clearScopes($rateLimiter, 'registration', $scopes); $this->clearScopes($rateLimiter, 'registration', $scopes);
$this->session->flash('success', 'Dein Mandant wurde angelegt. Du kannst dich jetzt anmelden.'); $this->session->flash('success', 'Die Kaffeeliste wurde angelegt. Sie können sich jetzt anmelden.');
Response::redirect('/t/' . rawurlencode($slug) . '/login'); Response::redirect('/t/' . rawurlencode($slug) . '/login');
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
if ($this->database->isConfigured()) { if ($this->database->isConfigured()) {
@@ -141,7 +141,7 @@ final class PlatformController
public function adminLoginForm(): void public function adminLoginForm(): void
{ {
$this->view->render('admin/login', [ $this->view->render('admin/login', [
'title' => 'Plattform-Login', 'title' => 'Kaffeelisten verwalten',
]); ]);
} }
@@ -257,7 +257,7 @@ final class PlatformController
$passwordConfirm = (string) ($_POST['password_confirm'] ?? ''); $passwordConfirm = (string) ($_POST['password_confirm'] ?? '');
if ($password !== $passwordConfirm) { if ($password !== $passwordConfirm) {
throw new RuntimeException('Die Passwort-Bestaetigung stimmt nicht ueberein.'); throw new RuntimeException('Die Passwort-Bestätigung stimmt nicht überein.');
} }
$pdo = $this->database->pdo(); $pdo = $this->database->pdo();
+34 -31
View File
@@ -33,7 +33,7 @@ final class TenantController
$tenant = $this->tenant($tenantSlug); $tenant = $this->tenant($tenantSlug);
$this->view->render('tenant/login', [ $this->view->render('tenant/login', [
'title' => 'Tenant-Login', 'title' => 'Kaffeelisten-Login',
'tenant' => $tenant, 'tenant' => $tenant,
]); ]);
} }
@@ -67,11 +67,11 @@ final class TenantController
if (!$this->isTenantManager($authState) && !$this->selfServiceAllowed((int) $tenant['id'])) { if (!$this->isTenantManager($authState) && !$this->selfServiceAllowed((int) $tenant['id'])) {
$auth->logout(); $auth->logout();
throw new RuntimeException('Die digitale Selbstbuchung ist fuer diesen Mandanten derzeit deaktiviert.'); throw new RuntimeException('Die Buchung ist für diese Kaffeeliste derzeit deaktiviert.');
} }
$this->clearRateLimitScopes($rateLimiter, $scopes); $this->clearRateLimitScopes($rateLimiter, $scopes);
$this->session->flash('success', 'Willkommen zur digitalen Kaffeeliste.'); $this->session->flash('success', 'Willkommen in Ihrer Kaffeeliste.');
Response::redirect(tenant_url($tenantSlug)); Response::redirect(tenant_url($tenantSlug));
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
remember_old_input($_POST); remember_old_input($_POST);
@@ -86,7 +86,7 @@ final class TenantController
$pdo = $this->database->pdo(); $pdo = $this->database->pdo();
$auth = new Auth($pdo, $this->session); $auth = new Auth($pdo, $this->session);
$auth->logout(); $auth->logout();
$this->session->flash('success', 'Du wurdest abgemeldet.'); $this->session->flash('success', 'Sie wurden abgemeldet.');
Response::redirect(tenant_url($tenantSlug, 'login')); Response::redirect(tenant_url($tenantSlug, 'login'));
} }
@@ -125,6 +125,7 @@ final class TenantController
m.display_name AS member_name, m.display_name AS member_name,
p.name AS product_name, p.name AS product_name,
s.name AS source_name, s.name AS source_name,
s.code AS source_code,
CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed
FROM consumption_events c FROM consumption_events c
INNER JOIN members m ON m.id = c.member_id INNER JOIN members m ON m.id = c.member_id
@@ -159,7 +160,7 @@ final class TenantController
$balancesStatement->execute(['tenant_id' => $tenant['id']]); $balancesStatement->execute(['tenant_id' => $tenant['id']]);
} else { } else {
if ($currentMember === null) { if ($currentMember === null) {
throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.'); throw new RuntimeException('Für diesen Benutzer ist kein aktives Mitglied in der Kaffeeliste hinterlegt.');
} }
$summaryStatement = $pdo->prepare( $summaryStatement = $pdo->prepare(
@@ -193,6 +194,7 @@ final class TenantController
m.display_name AS member_name, m.display_name AS member_name,
p.name AS product_name, p.name AS product_name,
s.name AS source_name, s.name AS source_name,
s.code AS source_code,
CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed
FROM consumption_events c FROM consumption_events c
INNER JOIN members m ON m.id = c.member_id INNER JOIN members m ON m.id = c.member_id
@@ -234,7 +236,7 @@ final class TenantController
} }
$this->view->render('tenant/dashboard', [ $this->view->render('tenant/dashboard', [
'title' => 'Dashboard', 'title' => 'Kaffeekasse',
'tenant' => $tenant, 'tenant' => $tenant,
'authState' => $authState, 'authState' => $authState,
'summary' => $summary, 'summary' => $summary,
@@ -271,11 +273,11 @@ final class TenantController
if ($email !== '') { if ($email !== '') {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Die E-Mail-Adresse ist ungueltig.'); throw new RuntimeException('Die E-Mail-Adresse ist ungültig.');
} }
if ($loginPassword === '' || mb_strlen($loginPassword) < 12) { if ($loginPassword === '' || mb_strlen($loginPassword) < 12) {
throw new RuntimeException('Fuer Login-faehige Mitglieder ist ein Passwort mit mindestens 12 Zeichen noetig.'); throw new RuntimeException('Für Mitglieder mit Login ist ein Passwort mit mindestens 12 Zeichen nötig.');
} }
$existingUserStatement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1'); $existingUserStatement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
@@ -319,7 +321,7 @@ final class TenantController
]); ]);
(new AuditService($pdo))->log((int) $tenant['id'], (int) $authState['user_id'], 'member.created', 'member', (int) $pdo->lastInsertId(), 'success'); (new AuditService($pdo))->log((int) $tenant['id'], (int) $authState['user_id'], 'member.created', 'member', (int) $pdo->lastInsertId(), 'success');
$this->session->flash('success', 'Das Mitglied wurde gespeichert.'); $this->session->flash('success', 'Das Mitglied wurde angelegt.');
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
remember_old_input($_POST); remember_old_input($_POST);
$this->session->flash('error', $throwable->getMessage()); $this->session->flash('error', $throwable->getMessage());
@@ -406,7 +408,7 @@ final class TenantController
$productsStatement->execute(['tenant_id' => $tenant['id']]); $productsStatement->execute(['tenant_id' => $tenant['id']]);
$this->view->render('tenant/products', [ $this->view->render('tenant/products', [
'title' => 'Produkte', 'title' => 'Produkte und Preise',
'tenant' => $tenant, 'tenant' => $tenant,
'authState' => $authState, 'authState' => $authState,
'products' => $productsStatement->fetchAll(), 'products' => $productsStatement->fetchAll(),
@@ -429,7 +431,7 @@ final class TenantController
if ($action === 'reverse_booking') { if ($action === 'reverse_booking') {
if (!$isManager) { if (!$isManager) {
throw new RuntimeException('Nur Admins duerfen Buchungen stornieren.'); throw new RuntimeException('Nur Administratorinnen und Administratoren dürfen Buchungen stornieren.');
} }
$service->reverseLedgerEntry( $service->reverseLedgerEntry(
@@ -453,7 +455,7 @@ final class TenantController
(float) ($_POST['quantity'] ?? 1), (float) ($_POST['quantity'] ?? 1),
trim((string) ($_POST['notes'] ?? '')) trim((string) ($_POST['notes'] ?? ''))
); );
$this->session->flash('success', 'Die Buchung wurde gespeichert.'); $this->session->flash('success', 'Die Kaffeebuchung wurde gespeichert.');
} }
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
remember_old_input($_POST); remember_old_input($_POST);
@@ -472,6 +474,7 @@ final class TenantController
m.display_name AS member_name, m.display_name AS member_name,
p.name AS product_name, p.name AS product_name,
s.name AS source_name, s.name AS source_name,
s.code AS source_code,
le.id AS ledger_entry_id, le.id AS ledger_entry_id,
CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed
FROM consumption_events c FROM consumption_events c
@@ -492,7 +495,7 @@ final class TenantController
if (!$isManager) { if (!$isManager) {
if ($currentMember === null) { if ($currentMember === null) {
throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.'); throw new RuntimeException('Für diesen Benutzer ist kein aktives Mitglied in der Kaffeeliste hinterlegt.');
} }
$sql .= ' AND c.member_id = :member_id'; $sql .= ' AND c.member_id = :member_id';
@@ -505,7 +508,7 @@ final class TenantController
$bookingsStatement->execute($params); $bookingsStatement->execute($params);
$this->view->render('tenant/bookings', [ $this->view->render('tenant/bookings', [
'title' => 'Digitale Buchungen', 'title' => 'Kaffee buchen',
'tenant' => $tenant, 'tenant' => $tenant,
'authState' => $authState, 'authState' => $authState,
'members' => $members, 'members' => $members,
@@ -550,7 +553,7 @@ final class TenantController
} }
} else { } else {
if ($currentMember === null) { if ($currentMember === null) {
throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.'); throw new RuntimeException('Für diesen Benutzer ist kein aktives Mitglied in der Kaffeeliste hinterlegt.');
} }
$memberId = (int) $currentMember['id']; $memberId = (int) $currentMember['id'];
@@ -614,7 +617,7 @@ final class TenantController
(int) $authState['user_id'], (int) $authState['user_id'],
trim((string) ($_POST['notes'] ?? '')) trim((string) ($_POST['notes'] ?? ''))
); );
$this->session->flash('success', 'Die Einzahlung wurde gebucht.'); $this->session->flash('success', 'Die Einzahlung wurde gutgeschrieben.');
} }
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
remember_old_input($_POST); remember_old_input($_POST);
@@ -645,7 +648,7 @@ final class TenantController
$paymentsStatement->execute(['tenant_id' => $tenant['id']]); $paymentsStatement->execute(['tenant_id' => $tenant['id']]);
$this->view->render('tenant/payments', [ $this->view->render('tenant/payments', [
'title' => 'Einzahlungen', 'title' => 'Geld einzahlen',
'tenant' => $tenant, 'tenant' => $tenant,
'authState' => $authState, 'authState' => $authState,
'members' => $members, 'members' => $members,
@@ -688,10 +691,10 @@ final class TenantController
trim((string) ($_POST['note'] ?? '')) ?: null, trim((string) ($_POST['note'] ?? '')) ?: null,
(int) $authState['user_id'] (int) $authState['user_id']
); );
$this->session->flash('success', 'Die Papierlisten-Position wurde gespeichert.'); $this->session->flash('success', 'Der Eintrag aus der Papierliste wurde gespeichert.');
} elseif ($action === 'post_sheet') { } elseif ($action === 'post_sheet') {
$service->postPaperSheet((int) $tenant['id'], (int) ($_POST['paper_sheet_id'] ?? 0), (int) $authState['user_id']); $service->postPaperSheet((int) $tenant['id'], (int) ($_POST['paper_sheet_id'] ?? 0), (int) $authState['user_id']);
$this->session->flash('success', 'Die Papierliste wurde ins Ledger uebernommen.'); $this->session->flash('success', 'Die Papierliste wurde als Buchungen übernommen.');
} elseif ($action === 'reverse_sheet') { } elseif ($action === 'reverse_sheet') {
$service->reversePaperSheet( $service->reversePaperSheet(
(int) $tenant['id'], (int) $tenant['id'],
@@ -699,7 +702,7 @@ final class TenantController
(int) $authState['user_id'], (int) $authState['user_id'],
trim((string) ($_POST['reason'] ?? '')) ?: null trim((string) ($_POST['reason'] ?? '')) ?: null
); );
$this->session->flash('success', 'Die verbuchte Papierliste wurde storniert.'); $this->session->flash('success', 'Die übernommene Papierliste wurde storniert.');
} }
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
remember_old_input($_POST); remember_old_input($_POST);
@@ -745,7 +748,7 @@ final class TenantController
$linesStatement->execute(['tenant_id' => $tenant['id']]); $linesStatement->execute(['tenant_id' => $tenant['id']]);
$this->view->render('tenant/paper-sheets', [ $this->view->render('tenant/paper-sheets', [
'title' => 'Papierlisten', 'title' => 'Papierliste nachtragen',
'tenant' => $tenant, 'tenant' => $tenant,
'authState' => $authState, 'authState' => $authState,
'members' => $members, 'members' => $members,
@@ -775,7 +778,7 @@ final class TenantController
trim((string) ($_POST['location_label'] ?? '')) ?: null, trim((string) ($_POST['location_label'] ?? '')) ?: null,
(int) $authState['user_id'] (int) $authState['user_id']
); );
$this->session->flash('success', 'Das RFID-Geraet wurde vorbereitet. Token: ' . $token); $this->session->flash('success', 'Der Kartenleser wurde angelegt. Das Geräte-Token wird einmalig angezeigt: ' . $token);
} elseif ($action === 'assign_tag') { } elseif ($action === 'assign_tag') {
$service->assignTag( $service->assignTag(
(int) $tenant['id'], (int) $tenant['id'],
@@ -784,7 +787,7 @@ final class TenantController
trim((string) ($_POST['label'] ?? '')) ?: null, trim((string) ($_POST['label'] ?? '')) ?: null,
(int) $authState['user_id'] (int) $authState['user_id']
); );
$this->session->flash('success', 'Die RFID-Karte wurde zugewiesen.'); $this->session->flash('success', 'Die Karte wurde zugewiesen.');
} }
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
remember_old_input($_POST); remember_old_input($_POST);
@@ -816,7 +819,7 @@ final class TenantController
$events->execute(['tenant_id' => $tenant['id']]); $events->execute(['tenant_id' => $tenant['id']]);
$this->view->render('tenant/rfid', [ $this->view->render('tenant/rfid', [
'title' => 'RFID-Roadmap', 'title' => 'Kartenleser einrichten',
'tenant' => $tenant, 'tenant' => $tenant,
'authState' => $authState, 'authState' => $authState,
'members' => $members, 'members' => $members,
@@ -862,7 +865,7 @@ final class TenantController
$tenant = (new TenantContext($pdo))->bySlug($tenantSlug); $tenant = (new TenantContext($pdo))->bySlug($tenantSlug);
if (!$tenant || $tenant['status'] === 'suspended') { if (!$tenant || $tenant['status'] === 'suspended') {
throw new RuntimeException('Der Mandant ist nicht verfuegbar.'); throw new RuntimeException('Die Kaffeeliste ist nicht verfügbar.');
} }
return $tenant; return $tenant;
@@ -880,7 +883,7 @@ final class TenantController
} }
if ($roles !== [] && !$auth->requireRole($roles)) { if ($roles !== [] && !$auth->requireRole($roles)) {
$this->session->flash('error', 'Dafuer fehlen die passenden Rechte.'); $this->session->flash('error', 'Dafür fehlen die passenden Rechte.');
Response::redirect(tenant_url($tenantSlug)); Response::redirect(tenant_url($tenantSlug));
} }
@@ -893,7 +896,7 @@ final class TenantController
if (!$this->isTenantManager($authState) && !$this->selfServiceAllowed((int) $tenant['id'])) { if (!$this->isTenantManager($authState) && !$this->selfServiceAllowed((int) $tenant['id'])) {
$auth->logout(); $auth->logout();
$this->session->flash('error', 'Die digitale Selbstbuchung ist fuer diesen Mandanten derzeit deaktiviert.'); $this->session->flash('error', 'Die Buchung ist für diese Kaffeeliste derzeit deaktiviert.');
Response::redirect(tenant_url($tenantSlug, 'login')); Response::redirect(tenant_url($tenantSlug, 'login'));
} }
@@ -968,18 +971,18 @@ final class TenantController
): int { ): int {
if ($this->isTenantManager($authState)) { if ($this->isTenantManager($authState)) {
if ($postedMemberId <= 0) { if ($postedMemberId <= 0) {
throw new RuntimeException('Bitte ein Mitglied fuer die Buchung auswaehlen.'); throw new RuntimeException('Bitte ein Mitglied für die Buchung auswählen.');
} }
return $this->assertMemberIdBelongsToTenant($tenantId, $postedMemberId); return $this->assertMemberIdBelongsToTenant($tenantId, $postedMemberId);
} }
if ($currentMember === null) { if ($currentMember === null) {
throw new RuntimeException('Fuer dieses Konto ist kein buchungsfaehiges Mitglied hinterlegt.'); throw new RuntimeException('Für dieses Konto ist kein buchungsfähiges Mitglied hinterlegt.');
} }
if ($postedMemberId > 0 && $postedMemberId !== (int) $currentMember['id']) { if ($postedMemberId > 0 && $postedMemberId !== (int) $currentMember['id']) {
throw new RuntimeException('Du darfst nur fuer dein eigenes Mitglied buchen.'); throw new RuntimeException('Sie dürfen nur für Ihr eigenes Mitglied buchen.');
} }
return (int) $currentMember['id']; return (int) $currentMember['id'];
@@ -1001,7 +1004,7 @@ final class TenantController
]); ]);
if (!$statement->fetchColumn()) { if (!$statement->fetchColumn()) {
throw new RuntimeException('Das angegebene Mitglied gehoert nicht zu diesem Mandanten.'); throw new RuntimeException('Das angegebene Mitglied gehört nicht zu dieser Kaffeeliste.');
} }
return $memberId; return $memberId;
+4 -4
View File
@@ -271,7 +271,7 @@ final class ExportService
private function assertTenantId(int $tenantId): void private function assertTenantId(int $tenantId): void
{ {
if ($tenantId <= 0) { if ($tenantId <= 0) {
throw new RuntimeException('Die Tenant-ID muss groesser als 0 sein.'); throw new RuntimeException('Die Standort-ID muss größer als 0 sein.');
} }
} }
@@ -283,7 +283,7 @@ final class ExportService
private function openCsvOutput(string $filename) private function openCsvOutput(string $filename)
{ {
if (headers_sent($file, $line)) { if (headers_sent($file, $line)) {
throw new RuntimeException(sprintf('CSV-Header koennen nicht mehr gesendet werden (%s:%d).', $file, $line)); throw new RuntimeException(sprintf('Export-Header können nicht mehr gesendet werden (%s:%d).', $file, $line));
} }
$safeFilename = $this->sanitizeFilename($filename); $safeFilename = $this->sanitizeFilename($filename);
@@ -299,7 +299,7 @@ final class ExportService
$handle = fopen('php://output', 'wb'); $handle = fopen('php://output', 'wb');
if ($handle === false) { if ($handle === false) {
throw new RuntimeException('php://output konnte nicht geoeffnet werden.'); throw new RuntimeException('Die Exportausgabe konnte nicht geöffnet werden.');
} }
fwrite($handle, "\xEF\xBB\xBF"); fwrite($handle, "\xEF\xBB\xBF");
@@ -330,7 +330,7 @@ final class ExportService
}, $row); }, $row);
if (fputcsv($handle, $values, self::CSV_DELIMITER) === false) { if (fputcsv($handle, $values, self::CSV_DELIMITER) === false) {
throw new RuntimeException('Die CSV-Zeile konnte nicht geschrieben werden.'); throw new RuntimeException('Die Exportzeile konnte nicht geschrieben werden.');
} }
} }
+15 -15
View File
@@ -278,7 +278,7 @@ final class LedgerService
$this->assertTenantMembership($tenantId, $createdByUserId); $this->assertTenantMembership($tenantId, $createdByUserId);
if (trim($title) === '') { if (trim($title) === '') {
throw new RuntimeException('Bitte einen Titel fuer die Papierliste angeben.'); throw new RuntimeException('Bitte einen Titel für die Liste angeben.');
} }
$statement = $this->pdo->prepare( $statement = $this->pdo->prepare(
@@ -345,11 +345,11 @@ final class LedgerService
$paperSheet = $sheet->fetch(); $paperSheet = $sheet->fetch();
if (!$paperSheet) { if (!$paperSheet) {
throw new RuntimeException('Die Papierliste wurde nicht gefunden.'); throw new RuntimeException('Die Liste wurde nicht gefunden.');
} }
if ($paperSheet['status'] !== 'draft') { if ($paperSheet['status'] !== 'draft') {
throw new RuntimeException('Nur Entwuerfe koennen erweitert werden.'); throw new RuntimeException('Nur Entwürfe können erweitert werden.');
} }
$statement = $this->pdo->prepare( $statement = $this->pdo->prepare(
@@ -402,11 +402,11 @@ final class LedgerService
$sheet = $sheetStatement->fetch(); $sheet = $sheetStatement->fetch();
if (!$sheet) { if (!$sheet) {
throw new RuntimeException('Die Papierliste wurde nicht gefunden.'); throw new RuntimeException('Die Liste wurde nicht gefunden.');
} }
if ($sheet['status'] !== 'draft') { if ($sheet['status'] !== 'draft') {
throw new RuntimeException('Die Papierliste wurde bereits verbucht.'); throw new RuntimeException('Die Liste wurde bereits verbucht.');
} }
$linesStatement = $this->pdo->prepare( $linesStatement = $this->pdo->prepare(
@@ -419,7 +419,7 @@ final class LedgerService
$lines = $linesStatement->fetchAll(); $lines = $linesStatement->fetchAll();
if ($lines === []) { if ($lines === []) {
throw new RuntimeException('Die Papierliste enthaelt noch keine Positionen.'); throw new RuntimeException('Die Liste enthält noch keine Positionen.');
} }
foreach ($lines as $line) { foreach ($lines as $line) {
@@ -431,7 +431,7 @@ final class LedgerService
$line['effective_at'] ?: gmdate('Y-m-d H:i:s'), $line['effective_at'] ?: gmdate('Y-m-d H:i:s'),
$actorUserId, $actorUserId,
(float) $line['quantity'], (float) $line['quantity'],
$line['note'] ?: 'Papierliste', $line['note'] ?: 'Liste',
(int) $line['id'], (int) $line['id'],
'sheet:' . $sheetId 'sheet:' . $sheetId
); );
@@ -472,7 +472,7 @@ final class LedgerService
$lines = $linesStatement->fetchAll(); $lines = $linesStatement->fetchAll();
if ($lines === []) { if ($lines === []) {
throw new RuntimeException('Es wurden keine Ledger-Zeilen fuer den Vorgang gefunden.'); throw new RuntimeException('Es wurden keine Buchungszeilen für den Vorgang gefunden.');
} }
$description = 'Storno: ' . $entry['description']; $description = 'Storno: ' . $entry['description'];
@@ -583,7 +583,7 @@ final class LedgerService
$entryIds = $statement->fetchAll(PDO::FETCH_COLUMN); $entryIds = $statement->fetchAll(PDO::FETCH_COLUMN);
if ($entryIds === []) { if ($entryIds === []) {
throw new RuntimeException('Zu dieser Papierliste wurden keine verbuchten Eintraege gefunden.'); throw new RuntimeException('Zu dieser Liste wurden keine verbuchten Einträge gefunden.');
} }
$reversed = 0; $reversed = 0;
@@ -597,13 +597,13 @@ final class LedgerService
$tenantId, $tenantId,
(int) $entryId, (int) $entryId,
$actorUserId, $actorUserId,
$reason !== null && trim($reason) !== '' ? $reason : 'Papierliste #' . $sheetId $reason !== null && trim($reason) !== '' ? $reason : 'Liste #' . $sheetId
); );
$reversed++; $reversed++;
} }
if ($reversed === 0) { if ($reversed === 0) {
throw new RuntimeException('Diese Papierliste wurde bereits vollstaendig storniert.'); throw new RuntimeException('Diese Liste wurde bereits vollständig storniert.');
} }
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.reversed', 'paper_sheet', $sheetId, 'success', [ $this->audit->log($tenantId, $actorUserId, 'paper_sheet.reversed', 'paper_sheet', $sheetId, 'success', [
@@ -633,7 +633,7 @@ final class LedgerService
$price = $statement->fetchColumn(); $price = $statement->fetchColumn();
if ($price === false) { if ($price === false) {
throw new RuntimeException('Fuer das Produkt ist kein aktiver Preis hinterlegt.'); throw new RuntimeException('Für das Produkt ist kein aktiver Preis hinterlegt.');
} }
return (int) $price; return (int) $price;
@@ -668,7 +668,7 @@ final class LedgerService
]); ]);
if (!$statement->fetchColumn()) { if (!$statement->fetchColumn()) {
throw new RuntimeException('Das gewaehlte Mitglied gehoert nicht zu diesem Mandanten.'); throw new RuntimeException('Das gewählte Mitglied gehört nicht zu diesem Standort.');
} }
} }
@@ -683,7 +683,7 @@ final class LedgerService
]); ]);
if (!$statement->fetchColumn()) { if (!$statement->fetchColumn()) {
throw new RuntimeException('Das gewaehlte Produkt gehoert nicht zu diesem Mandanten.'); throw new RuntimeException('Das gewählte Produkt gehört nicht zu dieser Kaffeeliste.');
} }
} }
@@ -703,7 +703,7 @@ final class LedgerService
]); ]);
if (!$statement->fetchColumn()) { if (!$statement->fetchColumn()) {
throw new RuntimeException('Der Benutzer ist fuer diesen Mandanten nicht aktiv.'); throw new RuntimeException('Der Benutzer ist für diesen Standort nicht aktiv.');
} }
} }
+3 -3
View File
@@ -20,11 +20,11 @@ final class MailerService
$textBody = trim($textBody); $textBody = trim($textBody);
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Die Zieladresse fuer den Mailversand ist ungueltig.'); throw new RuntimeException('Die Zieladresse für den Mailversand ist ungültig.');
} }
if ($subject === '' || $textBody === '') { if ($subject === '' || $textBody === '') {
throw new RuntimeException('Betreff und Inhalt fuer den Mailversand sind erforderlich.'); throw new RuntimeException('Betreff und Inhalt für den Mailversand sind erforderlich.');
} }
$subjectLine = $this->encodeSubject($this->sanitizeHeaderValue($this->subjectPrefix() . $subject)); $subjectLine = $this->encodeSubject($this->sanitizeHeaderValue($this->subjectPrefix() . $subject));
@@ -61,7 +61,7 @@ final class MailerService
'MIME-Version: 1.0', 'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8', 'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: 8bit', 'Content-Transfer-Encoding: 8bit',
'X-Mailer: Kaffeeliste-Neustart', 'X-Mailer: Kaffeeliste',
]; ];
$from = trim((string) ($this->config['from'] ?? '')); $from = trim((string) ($this->config['from'] ?? ''));
+11 -11
View File
@@ -40,13 +40,13 @@ final class PasswordResetService
public function createTokenForUser(int $userId, array $context = []): array public function createTokenForUser(int $userId, array $context = []): array
{ {
if ($userId <= 0) { if ($userId <= 0) {
throw new RuntimeException('Der Benutzer fuer den Passwort-Reset ist ungueltig.'); throw new RuntimeException('Der Benutzer für den Passwortwechsel ist ungültig.');
} }
$user = $this->findUserById($userId); $user = $this->findUserById($userId);
if (!$user) { if (!$user) {
throw new RuntimeException('Der Benutzer fuer den Passwort-Reset wurde nicht gefunden.'); throw new RuntimeException('Der Benutzer für den Passwortwechsel wurde nicht gefunden.');
} }
$nowTs = time(); $nowTs = time();
@@ -204,7 +204,7 @@ final class PasswordResetService
$newPassword = (string) $newPassword; $newPassword = (string) $newPassword;
if ($token === '') { if ($token === '') {
throw new RuntimeException('Der Reset-Link ist ungueltig oder abgelaufen.'); throw new RuntimeException('Der Link ist ungültig oder abgelaufen.');
} }
if (mb_strlen($newPassword) < 12) { if (mb_strlen($newPassword) < 12) {
@@ -229,7 +229,7 @@ final class PasswordResetService
$resetToken = $statement->fetch(); $resetToken = $statement->fetch();
if (!$resetToken) { if (!$resetToken) {
throw new RuntimeException('Der Reset-Link ist ungueltig oder abgelaufen.'); throw new RuntimeException('Der Link ist ungültig oder abgelaufen.');
} }
$expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']); $expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']);
@@ -239,7 +239,7 @@ final class PasswordResetService
|| $expiresAtTs === null || $expiresAtTs === null
|| $expiresAtTs < $nowTs || $expiresAtTs < $nowTs
) { ) {
throw new RuntimeException('Der Reset-Link ist ungueltig oder abgelaufen.'); throw new RuntimeException('Der Link ist ungültig oder abgelaufen.');
} }
$updateUserStatement = $this->pdo->prepare( $updateUserStatement = $this->pdo->prepare(
@@ -314,14 +314,14 @@ final class PasswordResetService
private function sendResetMail(array $token): void private function sendResetMail(array $token): void
{ {
$name = trim((string) ($token['full_name'] ?? '')); $name = trim((string) ($token['full_name'] ?? ''));
$greeting = $name !== '' ? 'Hallo ' . $name . ',' : 'Hallo,'; $greeting = $name !== '' ? 'Guten Tag ' . $name . ',' : 'Guten Tag,';
$body = implode("\n\n", [ $body = implode("\n\n", [
$greeting, $greeting,
'fuer dein Konto wurde ein Passwort-Reset angefragt.', 'für Ihr Konto wurde ein Passwortwechsel angefragt.',
'Bitte nutze diesen Link, um ein neues Passwort zu setzen:', 'Bitte nutzen Sie diesen Link, um ein neues Passwort zu setzen:',
(string) $token['reset_url'], (string) $token['reset_url'],
'Der Link ist gueltig bis ' . (string) $token['expires_at'] . ' UTC.', 'Der Link ist gültig bis ' . (string) $token['expires_at'] . '.',
'Falls du den Reset nicht angefragt hast, kannst du diese Nachricht ignorieren.', 'Falls Sie den Passwortwechsel nicht angefragt haben, können Sie diese Nachricht ignorieren.',
]); ]);
$this->mailer->send( $this->mailer->send(
@@ -410,7 +410,7 @@ final class PasswordResetService
{ {
$subject = trim((string) ($this->config['subject'] ?? '')); $subject = trim((string) ($this->config['subject'] ?? ''));
return $subject !== '' ? $subject : 'Passwort zuruecksetzen'; return $subject !== '' ? $subject : 'Passwort zurücksetzen';
} }
private function ttlMinutes(): int private function ttlMinutes(): int
+4 -4
View File
@@ -16,7 +16,7 @@ final class RfidService
public function createDevice(int $tenantId, string $name, ?string $location, int $actorUserId): string public function createDevice(int $tenantId, string $name, ?string $location, int $actorUserId): string
{ {
if (trim($name) === '') { if (trim($name) === '') {
throw new RuntimeException('Bitte einen Geraetenamen angeben.'); throw new RuntimeException('Bitte einen Namen für den Kartenleser angeben.');
} }
$token = bin2hex(random_bytes(20)); $token = bin2hex(random_bytes(20));
@@ -52,7 +52,7 @@ final class RfidService
]); ]);
if (!$memberStatement->fetchColumn()) { if (!$memberStatement->fetchColumn()) {
throw new RuntimeException('Das ausgewaehlte Mitglied ist fuer RFID nicht verfuegbar.'); throw new RuntimeException('Das ausgewählte Mitglied ist für Karten nicht verfügbar.');
} }
$statement = $this->pdo->prepare( $statement = $this->pdo->prepare(
@@ -89,11 +89,11 @@ final class RfidService
$device = $deviceStatement->fetch(); $device = $deviceStatement->fetch();
if (!$device) { if (!$device) {
throw new RuntimeException('Das RFID-Geraet ist unbekannt.'); throw new RuntimeException('Der Kartenleser ist unbekannt.');
} }
if (($device['status'] ?? '') !== 'active') { if (($device['status'] ?? '') !== 'active') {
throw new RuntimeException('Das RFID-Geraet ist nicht aktiv.'); throw new RuntimeException('Der Kartenleser ist nicht aktiv.');
} }
$status = 'received'; $status = 'received';
+6 -6
View File
@@ -31,12 +31,12 @@ final class SetupService
foreach ($required as $field) { foreach ($required as $field) {
if (trim((string) ($input[$field] ?? '')) === '') { if (trim((string) ($input[$field] ?? '')) === '') {
throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.'); throw new RuntimeException('Bitte alle Pflichtfelder ausfüllen.');
} }
} }
if (!filter_var($input['admin_email'], FILTER_VALIDATE_EMAIL)) { if (!filter_var($input['admin_email'], FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Die Admin-E-Mail ist ungueltig.'); throw new RuntimeException('Die Admin-E-Mail ist ungültig.');
} }
if (mb_strlen($input['admin_password']) < 12) { if (mb_strlen($input['admin_password']) < 12) {
@@ -171,7 +171,7 @@ final class SetupService
$rfidSecret = bin2hex(random_bytes(24)); $rfidSecret = bin2hex(random_bytes(24));
$lines = [ $lines = [
'APP_NAME="Kaffeekasse SaaS"', 'APP_NAME="Kaffeeliste"',
'APP_ENV=production', 'APP_ENV=production',
'APP_DEBUG=0', 'APP_DEBUG=0',
'APP_URL=' . $appUrl, 'APP_URL=' . $appUrl,
@@ -206,14 +206,14 @@ final class SetupService
'', '',
'MAIL_FROM=' . $mailFrom, 'MAIL_FROM=' . $mailFrom,
'MAIL_REPLY_TO=' . $mailFrom, 'MAIL_REPLY_TO=' . $mailFrom,
'MAIL_SUBJECT_PREFIX="[Kaffeekasse] "', 'MAIL_SUBJECT_PREFIX="[Kaffeeliste] "',
'MAIL_LOG_FALLBACK=1', 'MAIL_LOG_FALLBACK=1',
'MAIL_LOG_FILE=storage/logs/mail.log', 'MAIL_LOG_FILE=storage/logs/mail.log',
'', '',
'PASSWORD_RESET_URL=' . $appUrl . '/reset-password?token={{token}}', 'PASSWORD_RESET_URL=' . $appUrl . '/reset-password?token={{token}}',
'PASSWORD_RESET_TTL_MINUTES=60', 'PASSWORD_RESET_TTL_MINUTES=60',
'PASSWORD_RESET_MAX_ACTIVE_TOKENS=3', 'PASSWORD_RESET_MAX_ACTIVE_TOKENS=3',
'PASSWORD_RESET_SUBJECT="Passwort zuruecksetzen"', 'PASSWORD_RESET_SUBJECT="Passwort zurücksetzen"',
'', '',
'RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5', 'RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5',
'RATE_LIMIT_LOGIN_WINDOW_SECONDS=900', 'RATE_LIMIT_LOGIN_WINDOW_SECONDS=900',
@@ -242,7 +242,7 @@ final class SetupService
} }
if (!filter_var($mailFrom, FILTER_VALIDATE_EMAIL)) { if (!filter_var($mailFrom, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Die Mail-Absenderadresse ist ungueltig.'); throw new RuntimeException('Die Mail-Absenderadresse ist ungültig.');
} }
return $mailFrom; return $mailFrom;
+6 -6
View File
@@ -28,7 +28,7 @@ final class TenantRegistrationService
} }
if (!filter_var($ownerEmail, FILTER_VALIDATE_EMAIL)) { if (!filter_var($ownerEmail, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Die E-Mail-Adresse ist ungueltig.'); throw new RuntimeException('Die E-Mail-Adresse ist ungültig.');
} }
if (mb_strlen($ownerPassword) < 12) { if (mb_strlen($ownerPassword) < 12) {
@@ -52,7 +52,7 @@ final class TenantRegistrationService
$existingUser->execute(['email' => $ownerEmail]); $existingUser->execute(['email' => $ownerEmail]);
if ($existingUser->fetchColumn()) { if ($existingUser->fetchColumn()) {
throw new RuntimeException('Die E-Mail-Adresse ist bereits vorhanden. Fuer weitere Tenants bitte spaeter Einladungen nutzen.'); throw new RuntimeException('Die E-Mail-Adresse ist bereits vorhanden. Bitte verwenden Sie eine andere Adresse.');
} }
$now = gmdate('Y-m-d H:i:s'); $now = gmdate('Y-m-d H:i:s');
@@ -131,10 +131,10 @@ final class TenantRegistrationService
); );
foreach ([ foreach ([
['digital_self', 'Digitale Selbstbuchung', 'digital'], ['digital_self', 'Selbst gebucht', 'digital'],
['paper_sheet', 'Papierliste / Nacherfassung', 'paper'], ['paper_sheet', 'Papierliste / Nachtrag', 'paper'],
['admin_backoffice', 'Backoffice-Buchung', 'admin'], ['admin_backoffice', 'Nachgetragen durch Verwaltung', 'admin'],
['rfid_reader', 'RFID-Geraet', 'rfid'], ['rfid_reader', 'Kartenleser', 'rfid'],
] as [$code, $sourceName, $channel]) { ] as [$code, $sourceName, $channel]) {
$sourceInsert->execute([ $sourceInsert->execute([
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
+3 -3
View File
@@ -1,7 +1,7 @@
<?php <?php
return [ return [
'name' => $_ENV['APP_NAME'] ?? 'Kaffeekasse SaaS', 'name' => $_ENV['APP_NAME'] ?? 'Kaffeeliste',
'env' => $_ENV['APP_ENV'] ?? 'production', 'env' => $_ENV['APP_ENV'] ?? 'production',
'debug' => (($_ENV['APP_DEBUG'] ?? '0') === '1'), 'debug' => (($_ENV['APP_DEBUG'] ?? '0') === '1'),
'url' => rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/'), 'url' => rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/'),
@@ -20,7 +20,7 @@ return [
'mail' => [ 'mail' => [
'from' => trim($_ENV['MAIL_FROM'] ?? 'noreply@example.com'), 'from' => trim($_ENV['MAIL_FROM'] ?? 'noreply@example.com'),
'reply_to' => trim($_ENV['MAIL_REPLY_TO'] ?? ''), 'reply_to' => trim($_ENV['MAIL_REPLY_TO'] ?? ''),
'subject_prefix' => $_ENV['MAIL_SUBJECT_PREFIX'] ?? '[Kaffeekasse] ', 'subject_prefix' => $_ENV['MAIL_SUBJECT_PREFIX'] ?? '[Kaffeeliste] ',
'log_fallback' => (($_ENV['MAIL_LOG_FALLBACK'] ?? '1') === '1'), 'log_fallback' => (($_ENV['MAIL_LOG_FALLBACK'] ?? '1') === '1'),
'log_file' => trim($_ENV['MAIL_LOG_FILE'] ?? ''), 'log_file' => trim($_ENV['MAIL_LOG_FILE'] ?? ''),
], ],
@@ -29,7 +29,7 @@ return [
?? rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/') . '/reset-password?token={{token}}', ?? rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/') . '/reset-password?token={{token}}',
'ttl_minutes' => max(5, (int) ($_ENV['PASSWORD_RESET_TTL_MINUTES'] ?? 60)), 'ttl_minutes' => max(5, (int) ($_ENV['PASSWORD_RESET_TTL_MINUTES'] ?? 60)),
'max_active_tokens' => max(1, (int) ($_ENV['PASSWORD_RESET_MAX_ACTIVE_TOKENS'] ?? 3)), 'max_active_tokens' => max(1, (int) ($_ENV['PASSWORD_RESET_MAX_ACTIVE_TOKENS'] ?? 3)),
'subject' => $_ENV['PASSWORD_RESET_SUBJECT'] ?? 'Passwort zuruecksetzen', 'subject' => $_ENV['PASSWORD_RESET_SUBJECT'] ?? 'Passwort zurücksetzen',
], ],
'rate_limits' => [ 'rate_limits' => [
'login' => [ 'login' => [
+362 -143
View File
@@ -1,44 +1,54 @@
:root { :root {
--bg: #f6efe6; --bg: #f5f6f8;
--bg-accent: #efe0cd; --bg-accent: #eef2f7;
--surface: rgba(255, 252, 248, 0.92); --surface: #ffffff;
--surface-strong: #fffaf2; --surface-subtle: #f9fafb;
--text: #1f140f; --surface-strong: #ffffff;
--muted: #6b544a; --text: #111827;
--border: rgba(86, 48, 26, 0.14); --muted: #6b7280;
--primary: #8f4518; --border: #d9dee7;
--primary-strong: #6f2f0e; --border-strong: #b8c0cc;
--secondary: #124f47; --primary: #0f62fe;
--success: #e7f7ef; --primary-strong: #0043ce;
--danger: #ffe3df; --secondary: #344054;
--shadow: 0 20px 60px rgba(86, 48, 26, 0.12); --success: #ecfdf3;
--radius: 24px; --success-border: #a6e9c5;
--danger: #fef2f2;
--danger-border: #fecaca;
--info: #eff6ff;
--info-border: #bfdbfe;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 8px 24px rgba(16, 24, 40, 0.04);
--radius: 8px;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html {
color-scheme: light;
}
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
color: var(--text); color: var(--text);
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
background: font-size: 16px;
radial-gradient(circle at top left, rgba(143, 69, 24, 0.18), transparent 26rem), line-height: 1.55;
radial-gradient(circle at top right, rgba(18, 79, 71, 0.14), transparent 24rem), background: linear-gradient(180deg, #fbfcfe 0%, var(--bg) 100%);
linear-gradient(160deg, var(--bg) 0%, #fcf7f0 48%, var(--bg-accent) 100%); }
body,
button,
input,
select,
textarea {
letter-spacing: 0;
} }
.page-backdrop { .page-backdrop {
position: fixed; display: none;
inset: 0;
pointer-events: none;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 3rem 3rem;
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.4), transparent 70%);
} }
.shell { .shell {
@@ -50,22 +60,23 @@ body {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 20; z-index: 20;
backdrop-filter: blur(18px); background: rgba(255, 255, 255, 0.88);
background: rgba(246, 239, 230, 0.72);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
backdrop-filter: saturate(1.2) blur(18px);
} }
.header-inner, .header-inner,
.footer-grid, .footer-grid,
.button-row, .button-row,
.metric-row, .metric-row,
.top-nav,
.inline-head, .inline-head,
.two-column, .two-column,
.grid-three, .grid-three,
.content-grid, .content-grid,
.plan-grid, .plan-grid,
.metric-cards { .metric-cards,
.action-strip,
.section-actions {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
} }
@@ -73,22 +84,22 @@ body {
.header-inner { .header-inner {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
align-items: center; align-items: center;
padding: 1rem 0; padding: 0.9rem 0;
} }
.brand { .brand {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.85rem; gap: 0.75rem;
min-width: 0;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
.brand strong, .brand strong {
.hero h1, display: block;
.page-head h1, font-size: 0.98rem;
.section-head h2 { font-weight: 700;
letter-spacing: -0.03em;
} }
.brand small, .brand small,
@@ -97,37 +108,49 @@ body {
.feature-list, .feature-list,
.metric-row span, .metric-row span,
.top-nav a, .top-nav a,
.site-footer p { .site-footer p,
.form-note,
.table-empty {
color: var(--muted); color: var(--muted);
} }
.brand-mark { .brand-mark {
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
width: 2.75rem; width: 2.35rem;
height: 2.75rem; height: 2.35rem;
border-radius: 0.9rem; border-radius: var(--radius);
background: linear-gradient(135deg, var(--primary), #c07a3d); background: var(--text);
color: white; color: #ffffff;
font-size: 0.82rem;
font-weight: 700; font-weight: 700;
flex: 0 0 auto;
} }
.top-nav { .top-nav {
grid-auto-flow: column; display: flex;
justify-content: end; flex-wrap: wrap;
justify-content: flex-end;
align-items: center; align-items: center;
gap: 0.35rem;
} }
.top-nav a { .top-nav a {
display: inline-flex;
align-items: center;
min-height: 2.35rem;
padding: 0.5rem 0.7rem;
border-radius: var(--radius);
text-decoration: none; text-decoration: none;
padding: 0.65rem 0.9rem; font-size: 0.94rem;
border-radius: 999px; font-weight: 600;
white-space: nowrap;
} }
.top-nav a.active, .top-nav a.active,
.top-nav a:hover { .top-nav a:hover {
background: rgba(18, 79, 71, 0.08); background: var(--info);
color: var(--secondary); color: var(--primary-strong);
} }
.main-shell { .main-shell {
@@ -151,40 +174,60 @@ body {
.hero-card, .hero-card,
.card, .card,
.form-card, .form-card,
.table-card { .table-card,
.plan-card,
.timeline,
.metric-card,
.quick-action {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.hero-copy { .hero-copy,
padding: 2.4rem; .hero-card {
padding: 2rem;
} }
.hero-card { .hero-card {
padding: 2rem;
align-self: stretch; align-self: stretch;
background: }
linear-gradient(180deg, rgba(255,255,255,0.84), rgba(255,250,242,0.94)),
linear-gradient(135deg, rgba(18,79,71,0.12), transparent 50%); h1,
h2,
h3,
p {
margin-top: 0;
} }
.hero h1, .hero h1,
.page-head h1 { .page-head h1 {
font-size: clamp(2.2rem, 4vw, 4.6rem); max-width: 13ch;
line-height: 0.96;
margin: 0.6rem 0 1rem; margin: 0.6rem 0 1rem;
font-size: 3rem;
line-height: 1.05;
font-weight: 760;
} }
.lead { .section-head h2,
font-size: 1.15rem; .card h2,
.hero-card h2 {
font-size: 1.35rem;
line-height: 1.25;
}
.lead,
.page-head p {
max-width: 58rem;
color: var(--secondary);
font-size: 1.08rem;
line-height: 1.7; line-height: 1.7;
max-width: 56rem;
} }
.button-row, .button-row,
.metric-row { .metric-row,
.section-actions {
grid-auto-flow: column; grid-auto-flow: column;
justify-content: start; justify-content: start;
} }
@@ -193,43 +236,73 @@ body {
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 0.95rem 1.25rem; min-height: 2.75rem;
border-radius: 999px; padding: 0.72rem 1rem;
border-radius: var(--radius);
border: 1px solid transparent; border: 1px solid transparent;
text-decoration: none; text-decoration: none;
font-weight: 700; font-weight: 650;
cursor: pointer; cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
} }
.button-primary { .button-primary {
color: white; color: #ffffff;
background: linear-gradient(135deg, var(--primary), var(--primary-strong)); background: var(--primary);
}
.button-primary:hover {
background: var(--primary-strong);
} }
.button-secondary { .button-secondary {
color: var(--secondary); color: var(--text);
border-color: rgba(18, 79, 71, 0.24); border-color: var(--border);
background: rgba(18, 79, 71, 0.08); background: var(--surface);
}
.button-secondary:hover {
border-color: var(--border-strong);
background: var(--surface-subtle);
}
.button:disabled,
.button[aria-disabled="true"] {
opacity: 0.55;
cursor: not-allowed;
} }
.metric-row { .metric-row {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 1.4rem 0 0; margin: 1.5rem 0 0;
align-items: end; align-items: start;
} }
.metric-row strong { .metric-row li {
max-width: 15rem;
}
.metric-row strong,
.metric-card strong,
.stats-panel strong {
display: block; display: block;
font-size: 1.7rem; font-size: 1.6rem;
line-height: 1.15;
}
.metric-row span,
.metric-card span,
.stats-panel span {
display: block;
margin-top: 0.25rem;
} }
.eyebrow { .eyebrow {
display: inline-block; display: inline-block;
text-transform: uppercase; margin-bottom: 0.45rem;
letter-spacing: 0.18em; color: var(--primary-strong);
font-size: 0.72rem; font-size: 0.84rem;
color: var(--primary);
font-weight: 700; font-weight: 700;
} }
@@ -237,35 +310,40 @@ body {
.stack-list, .stack-list,
.step-list { .step-list {
margin: 1rem 0 0; margin: 1rem 0 0;
padding-left: 1.2rem; padding-left: 1.15rem;
}
.feature-list li,
.step-list li {
margin: 0.45rem 0;
} }
.stats-panel, .stats-panel,
.metric-cards, .metric-cards,
.content-grid, .content-grid,
.plan-grid, .plan-grid,
.grid-three { .grid-three,
.action-strip {
margin-top: 1.5rem; margin-top: 1.5rem;
} }
.stats-panel { .stats-panel {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.85rem; gap: 0.8rem;
} }
.stats-panel div, .stats-panel div,
.metric-card { .metric-card {
padding: 1rem; padding: 1rem;
border-radius: 1rem; background: var(--surface-subtle);
background: rgba(255,255,255,0.74);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: none;
} }
.metric-card strong, .metric-card {
.stats-panel strong { min-height: 7rem;
display: block;
font-size: 1.7rem;
} }
.content-grid, .content-grid,
@@ -277,13 +355,14 @@ body {
.card, .card,
.form-card, .form-card,
.table-card, .table-card,
.timeline { .timeline,
padding: 1.4rem; .plan-card {
padding: 1.25rem;
} }
.highlighted { .highlighted {
border-color: rgba(143, 69, 24, 0.38); border-color: var(--primary);
background: linear-gradient(180deg, rgba(255, 251, 244, 0.98), rgba(250, 236, 219, 0.9)); box-shadow: 0 0 0 1px rgba(15, 98, 254, 0.2), var(--shadow);
} }
.page-head { .page-head {
@@ -292,14 +371,17 @@ body {
.inline-head { .inline-head {
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
align-items: center; align-items: start;
} }
.form-card, .form-card {
.table-card {
overflow: hidden; overflow: hidden;
} }
.table-card {
overflow-x: auto;
}
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -314,7 +396,7 @@ label {
display: grid; display: grid;
gap: 0.45rem; gap: 0.45rem;
margin-bottom: 1rem; margin-bottom: 1rem;
font-weight: 700; font-weight: 650;
} }
input, input,
@@ -328,30 +410,59 @@ input,
select, select,
textarea { textarea {
width: 100%; width: 100%;
padding: 0.85rem 1rem; min-height: 2.75rem;
border-radius: 1rem; padding: 0.72rem 0.85rem;
border: 1px solid rgba(86, 48, 26, 0.18); border-radius: var(--radius);
background: rgba(255,255,255,0.95); border: 1px solid var(--border);
background: #ffffff;
color: var(--text);
}
input::placeholder,
textarea::placeholder {
color: #98a2b3;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(15, 98, 254, 0.16);
}
a:focus-visible,
button:focus-visible,
.button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 3px solid rgba(15, 98, 254, 0.35);
outline-offset: 2px;
} }
table { table {
width: 100%; width: 100%;
min-width: 42rem;
border-collapse: collapse; border-collapse: collapse;
} }
th, th,
td { td {
padding: 0.95rem 0.8rem; padding: 0.82rem 0.75rem;
border-bottom: 1px solid rgba(86, 48, 26, 0.08); border-bottom: 1px solid var(--border);
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
} }
th { th {
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted); color: var(--muted);
font-size: 0.86rem;
font-weight: 700;
}
tbody tr:hover {
background: var(--surface-subtle);
} }
.stack-list { .stack-list {
@@ -363,68 +474,135 @@ th {
display: grid; display: grid;
gap: 0.2rem; gap: 0.2rem;
padding: 0.85rem 0; padding: 0.85rem 0;
border-bottom: 1px solid rgba(86, 48, 26, 0.08); border-bottom: 1px solid var(--border);
} }
.flash { .stack-list li:last-child {
border-bottom: 0;
}
.flash,
.alert {
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 1rem 1.2rem; padding: 0.9rem 1rem;
border-radius: 1rem; border-radius: var(--radius);
font-weight: 700; font-weight: 650;
} }
.flash-success { .flash-success {
background: var(--success); background: var(--success);
border: 1px solid rgba(18, 79, 71, 0.16); border: 1px solid var(--success-border);
} }
.flash-error { .flash-error,
.alert-error {
background: var(--danger); background: var(--danger);
border: 1px solid rgba(143, 69, 24, 0.16); border: 1px solid var(--danger-border);
} color: #7f1d1d;
.alert {
margin-bottom: 1rem;
padding: 1rem 1.2rem;
border-radius: 1rem;
font-weight: 700;
} }
.alert-info { .alert-info {
background: rgba(18, 79, 71, 0.08); background: var(--info);
border: 1px solid rgba(18, 79, 71, 0.16); border: 1px solid var(--info-border);
color: var(--secondary); color: #1e3a8a;
}
.alert-error {
background: var(--danger);
border: 1px solid rgba(143, 69, 24, 0.16);
color: var(--primary-strong);
} }
.code-block { .code-block {
overflow: auto; overflow: auto;
padding: 1rem; padding: 1rem;
border-radius: 1rem; border-radius: var(--radius);
background: #201814; background: #111827;
color: #fef0df; color: #f9fafb;
font-size: 0.9rem;
} }
.text-link { .text-link {
color: var(--secondary); color: var(--primary-strong);
font-weight: 650;
text-decoration: none; text-decoration: none;
} }
.text-link:hover {
text-decoration: underline;
}
.site-footer { .site-footer {
padding: 0 0 2rem; padding: 0 0 2rem;
} }
.footer-grid { .footer-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 1.5rem; padding: 1.25rem 0;
background: rgba(255,255,255,0.64); border-top: 1px solid var(--border);
}
.action-strip {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 1.5rem;
}
.quick-action {
display: grid;
align-content: start;
gap: 0.25rem;
min-height: 7rem;
padding: 1rem;
color: inherit;
text-decoration: none;
}
.quick-action strong {
font-size: 1rem;
}
.quick-action span {
color: var(--muted);
font-size: 0.92rem;
}
.quick-action.primary {
border-color: rgba(15, 98, 254, 0.35);
background: var(--info);
}
.quick-action:hover {
border-color: var(--primary);
}
.section-actions {
margin: 1rem 0;
}
.form-note {
margin: 0 0 1rem;
padding: 0.85rem 1rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface-subtle);
}
.status-pill {
display: inline-flex;
align-items: center;
min-height: 1.75rem;
padding: 0.2rem 0.55rem;
border-radius: var(--radius);
background: var(--surface-subtle);
border: 1px solid var(--border);
color: var(--secondary);
font-size: 0.86rem;
font-weight: 650;
}
.inline-form {
margin: 0.65rem 0 0;
}
code {
padding: 0.12rem 0.28rem;
border-radius: 5px;
background: var(--surface-subtle);
border: 1px solid var(--border);
} }
@media (max-width: 960px) { @media (max-width: 960px) {
@@ -436,18 +614,59 @@ th {
.grid-three, .grid-three,
.footer-grid, .footer-grid,
.form-grid, .form-grid,
.inline-head { .inline-head,
.action-strip {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.top-nav,
.button-row,
.metric-row {
grid-auto-flow: row;
justify-content: start;
}
.header-inner { .header-inner {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.75rem;
}
.top-nav {
justify-content: flex-start;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 0.1rem;
scrollbar-width: thin;
}
.button-row,
.metric-row,
.section-actions {
grid-auto-flow: row;
justify-content: stretch;
}
.button-row .button,
.section-actions .button {
width: 100%;
}
.hero h1,
.page-head h1 {
max-width: 100%;
font-size: 2.15rem;
}
.hero-copy,
.hero-card {
padding: 1.25rem;
}
.stats-panel {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
} }
} }
+6 -6
View File
@@ -2,9 +2,9 @@
<section class="page-head inline-head"> <section class="page-head inline-head">
<div> <div>
<span class="eyebrow">Betrieb</span> <span class="eyebrow">Plattform</span>
<h1>Plattform-Dashboard</h1> <h1>Kaffeelisten verwalten</h1>
<p>Ueberblick ueber Mandanten, Paketstand und direkte Einstiege in die Kundenumgebungen.</p> <p>Überblick über Kaffeelisten, Pakete und direkte Einstiege in die jeweiligen Arbeitsbereiche.</p>
</div> </div>
<form method="post" action="<?= e(url('/admin/logout')) ?>"> <form method="post" action="<?= e(url('/admin/logout')) ?>">
<?= csrf_field($csrf) ?> <?= csrf_field($csrf) ?>
@@ -16,7 +16,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Mandant</th> <th>Kaffeeliste</th>
<th>Paket</th> <th>Paket</th>
<th>Status</th> <th>Status</th>
<th>Mitglieder</th> <th>Mitglieder</th>
@@ -32,10 +32,10 @@
<small>/t/<?= e($tenant['slug']) ?></small> <small>/t/<?= e($tenant['slug']) ?></small>
</td> </td>
<td><?= e(ucfirst($tenant['plan'])) ?></td> <td><?= e(ucfirst($tenant['plan'])) ?></td>
<td><?= e($tenant['status']) ?></td> <td><span class="status-pill"><?= e($tenant['status']) ?></span></td>
<td><?= e((string) $tenant['member_count']) ?></td> <td><?= e((string) $tenant['member_count']) ?></td>
<td><?= e((string) $tenant['product_count']) ?></td> <td><?= e((string) $tenant['product_count']) ?></td>
<td><a class="text-link" href="<?= e(tenant_url($tenant['slug'])) ?>">Tenant oeffnen</a></td> <td><a class="text-link" href="<?= e(tenant_url($tenant['slug'])) ?>">Kaffeeliste öffnen</a></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
+10 -10
View File
@@ -5,33 +5,33 @@ require __DIR__ . '/../partials/layout-top.php';
<section class="page-head"> <section class="page-head">
<span class="eyebrow">Plattformzugang</span> <span class="eyebrow">Plattformzugang</span>
<h1>Admin-Login</h1> <h1>Kaffeelisten verwalten</h1>
<p>Fuer Pilotbetreuung, Mandantenuebersicht und Betriebsaufgaben. Mitglieder nutzen dafuer nicht diese Seite, sondern den jeweiligen Standort-Login.</p> <p>Dieser Bereich ist für Einrichtung, Standorte und Betriebsaufgaben vorgesehen.</p>
</section> </section>
<div class="two-column"> <div class="two-column">
<form class="card form-card" method="post" action="<?= e(url('/admin/login')) ?>"> <form class="card form-card" method="post" action="<?= e(url('/admin/login')) ?>">
<?= csrf_field($csrf) ?> <?= csrf_field($csrf) ?>
<label> <label>
<span>E-Mail</span> <span>E-Mail-Adresse</span>
<input type="email" name="email" value="<?= e((string) old('email')) ?>" required> <input type="email" name="email" value="<?= e((string) old('email')) ?>" required>
</label> </label>
<label> <label>
<span>Passwort</span> <span>Passwort</span>
<input type="password" name="password" required> <input type="password" name="password" required>
</label> </label>
<button class="button button-primary" type="submit">Plattform anmelden</button> <button class="button button-primary" type="submit">Anmelden</button>
<p class="muted">Passwort vergessen? <a class="text-link" href="<?= e($resetRequestUrl) ?>">Reset anstossen</a></p> <p class="muted">Passwort vergessen? <a class="text-link" href="<?= e($resetRequestUrl) ?>">Passwort zurücksetzen</a></p>
</form> </form>
<aside class="card" id="reset-help"> <aside class="card" id="reset-help">
<span class="eyebrow">Passwort vergessen</span> <span class="eyebrow">Passwort vergessen</span>
<h2>Reset im Pilotbetrieb</h2> <h2>Passwort wiederherstellen</h2>
<p class="muted">Der Passwort-Reset ist fuer den Pilot bereits vorgesehen. Bis die automatische Zustellung freigeschaltet ist, sollte das Plattformteam die Anfrage pruefen und den Reset manuell begleiten.</p> <p class="muted">Fordern Sie einen Link für die hinterlegte E-Mail-Adresse an. Mitglieder nutzen den Login ihrer jeweiligen Kaffeeliste.</p>
<ul class="feature-list"> <ul class="feature-list">
<li>Nur fuer Plattform-Admins und Pilotbetreuung</li> <li>Nur für Administratorinnen und Administratoren</li>
<li>Anfrage immer ueber die bekannte Login-E-Mail pruefen</li> <li>Anfragen laufen über die bekannte Login-E-Mail</li>
<li>Mandanten- und Member-Logins laufen getrennt vom Plattformzugang</li> <li>Kaffeelisten-Zugang und Plattformverwaltung bleiben getrennt</li>
</ul> </ul>
</aside> </aside>
</div> </div>
+20 -20
View File
@@ -20,11 +20,11 @@ if ($tenantName === '') {
} }
if ($tenantName === '' && $tenantSlug !== '') { if ($tenantName === '' && $tenantSlug !== '') {
$tenantName = 'Standort ' . $tenantSlug; $tenantName = 'Kaffeeliste ' . $tenantSlug;
} }
if ($tenantName === '') { if ($tenantName === '') {
$tenantName = 'Standortzugang'; $tenantName = 'Kaffeelisten-Zugang';
} }
$token = trim((string) ($token ?? old('token'))); $token = trim((string) ($token ?? old('token')));
@@ -49,15 +49,15 @@ require __DIR__ . '/../partials/layout-top.php';
?> ?>
<section class="page-head"> <section class="page-head">
<span class="eyebrow">Reset bestaetigen</span> <span class="eyebrow">Passwort erneuern</span>
<h1>Neues Passwort setzen</h1> <h1>Neues Passwort setzen</h1>
<p> <p>
<?= e($hasValidResetToken <?= e($hasValidResetToken
? ($scope === 'platform' ? ($scope === 'platform'
? 'Der Plattformzugang bekommt hier sein neues Passwort.' ? 'Legen Sie hier ein neues Passwort für die Plattformverwaltung fest.'
: 'Der Standortzugang fuer ' . $tenantName . ' bekommt hier sein neues Passwort.') : 'Legen Sie hier ein neues Passwort für den Zugang von ' . $tenantName . ' fest.')
: 'Hier wird nur mit einem gueltigen Reset-Link ein neues Passwort gesetzt.') ?> : 'Hier wird nur mit einem gültigen Link ein neues Passwort gesetzt.') ?>
So bleibt der Login-Reset von PIN, RFID und sonstigen Standortdaten getrennt. PINs und Karten bleiben davon unberührt.
</p> </p>
</section> </section>
@@ -75,9 +75,9 @@ require __DIR__ . '/../partials/layout-top.php';
<input type="hidden" name="token" value="<?= e($token) ?>"> <input type="hidden" name="token" value="<?= e($token) ?>">
<?php if ($resetEmail !== '' || $expiresAt !== ''): ?> <?php if ($resetEmail !== '' || $expiresAt !== ''): ?>
<p class="muted"> <p class="muted">
<?= e($resetEmail !== '' ? 'Link fuer ' . $resetEmail . '.' : 'Der Reset-Link wurde geprueft.') ?> <?= e($resetEmail !== '' ? 'Link für ' . $resetEmail . '.' : 'Der Link wurde geprüft.') ?>
<?php if ($expiresAt !== ''): ?> <?php if ($expiresAt !== ''): ?>
Gueltig bis <?= e($expiresAt) ?> UTC. Gültig bis <?= e($expiresAt) ?>.
<?php endif; ?> <?php endif; ?>
</p> </p>
<?php endif; ?> <?php endif; ?>
@@ -89,29 +89,29 @@ require __DIR__ . '/../partials/layout-top.php';
<span>Passwort wiederholen</span> <span>Passwort wiederholen</span>
<input type="password" name="password_confirm" minlength="12" required> <input type="password" name="password_confirm" minlength="12" required>
</label> </label>
<button class="button button-primary" type="submit">Passwort uebernehmen</button> <button class="button button-primary" type="submit">Passwort speichern</button>
<p class="muted">Nach erfolgreicher Bestaetigung soll dieser Flow wieder in den passenden Login zurueckfuehren.</p> <p class="muted">Danach geht es zurück zur Anmeldung.</p>
<p><a class="text-link" href="<?= e($loginUrl) ?>">Zurueck zum Login</a></p> <p><a class="text-link" href="<?= e($loginUrl) ?>">Zurück zum Login</a></p>
</form> </form>
<?php else: ?> <?php else: ?>
<div class="card form-card"> <div class="card form-card">
<h2>Reset-Link neu anfordern</h2> <h2>Neuen Link anfordern</h2>
<p class="muted"> <p class="muted">
<?= e($token === '' <?= e($token === ''
? 'Oeffne den Reset-Link aus der E-Mail, um hier ein neues Passwort zu setzen.' ? 'Öffnen Sie den Link aus der E-Mail, um hier ein neues Passwort zu setzen.'
: 'Dieser Reset-Link ist ungueltig oder abgelaufen. Bitte fordere einen neuen Link an.') ?> : 'Dieser Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Link an.') ?>
</p> </p>
<p><a class="text-link" href="<?= e($requestUrl) ?>">Neuen Reset-Link anfordern</a></p> <p><a class="text-link" href="<?= e($requestUrl) ?>">Neuen Link anfordern</a></p>
<p><a class="text-link" href="<?= e($loginUrl) ?>">Zurueck zum Login</a></p> <p><a class="text-link" href="<?= e($loginUrl) ?>">Zurück zum Login</a></p>
</div> </div>
<?php endif; ?> <?php endif; ?>
<aside class="card"> <aside class="card">
<span class="eyebrow">Worauf geachtet wird</span> <span class="eyebrow">Worauf geachtet wird</span>
<ul class="feature-list"> <ul class="feature-list">
<li>Mindestens 12 Zeichen fuer Plattform- und Standortzugang</li> <li>Mindestens 12 Zeichen für Plattform- und Kaffeelisten-Zugang</li>
<li>Das neue Passwort ersetzt nur den Login, nicht PIN oder RFID-Zuweisungen</li> <li>Das neue Passwort ersetzt nur den Login, nicht PIN oder Karten</li>
<li>Bei Rueckfragen bleibt das Standortteam im Pilot die erste Anlaufstelle</li> <li>Bei Rückfragen hilft die verantwortliche Person der Kaffeeliste</li>
</ul> </ul>
</aside> </aside>
</div> </div>
+17 -18
View File
@@ -20,28 +20,27 @@ if ($tenantName === '') {
} }
if ($tenantName === '' && $tenantSlug !== '') { if ($tenantName === '' && $tenantSlug !== '') {
$tenantName = 'Standort ' . $tenantSlug; $tenantName = 'Kaffeeliste ' . $tenantSlug;
} }
if ($tenantName === '') { if ($tenantName === '') {
$tenantName = 'Standortzugang'; $tenantName = 'Kaffeelisten-Zugang';
} }
$loginUrl = $scope === 'tenant' $loginUrl = $scope === 'tenant'
? ($tenantSlug !== '' ? tenant_url($tenantSlug, 'login') : url('/')) ? ($tenantSlug !== '' ? tenant_url($tenantSlug, 'login') : url('/'))
: url('/admin/login'); : url('/admin/login');
$emailLabel = $scope === 'tenant' ? 'Login-E-Mail' : 'Admin-E-Mail'; $emailLabel = $scope === 'tenant' ? 'E-Mail-Adresse' : 'Admin-E-Mail';
require __DIR__ . '/../partials/layout-top.php'; require __DIR__ . '/../partials/layout-top.php';
?> ?>
<section class="page-head"> <section class="page-head">
<span class="eyebrow">Passwort vergessen</span> <span class="eyebrow">Passwort vergessen</span>
<h1>Reset-Anfrage vorbereiten</h1> <h1>Neues Passwort anfordern</h1>
<p> <p>
<?= e($scope === 'platform' <?= e($scope === 'platform'
? 'Diese Seite sammelt die Anfrage fuer einen Plattform-Reset.' ? 'Fordern Sie einen Link für den Zugang zur Plattformverwaltung an.'
: 'Diese Seite sammelt die Anfrage fuer den Standort-Reset von ' . $tenantName . '.') ?> : 'Fordern Sie einen Link für den Zugang von ' . $tenantName . ' an.') ?>
Im Pilotbetrieb kann die Bestaetigung noch manuell begleitet werden.
</p> </p>
</section> </section>
@@ -61,29 +60,29 @@ require __DIR__ . '/../partials/layout-top.php';
</label> </label>
<?php if ($scope !== 'platform' && $tenantSlug === ''): ?> <?php if ($scope !== 'platform' && $tenantSlug === ''): ?>
<label> <label>
<span>Standort-Slug</span> <span>Kurzname der Kaffeeliste</span>
<input type="text" name="tenant_slug" value="<?= e((string) old('tenant_slug')) ?>" placeholder="mein-team" required> <input type="text" name="tenant_slug" value="<?= e((string) old('tenant_slug')) ?>" placeholder="kaffeeliste-nord" required>
</label> </label>
<?php endif; ?> <?php endif; ?>
<button class="button button-primary" type="submit">Reset-Link anfordern</button> <button class="button button-primary" type="submit">Link per E-Mail senden</button>
<p class="muted">Nach dem Versand soll der Link wieder auf diese Strecke zurueckfuehren und danach zum passenden Login.</p> <p class="muted">Nach dem Klick auf den Link können Sie ein neues Passwort festlegen.</p>
<p><a class="text-link" href="<?= e($loginUrl) ?>">Zurueck zum Login</a></p> <p><a class="text-link" href="<?= e($loginUrl) ?>">Zurück zum Login</a></p>
</form> </form>
<aside class="card"> <aside class="card">
<span class="eyebrow">Pilotablauf</span> <span class="eyebrow">So geht es weiter</span>
<ul class="stack-list"> <ul class="stack-list">
<li> <li>
<strong>1. E-Mail pruefen</strong> <strong>1. E-Mail-Adresse eingeben</strong>
<small>Die Anfrage sollte immer ueber die bekannte Login-Adresse laufen.</small> <small>Nutzen Sie die Adresse, mit der der Zugang angelegt wurde.</small>
</li> </li>
<li> <li>
<strong>2. Reset bestaetigen</strong> <strong>2. Link öffnen</strong>
<small>Im Pilot kann diese Freigabe vorerst noch manuell durch Plattform- oder Standortteam begleitet werden.</small> <small>Der Link ist zeitlich begrenzt und nur einmal nutzbar.</small>
</li> </li>
<li> <li>
<strong>3. Neues Passwort setzen</strong> <strong>3. Neues Passwort setzen</strong>
<small>Danach geht es ueber die Confirm-Seite direkt zurueck in den passenden Login.</small> <small>Danach melden Sie sich mit dem neuen Passwort an.</small>
</li> </li>
</ul> </ul>
</aside> </aside>
+45 -45
View File
@@ -2,37 +2,37 @@
<section class="hero"> <section class="hero">
<div class="hero-copy"> <div class="hero-copy">
<span class="eyebrow">Pilotbetrieb fuer Teams und Vereine</span> <span class="eyebrow">Digitale Kaffeeliste für Teams, Vereine und Standorte</span>
<h1>Die digitale Kaffeeliste ist bereit fuer echte Pilotstandorte, nicht fuer leere Demo-Versprechen.</h1> <h1>Kaffeekasse verwalten, ohne Zettel und Tabellen auseinanderzuhalten.</h1>
<p class="lead">Aktuell wird der Ablauf mit ausgewaehlten Teams verfeinert: Pilotmandanten koennen sich registrieren, Mitglieder fuer die Selbstbuchung anlegen und Backoffice-Prozesse fuer Papierlisten, Einzahlungen und spaetere RFID-Anbindung realistisch testen.</p> <p class="lead">Mitglieder buchen ihren Kaffee selbst, Einzahlungen landen am richtigen Konto und Papierlisten können später sauber nacherfasst werden. So sehen Verantwortliche jederzeit, wer gebucht, bezahlt oder noch offen hat.</p>
<div class="button-row"> <div class="button-row">
<?php if ($setupComplete): ?> <?php if ($setupComplete): ?>
<a class="button button-primary" href="<?= e(url('/register')) ?>">Pilotmandant anlegen</a> <a class="button button-primary" href="<?= e(url('/register')) ?>">Kaffeeliste anlegen</a>
<a class="button button-secondary" href="<?= e(url('/admin/login')) ?>">Plattform-Login</a> <a class="button button-secondary" href="<?= e(url('/admin/login')) ?>">Plattform-Login</a>
<?php else: ?> <?php else: ?>
<a class="button button-primary" href="<?= e(url('/install')) ?>">Installation starten</a> <a class="button button-primary" href="<?= e(url('/install')) ?>">Kaffeeliste installieren</a>
<?php endif; ?> <?php endif; ?>
</div> </div>
<ul class="metric-row"> <ul class="metric-row">
<li><strong>1</strong><span>gemeinsames Ledger fuer Selbstbuchung, Papier und spaetere RFID-Events</span></li> <li><strong>Digital</strong><span>Kaffee direkt selbst oder durch die Verwaltung buchen</span></li>
<li><strong>Pilot</strong><span>klare Trennung zwischen Member-Self-Service und Standort-Backoffice</span></li> <li><strong>Papier</strong><span>Strichlisten nachtragen und gesammelt abschließen</span></li>
<li><strong>100%</strong><span>PHP/MySQL-Betrieb fuer klassisches Webhosting</span></li> <li><strong>Saldo</strong><span>Offene Beträge pro Person nachvollziehen</span></li>
</ul> </ul>
</div> </div>
<aside class="hero-card"> <aside class="hero-card">
<h2>Worauf der Pilot gerade abzielt</h2> <h2>Was die Kaffeeliste abnimmt</h2>
<ul class="feature-list"> <ul class="feature-list">
<li>Owner-Onboarding mit sofort nutzbarem Testmandanten</li> <li>Kaffeeliste mit verantwortlicher Person einrichten</li>
<li>Mitglieder-Logins fuer schlanke Selbstbuchung statt Volladmin-Anmutung</li> <li>Mitglieder mit oder ohne Login anlegen</li>
<li>Papierlisten als Entwurf, Nacherfassung und sauberes Ledger-Posting</li> <li>Produkte, Preise und Einzahlungen nachvollziehbar pflegen</li>
<li>Einzahlungen, Salden und Audit-Trail fuer das Standortteam</li> <li>Papierlisten als Entwurf erfassen und gesammelt übernehmen</li>
<li>RFID-Devices, Karten und Event-Inbox fuer spaetere Automatisierung</li> <li>Kartenleser und Karten vorbereiten, wenn später RFID dazukommt</li>
</ul> </ul>
<?php if (is_array($stats)): ?> <?php if (is_array($stats)): ?>
<div class="stats-panel"> <div class="stats-panel">
<div><strong><?= e((string) $stats['tenants']) ?></strong><span>Pilotmandanten in der Datenbank</span></div> <div><strong><?= e((string) $stats['tenants']) ?></strong><span>Kaffeelisten in der Datenbank</span></div>
<div><strong><?= e((string) $stats['members']) ?></strong><span>Mitglieder</span></div> <div><strong><?= e((string) $stats['members']) ?></strong><span>Mitglieder</span></div>
<div><strong><?= e((string) $stats['events']) ?></strong><span>Buchungsvorfaelle</span></div> <div><strong><?= e((string) $stats['events']) ?></strong><span>Buchungen</span></div>
</div> </div>
<?php endif; ?> <?php endif; ?>
</aside> </aside>
@@ -40,53 +40,53 @@
<section class="content-grid"> <section class="content-grid">
<article class="card"> <article class="card">
<span class="eyebrow">Heute stabil</span> <span class="eyebrow">Buchungen</span>
<h2>Eine Buchungslogik fuer reale Nutzung</h2> <h2>Aus jedem Strich wird ein nachvollziehbarer Vorgang</h2>
<p>Jede Kaffee- oder Tee-Buchung landet im selben Ledger. Ob jemand digital bucht, ob das Standortteam eine Papierliste erfasst oder spaeter ein RFID-Leser Events einspeist: Salden, Historie und Auswertungen bleiben konsistent.</p> <p>Ob online gebucht, von der Verwaltung erfasst oder aus einer Papierliste übernommen: Jede Buchung landet im gemeinsamen Verlauf und wirkt sich direkt auf den Mitgliedssaldo aus.</p>
</article> </article>
<article class="card"> <article class="card">
<span class="eyebrow">Pilotfokus</span> <span class="eyebrow">Verantwortung</span>
<h2>Self-Service fuer Mitglieder, Backoffice fuers Standortteam</h2> <h2>Mitglieder buchen selbst, die Kaffeekasse bleibt geordnet</h2>
<p>Der Pilot trennt bewusst zwischen einfacher Selbstbuchung und administrativen Seiten. Dadurch wirkt der Zugang fuer Mitglieder alltagstauglich, waehrend Produkte, Einzahlungen und Korrekturen beim Standortteam bleiben.</p> <p>Mitglieder erfassen ihren Verbrauch schnell selbst. Verantwortliche pflegen Preise, buchen Einzahlungen, korrigieren Fehler und sehen offene Beträge pro Kaffeeliste.</p>
</article> </article>
<article class="card"> <article class="card">
<span class="eyebrow">Rahmen</span> <span class="eyebrow">Papierliste</span>
<h2>Ehrlicher Rollout statt Schein-Automatisierung</h2> <h2>Papier bleibt möglich, die Abrechnung wird trotzdem sauber</h2>
<p>Reset-Flow, Exporte und Korrekturhilfen werden gerade an echten Pilotablaeufen geschliffen. Gleichzeitig bleibt der technische Kern schlank genug fuer klassisches Webhosting mit PHP, MySQL und Webroot auf `public/`.</p> <p>Wochen- oder Monatslisten können weiter genutzt, später nachgetragen und gesammelt abgeschlossen werden. So verschwinden keine Zettel und keine offenen Beträge mehr im Chaos.</p>
</article> </article>
</section> </section>
<section class="plans"> <section class="plans">
<div class="section-head"> <div class="section-head">
<span class="eyebrow">Pilotstufen</span> <span class="eyebrow">Pakete</span>
<h2>Die Startkonfiguration im Pilot, nicht die finale Vermarktung</h2> <h2>Vom digitalen Start bis zur erweiterten Kaffeekasse</h2>
</div> </div>
<div class="plan-grid"> <div class="plan-grid">
<article class="plan-card"> <article class="plan-card">
<h3>Starter</h3> <h3>Starter</h3>
<p>Fuer kleine Teams mit digitaler Buchung und moeglichst wenig Abstimmungsaufwand im Alltag.</p> <p>Für Teams, die ihre Kaffeekasse zuerst digital und ohne Zusatzhardware führen wollen.</p>
<ul> <ul>
<li>Mitglieder, Produkte und Selbstbuchung</li> <li>Mitglieder, Produkte und Preise verwalten</li>
<li>Schlanke Navigation fuer Member-Logins</li> <li>Buchungen und Einzahlungen digital erfassen</li>
<li>Schneller Einstieg fuer den ersten Pilotstandort</li> <li>Offene Beträge pro Person einsehen</li>
</ul> </ul>
</article> </article>
<article class="plan-card highlighted"> <article class="plan-card highlighted">
<h3>Team</h3> <h3>Team</h3>
<p>Fuer Organisationen, die Papierlisten und digitale Prozesse im Pilot parallel fahren.</p> <p>Für Organisationen, die digitale Buchungen und Papierlisten parallel nutzen.</p>
<ul> <ul>
<li>Papierlisten-Workflow mit Nacherfassung</li> <li>Papierlisten nacherfassen und gesammelt buchen</li>
<li>Backoffice fuer Einzahlungen und Korrekturen</li> <li>Einzahlungen, Korrekturen und offene Beträge verwalten</li>
<li>Vorbereitung fuer spaetere Export- und Supportpfade</li> <li>Buchungen als Tabelle exportieren</li>
</ul> </ul>
</article> </article>
<article class="plan-card"> <article class="plan-card">
<h3>Business</h3> <h3>Business</h3>
<p>Fuer Standorte mit RFID-Roadmap, hoeheren Betriebsanforderungen und fruehem Integrationsbedarf.</p> <p>Für Standorte, die später Kartenleser oder verbindlichere Abläufe ergänzen möchten.</p>
<ul> <ul>
<li>RFID-Devices, Karten und Intake-API</li> <li>Kartenleser vorbereiten und Karten zuweisen</li>
<li>Release-, Backup- und Deployment-Rahmen</li> <li>RFID-Ereignisse standortbezogen empfangen</li>
<li>Frueher Blick auf Betriebs- und Supportprozesse</li> <li>Betriebs-, Sicherungs- und Supportprozesse planen</li>
</ul> </ul>
</article> </article>
</div> </div>
@@ -94,15 +94,15 @@
<section class="timeline card"> <section class="timeline card">
<div class="section-head"> <div class="section-head">
<span class="eyebrow">Pilotablauf</span> <span class="eyebrow">Einrichtung</span>
<h2>So startet ein neuer Standort realistisch</h2> <h2>In wenigen Schritten zur nutzbaren Kaffeeliste</h2>
</div> </div>
<ol class="step-list"> <ol class="step-list">
<li>Plattform installieren und ersten Admin anlegen.</li> <li>Plattform installieren und ersten Admin anlegen.</li>
<li>Pilotmandant registrieren, Owner-Onboarding abschliessen und Produkte vorbelegen.</li> <li>Kaffeeliste mit verantwortlicher Person registrieren.</li>
<li>Mitglieder fuer Selbstbuchung anlegen und Standort-Backoffice abstimmen.</li> <li>Mitglieder, Produkte und Preise anlegen.</li>
<li>Digitale Buchungen und Papierlisten parallel im Team einsetzen.</li> <li>Buchungen und Einzahlungen im Alltag erfassen.</li>
<li>RFID-Geraete spaeter ueber die Event-Inbox anbinden, ohne das Kernledger umzubauen.</li> <li>Papierlisten nachtragen oder Kartenleser später ergänzen.</li>
</ol> </ol>
</section> </section>
+18 -18
View File
@@ -1,24 +1,24 @@
<?php require __DIR__ . '/../partials/layout-top.php'; ?> <?php require __DIR__ . '/../partials/layout-top.php'; ?>
<section class="page-head"> <section class="page-head">
<span class="eyebrow">Pilot-Onboarding</span> <span class="eyebrow">Neue Kaffeeliste einrichten</span>
<h1>Pilotmandant registrieren</h1> <h1>Kaffeeliste registrieren</h1>
<p>Dieses Formular legt einen echten Teststandort an. Bitte nur verwenden, wenn Organisation, Ansprechpartner und Startpreis fuer den Pilotbetrieb abgestimmt sind.</p> <p>Legen Sie eine Kaffeeliste für Ihr Büro, Team oder Ihren Verein an. Danach können Sie Mitglieder, Produkte, Einzahlungen und Papierlisten verwalten.</p>
</section> </section>
<div class="two-column"> <div class="two-column">
<form class="card form-card" method="post" action="<?= e(url('/register')) ?>"> <form class="card form-card" method="post" action="<?= e(url('/register')) ?>">
<?= csrf_field($csrf) ?> <?= csrf_field($csrf) ?>
<label> <label>
<span>Team- / Standortname</span> <span>Name der Kaffeeliste</span>
<input type="text" name="tenant_name" value="<?= e((string) old('tenant_name')) ?>" required> <input type="text" name="tenant_name" value="<?= e((string) old('tenant_name')) ?>" required>
</label> </label>
<label> <label>
<span>Kurzname fuer die Login-URL</span> <span>Kurzname für die Webadresse</span>
<input type="text" name="tenant_slug" value="<?= e((string) old('tenant_slug')) ?>" placeholder="mein-team"> <input type="text" name="tenant_slug" value="<?= e((string) old('tenant_slug')) ?>" placeholder="kaffeeliste-nord">
</label> </label>
<label> <label>
<span>Pilotstufe</span> <span>Paket</span>
<select name="plan"> <select name="plan">
<option value="starter">Starter</option> <option value="starter">Starter</option>
<option value="team" selected>Team</option> <option value="team" selected>Team</option>
@@ -26,33 +26,33 @@
</select> </select>
</label> </label>
<label> <label>
<span>Startpreis pro Kaffee in EUR</span> <span>Standardpreis pro Kaffee in EUR</span>
<input type="number" name="default_price_eur" min="0.50" step="0.05" value="<?= e((string) old('default_price_eur', '1.20')) ?>"> <input type="number" name="default_price_eur" min="0.50" step="0.05" value="<?= e((string) old('default_price_eur', '1.20')) ?>">
</label> </label>
<label> <label>
<span>Ansprechpartner / Owner</span> <span>Verantwortliche Person</span>
<input type="text" name="owner_name" value="<?= e((string) old('owner_name')) ?>" required> <input type="text" name="owner_name" value="<?= e((string) old('owner_name')) ?>" required>
</label> </label>
<label> <label>
<span>Login-E-Mail fuer den Owner</span> <span>E-Mail-Adresse für die Verwaltung</span>
<input type="email" name="owner_email" value="<?= e((string) old('owner_email')) ?>" required> <input type="email" name="owner_email" value="<?= e((string) old('owner_email')) ?>" required>
</label> </label>
<label> <label>
<span>Startpasswort fuer den Owner</span> <span>Startpasswort</span>
<input type="password" name="owner_password" minlength="12" required> <input type="password" name="owner_password" minlength="12" required>
</label> </label>
<button class="button button-primary" type="submit">Pilotmandant anlegen</button> <button class="button button-primary" type="submit">Kaffeeliste anlegen</button>
</form> </form>
<aside class="card"> <aside class="card">
<span class="eyebrow">Vor dem Absenden</span> <span class="eyebrow">Hinweise</span>
<ul class="feature-list"> <ul class="feature-list">
<li>Die Registrierung erzeugt sofort einen nutzbaren Testmandanten</li> <li>Die Registrierung erzeugt sofort eine nutzbare Kaffeeliste</li>
<li>Owner-Zugang und erstes Mitglied werden automatisch angelegt</li> <li>Der erste Verwaltungszugang wird automatisch angelegt</li>
<li>Systemquellen fuer digital, Papier, Backoffice und RFID stehen direkt bereit</li> <li>Mitglieder, Produkte, Einzahlungen und Papierlisten können danach ergänzt werden</li>
<li>Die Self-Service-Navigation fuer Mitglieder bleibt spaeter schlanker als das Admin-Backoffice</li> <li>Passwörter müssen mindestens 12 Zeichen haben</li>
</ul> </ul>
<p class="muted">Wenn ihr erst unverbindlich pruefen wollt, startet lieber mit einem klar benannten Pilotteam statt mit Fantasiedaten. Das macht spaetere Login-, Reset- und Supportablaeufe deutlich sauberer.</p> <p class="muted">Ein klarer Name hilft später bei Login, Rückfragen und mehreren Kaffeelisten im selben Umfeld.</p>
</aside> </aside>
</div> </div>
+3 -3
View File
@@ -3,7 +3,7 @@
<section class="page-head"> <section class="page-head">
<span class="eyebrow">Setup</span> <span class="eyebrow">Setup</span>
<h1>Plattform installieren</h1> <h1>Plattform installieren</h1>
<p>Der Installer schreibt `.env`, legt das Datenbankschema an und erstellt den ersten Plattform-Admin. Danach ist das Self-Service-Onboarding fuer Mandanten freigeschaltet.</p> <p>Die Installation richtet Datenbank, Grundeinstellungen und den ersten Verwaltungszugang ein. Danach können Kaffeelisten eingerichtet werden.</p>
<?php <?php
$availableDrivers = PDO::getAvailableDrivers(); $availableDrivers = PDO::getAvailableDrivers();
@@ -18,7 +18,7 @@
</div> </div>
<?php elseif ($hasMysql && $hasSqlite): ?> <?php elseif ($hasMysql && $hasSqlite): ?>
<div class="alert alert-info"> <div class="alert alert-info">
<strong>Datenbankoptionen:</strong> Sie können MySQL oder SQLite verwenden. <strong>Datenbankoptionen:</strong> Sie können MySQL oder SQLite verwenden.
Für SQLite geben Sie "sqlite" als DB-Host ein. Für SQLite geben Sie "sqlite" als DB-Host ein.
</div> </div>
<?php elseif (!$hasMysql && !$hasSqlite): ?> <?php elseif (!$hasMysql && !$hasSqlite): ?>
@@ -73,7 +73,7 @@
<input type="password" name="admin_password" minlength="12" required> <input type="password" name="admin_password" minlength="12" required>
</label> </label>
</div> </div>
<button class="button button-primary" type="submit">Installation abschliessen</button> <button class="button button-primary" type="submit">Installation abschließen</button>
</form> </form>
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?> <?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
+5 -4
View File
@@ -1,13 +1,14 @@
<?php $footerBrandName = trim((string) ($config['name'] ?? '')) ?: 'Kaffeeliste'; ?>
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
<div class="shell footer-grid"> <div class="shell footer-grid">
<div> <div>
<strong>Kaffeekasse SaaS</strong> <strong><?= e($footerBrandName) ?></strong>
<p>Fuer Teams, Vereine und Standorte, die klassische Listen und digitale Selbstbuchung in einem System zusammenbringen wollen.</p> <p>Für Teams, Vereine und Standorte, die Kaffee, Einzahlungen und offene Beträge an einem Ort im Griff behalten wollen.</p>
</div> </div>
<div> <div>
<span class="eyebrow">Produktionsreif gedacht</span> <span class="eyebrow">Im Alltag nutzbar</span>
<p>Path-basiertes Multi-Tenant-Setup, PHP/MySQL fuer Netcup-Webspace, Ledger-basierte Buchungen und vorbereitete RFID-Inbox.</p> <p>Mitglieder buchen selbst, Papierlisten lassen sich nachtragen und die verantwortliche Person behält die Kaffeekasse ohne Zettelchaos im Blick.</p>
</div> </div>
</div> </div>
</footer> </footer>
+16 -14
View File
@@ -10,13 +10,15 @@ $hasTenantAccess = $tenantSlug !== ''
&& is_array($authState) && is_array($authState)
&& (($authState['tenant_slug'] ?? null) === $tenantSlug); && (($authState['tenant_slug'] ?? null) === $tenantSlug);
$isTenantAdmin = in_array($tenantRole, ['owner', 'admin'], true); $isTenantAdmin = in_array($tenantRole, ['owner', 'admin'], true);
$configuredName = trim((string) ($config['name'] ?? ''));
$brandName = $configuredName !== '' ? $configuredName : 'Kaffeeliste';
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($title ?? $config['name']) ?></title> <title><?= e($title ?? $brandName) ?></title>
<link rel="stylesheet" href="<?= e(asset_url('app.css')) ?>"> <link rel="stylesheet" href="<?= e(asset_url('app.css')) ?>">
</head> </head>
<body class="<?= e($pageClass ?? 'app-shell') ?>"> <body class="<?= e($pageClass ?? 'app-shell') ?>">
@@ -24,40 +26,40 @@ $isTenantAdmin = in_array($tenantRole, ['owner', 'admin'], true);
<header class="site-header"> <header class="site-header">
<div class="shell header-inner"> <div class="shell header-inner">
<a class="brand" href="<?= e(url('/')) ?>"> <a class="brand" href="<?= e(url('/')) ?>">
<span class="brand-mark">KK</span> <span class="brand-mark">KL</span>
<span> <span>
<strong><?= e($config['name']) ?></strong> <strong><?= e($brandName) ?></strong>
<small>Mandantenfaehige Kaffeekasse</small> <small>Kaffeeliste für Teams, Vereine und Büroküchen</small>
</span> </span>
</a> </a>
<nav class="top-nav"> <nav class="top-nav">
<?php if ($hasTenantAccess): ?> <?php if ($hasTenantAccess): ?>
<a href="<?= e(tenant_url($tenantSlug)) ?>" class="<?= $currentPath === tenant_path($tenantSlug) ? 'active' : '' ?>">Uebersicht</a> <a href="<?= e(tenant_url($tenantSlug)) ?>" class="<?= $currentPath === tenant_path($tenantSlug) ? 'active' : '' ?>">Kaffeekasse</a>
<a href="<?= e(tenant_url($tenantSlug, 'bookings')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'bookings') ? 'active' : '' ?>">Buchen</a> <a href="<?= e(tenant_url($tenantSlug, 'bookings')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'bookings') ? 'active' : '' ?>">Kaffee buchen</a>
<?php if ($isTenantAdmin): ?> <?php if ($isTenantAdmin): ?>
<a href="<?= e(tenant_url($tenantSlug, 'members')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'members') ? 'active' : '' ?>">Mitglieder</a> <a href="<?= e(tenant_url($tenantSlug, 'members')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'members') ? 'active' : '' ?>">Mitglieder</a>
<a href="<?= e(tenant_url($tenantSlug, 'products')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'products') ? 'active' : '' ?>">Produkte</a> <a href="<?= e(tenant_url($tenantSlug, 'products')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'products') ? 'active' : '' ?>">Produkte</a>
<a href="<?= e(tenant_url($tenantSlug, 'payments')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'payments') ? 'active' : '' ?>">Einzahlungen</a> <a href="<?= e(tenant_url($tenantSlug, 'payments')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'payments') ? 'active' : '' ?>">Einzahlungen</a>
<a href="<?= e(tenant_url($tenantSlug, 'paper-sheets')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'paper-sheets') ? 'active' : '' ?>">Papierlisten</a> <a href="<?= e(tenant_url($tenantSlug, 'paper-sheets')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'paper-sheets') ? 'active' : '' ?>">Papierlisten</a>
<a href="<?= e(tenant_url($tenantSlug, 'rfid')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'rfid') ? 'active' : '' ?>">RFID</a> <a href="<?= e(tenant_url($tenantSlug, 'rfid')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'rfid') ? 'active' : '' ?>">Kartenleser</a>
<?php endif; ?> <?php endif; ?>
<?php elseif ($tenantSlug !== ''): ?> <?php elseif ($tenantSlug !== ''): ?>
<a href="<?= e(url('/')) ?>" class="<?= $currentPath === '/' ? 'active' : '' ?>">Start</a> <a href="<?= e(url('/')) ?>" class="<?= $currentPath === '/' ? 'active' : '' ?>">Start</a>
<a href="<?= e(url('/register')) ?>" class="<?= $currentPath === '/register' ? 'active' : '' ?>">Pilot registrieren</a> <a href="<?= e(url('/register')) ?>" class="<?= $currentPath === '/register' ? 'active' : '' ?>">Neue Kaffeeliste</a>
<a href="<?= e(tenant_url($tenantSlug, 'login')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'login') ? 'active' : '' ?>">Standort-Login</a> <a href="<?= e(tenant_url($tenantSlug, 'login')) ?>" class="<?= $currentPath === tenant_path($tenantSlug, 'login') ? 'active' : '' ?>">Anmelden</a>
<a href="<?= e(url('/admin/login')) ?>" class="<?= str_starts_with($currentPath, '/admin') ? 'active' : '' ?>">Plattform</a> <a href="<?= e(url('/admin/login')) ?>" class="<?= str_starts_with($currentPath, '/admin') ? 'active' : '' ?>">Plattform-Login</a>
<?php else: ?> <?php else: ?>
<a href="<?= e(url('/')) ?>" class="<?= $currentPath === '/' ? 'active' : '' ?>">Start</a> <a href="<?= e(url('/')) ?>" class="<?= $currentPath === '/' ? 'active' : '' ?>">Start</a>
<a href="<?= e(url('/register')) ?>" class="<?= $currentPath === '/register' ? 'active' : '' ?>">Pilot registrieren</a> <a href="<?= e(url('/register')) ?>" class="<?= $currentPath === '/register' ? 'active' : '' ?>">Neue Kaffeeliste</a>
<a href="<?= e(url('/admin/login')) ?>" class="<?= str_starts_with($currentPath, '/admin') ? 'active' : '' ?>">Plattform</a> <a href="<?= e(url('/admin/login')) ?>" class="<?= str_starts_with($currentPath, '/admin') ? 'active' : '' ?>">Plattform-Login</a>
<?php endif; ?> <?php endif; ?>
</nav> </nav>
</div> </div>
</header> </header>
<main class="shell main-shell"> <main class="shell main-shell">
<?php if ($flashSuccess): ?> <?php if ($flashSuccess): ?>
<div class="flash flash-success"><?= e((string) $flashSuccess) ?></div> <div class="flash flash-success" role="status"><?= e((string) $flashSuccess) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($flashError): ?> <?php if ($flashError): ?>
<div class="flash flash-error"><?= e((string) $flashError) ?></div> <div class="flash flash-error" role="alert"><?= e((string) $flashError) ?></div>
<?php endif; ?> <?php endif; ?>
+40 -44
View File
@@ -4,44 +4,46 @@ $isSelfService = $tenantRole === 'member';
$selectedMemberId = (string) old('member_id'); $selectedMemberId = (string) old('member_id');
$selectedProductId = (string) old('product_id'); $selectedProductId = (string) old('product_id');
$selectedSourceCode = (string) old('source_code', 'digital_self'); $selectedSourceCode = (string) old('source_code', 'digital_self');
$sourceLabels = [
'digital_self' => 'Selbst gebucht',
'paper_sheet' => 'Papierliste',
'admin_backoffice' => 'Nachgetragen durch Verwaltung',
'rfid_reader' => 'Kartenleser',
];
require __DIR__ . '/../partials/layout-top.php'; require __DIR__ . '/../partials/layout-top.php';
?> ?>
<section class="page-head"> <section class="page-head">
<span class="eyebrow"><?= e($isSelfService ? 'Selbstbuchung' : 'Digitale Erfassung') ?></span> <span class="eyebrow"><?= e($isSelfService ? 'Mein Kaffee' : 'Kaffeekasse') ?></span>
<h1><?= e($isSelfService ? 'Getraenke buchen' : 'Digitale Buchungen') ?></h1> <h1>Kaffee buchen</h1>
<?php if ($isSelfService): ?> <?php if ($isSelfService): ?>
<p>Diese Seite ist auf taegliche Member-Self-Service-Buchungen zugeschnitten. Korrekturen, Sammelbuchungen und Exporte bleiben im Pilot vorerst beim Standortteam.</p> <p>Wählen Sie, was Sie genommen haben. Die Buchung landet direkt auf Ihrem Kaffeekonto.</p>
<?php else: ?> <?php else: ?>
<p>Browser, Tablet und Kiosk landen im selben Ledger. Das Standortteam kann hier Selbstbuchung und Backoffice-Buchungen sichtbar trennen.</p> <p>Wählen Sie Mitglied, Produkt und Menge. Die Kaffeekasse berechnet den Betrag sofort mit dem hinterlegten Preis.</p>
<?php endif; ?> <?php endif; ?>
</section> </section>
<section class="card"> <section class="section-actions">
<span class="eyebrow">Export & Korrekturen</span> <a class="button button-secondary" href="<?= e(tenant_url($tenant['slug'], 'bookings/export')) ?>">Buchungen als CSV exportieren</a>
<p class="muted">
<?= e($isSelfService
? 'Hier ist bewusst Platz fuer persoenliche Exporte und klare Korrekturhinweise reserviert, ohne heute schon neue Backend-Logik vorwegzunehmen.'
: 'Hier ist bewusst Platz fuer CSV-Exporte, Sammelkorrekturen und Rueckfragen reserviert, ohne heute schon neue Controllerpfade vorwegzunehmen.') ?>
</p>
<ul class="feature-list">
<li>Download-Link fuer Buchungs-Export: Platzhalter fuer spaetere CSV- oder Monatsansicht</li>
<li>Storno- und Korrekturhinweise: Platzhalter fuer kuenftige Bedienhilfe direkt am Workflow</li>
<li><?= e($isSelfService ? 'Bis dahin bitte im Pilot nur den eigenen Namen waehlen; persoenliche Vorauswahl folgt.' : 'Sammelkorrekturen und Nachbuchungen bleiben vorerst beim Standortteam.') ?></li>
</ul>
</section> </section>
<div class="two-column"> <div class="two-column">
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'bookings')) ?>"> <form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'bookings')) ?>">
<?= csrf_field($csrf) ?> <?= csrf_field($csrf) ?>
<label> <?php if ($isSelfService): ?>
<span><?= e($isSelfService ? 'Dein Mitgliedseintrag' : 'Mitglied') ?></span> <?php if (is_array($currentMember)): ?>
<select name="member_id" required> <p class="form-note">Buchung für <?= e($currentMember['display_name']) ?></p>
<?php foreach ($members as $member): ?> <?php endif; ?>
<option value="<?= e((string) $member['id']) ?>" <?= $selectedMemberId === (string) $member['id'] ? 'selected' : '' ?>><?= e($member['display_name']) ?></option> <?php else: ?>
<?php endforeach; ?> <label>
</select> <span>Mitglied</span>
</label> <select name="member_id" required>
<?php foreach ($members as $member): ?>
<option value="<?= e((string) $member['id']) ?>" <?= $selectedMemberId === (string) $member['id'] ? 'selected' : '' ?>><?= e($member['display_name']) ?></option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
<label> <label>
<span>Produkt</span> <span>Produkt</span>
<select name="product_id" required> <select name="product_id" required>
@@ -54,10 +56,10 @@ require __DIR__ . '/../partials/layout-top.php';
<input type="hidden" name="source_code" value="digital_self"> <input type="hidden" name="source_code" value="digital_self">
<?php else: ?> <?php else: ?>
<label> <label>
<span>Quelle</span> <span>Buchungsart</span>
<select name="source_code"> <select name="source_code">
<option value="digital_self" <?= $selectedSourceCode === 'digital_self' ? 'selected' : '' ?>>Digitale Selbstbuchung</option> <option value="digital_self" <?= $selectedSourceCode === 'digital_self' ? 'selected' : '' ?>>Selbst gebucht</option>
<option value="admin_backoffice" <?= $selectedSourceCode === 'admin_backoffice' ? 'selected' : '' ?>>Backoffice-Buchung</option> <option value="admin_backoffice" <?= $selectedSourceCode === 'admin_backoffice' ? 'selected' : '' ?>>Nachgetragen durch Verwaltung</option>
</select> </select>
</label> </label>
<?php endif; ?> <?php endif; ?>
@@ -66,34 +68,27 @@ require __DIR__ . '/../partials/layout-top.php';
<input type="number" name="quantity" min="0.25" step="0.25" value="<?= e((string) old('quantity', '1')) ?>" required> <input type="number" name="quantity" min="0.25" step="0.25" value="<?= e((string) old('quantity', '1')) ?>" required>
</label> </label>
<label> <label>
<span>Verbrauchszeitpunkt</span> <span>Wann genommen?</span>
<input type="datetime-local" name="effective_at" value="<?= e((string) old('effective_at', gmdate('Y-m-d\TH:i'))) ?>" required> <input type="datetime-local" name="effective_at" value="<?= e((string) old('effective_at', gmdate('Y-m-d\TH:i'))) ?>" required>
</label> </label>
<label> <label>
<span>Notiz</span> <span>Notiz, falls nötig</span>
<input type="text" name="notes" value="<?= e((string) old('notes')) ?>"> <input type="text" name="notes" value="<?= e((string) old('notes')) ?>">
</label> </label>
<button class="button button-primary" type="submit"><?= e($isSelfService ? 'Jetzt buchen' : 'Buchung speichern') ?></button> <button class="button button-primary" type="submit">Auf Kaffeekonto buchen</button>
<?php if ($isSelfService): ?>
<p class="muted">Wenn du mehrere Namen siehst, nutze im Pilot bitte nur deinen eigenen Eintrag. Falsche Buchungen korrigiert das Standortteam.</p>
<?php endif; ?>
</form> </form>
<?php if ($isSelfService): ?> <?php if ($isSelfService): ?>
<section class="card"> <section class="card">
<span class="eyebrow">Pilot-Hinweis</span> <span class="eyebrow">Hinweise</span>
<ul class="stack-list"> <ul class="stack-list">
<li> <li>
<strong>Eigene Historie</strong> <strong>Ihr Verlauf</strong>
<small>Ein persoenlicher Verlauf wird spaeter hier eingeblendet.</small> <small>Ihre Buchungen können Sie als Tabelle exportieren.</small>
</li> </li>
<li> <li>
<strong>Korrekturen</strong> <strong>Korrekturen</strong>
<small>Falsche Buchungen bitte direkt an das Standortteam melden.</small> <small>Falsche Buchungen bitte direkt an die verantwortliche Person melden.</small>
</li>
<li>
<strong>Exporte</strong>
<small>Downloads und Monatsauszuege folgen nach weiterem Pilotfeedback.</small>
</li> </li>
</ul> </ul>
</section> </section>
@@ -105,22 +100,23 @@ require __DIR__ . '/../partials/layout-top.php';
<th>Zeit</th> <th>Zeit</th>
<th>Mitglied</th> <th>Mitglied</th>
<th>Produkt</th> <th>Produkt</th>
<th>Quelle</th> <th>Buchungsart</th>
<th>Betrag</th> <th>Betrag</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if ($bookings === []): ?> <?php if ($bookings === []): ?>
<tr> <tr>
<td colspan="5">Noch keine Buchungen erfasst.</td> <td class="table-empty" colspan="5">Noch keine Kaffee-Buchungen. Die erste Buchung können Sie links erfassen.</td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php foreach ($bookings as $booking): ?> <?php foreach ($bookings as $booking): ?>
<?php $sourceLabel = $sourceLabels[(string) ($booking['source_code'] ?? '')] ?? (string) $booking['source_name']; ?>
<tr> <tr>
<td><?= e($booking['recorded_at']) ?></td> <td><?= e($booking['recorded_at']) ?></td>
<td><?= e($booking['member_name']) ?></td> <td><?= e($booking['member_name']) ?></td>
<td><?= e($booking['product_name']) ?></td> <td><?= e($booking['product_name']) ?></td>
<td><?= e($booking['source_name']) ?></td> <td><?= e($sourceLabel) ?></td>
<td><?= e(money_from_cents((int) $booking['total_cents'])) ?></td> <td><?= e(money_from_cents((int) $booking['total_cents'])) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
+62 -19
View File
@@ -1,17 +1,23 @@
<?php <?php
$tenantRole = (string) ($authState['tenant_role'] ?? 'member'); $tenantRole = (string) ($authState['tenant_role'] ?? 'member');
$isSelfService = $tenantRole === 'member'; $isSelfService = $tenantRole === 'member';
$sourceLabels = [
'digital_self' => 'Selbst gebucht',
'paper_sheet' => 'Papierliste',
'admin_backoffice' => 'Nachgetragen durch Verwaltung',
'rfid_reader' => 'Kartenleser',
];
require __DIR__ . '/../partials/layout-top.php'; require __DIR__ . '/../partials/layout-top.php';
?> ?>
<section class="page-head inline-head"> <section class="page-head inline-head">
<div> <div>
<span class="eyebrow"><?= e($isSelfService ? 'Member-Self-Service' : ucfirst((string) $tenant['plan']) . ' Paket') ?></span> <span class="eyebrow"><?= e($isSelfService ? 'Mein Kaffeekonto' : 'Kaffeekasse') ?></span>
<h1><?= e($tenant['name']) ?></h1> <h1><?= e($tenant['name']) ?></h1>
<?php if ($isSelfService): ?> <?php if ($isSelfService): ?>
<p>Rolle: <?= e($tenantRole) ?>. Dieser Zugang ist fuer die taegliche Selbstbuchung gedacht; Stammdaten, Saldenpflege und Korrekturen bleiben beim Standortteam.</p> <p>Buchen Sie hier Ihren Kaffee oder andere Produkte und sehen Sie Ihren aktuellen offenen Betrag. Korrekturen übernimmt die verantwortliche Person.</p>
<?php else: ?> <?php else: ?>
<p>Rolle: <?= e($tenantRole) ?>. Hier laufen digitale Buchungen, Backoffice-Korrekturen und Standortzahlen zusammen.</p> <p>Hier sehen Sie, wer Kaffee gebucht hat, welche Beträge offen sind und was als Nächstes in der Kaffeekasse gepflegt werden muss.</p>
<?php endif; ?> <?php endif; ?>
</div> </div>
<form method="post" action="<?= e(tenant_url($tenant['slug'], 'logout')) ?>"> <form method="post" action="<?= e(tenant_url($tenant['slug'], 'logout')) ?>">
@@ -20,31 +26,67 @@ require __DIR__ . '/../partials/layout-top.php';
</form> </form>
</section> </section>
<section class="action-strip">
<a class="quick-action primary" href="<?= e(tenant_url($tenant['slug'], 'bookings')) ?>">
<strong><?= e($isSelfService ? 'Kaffee buchen' : 'Kaffee oder Produkt buchen') ?></strong>
<span><?= e($isSelfService ? 'Kaffee, Getränke oder Snacks auf Ihren Namen buchen' : 'Verbrauch direkt einem Mitglied zuordnen') ?></span>
</a>
<?php if ($isSelfService): ?>
<a class="quick-action" href="<?= e(tenant_url($tenant['slug'], 'bookings/export')) ?>">
<strong>Verlauf exportieren</strong>
<span>Eigene Buchungen als Tabelle herunterladen</span>
</a>
<?php else: ?>
<a class="quick-action" href="<?= e(tenant_url($tenant['slug'], 'members')) ?>">
<strong>Mitglied anlegen</strong>
<span>Person für Buchung, Papierliste oder Login hinzufügen</span>
</a>
<a class="quick-action" href="<?= e(tenant_url($tenant['slug'], 'payments')) ?>">
<strong>Geld einzahlen</strong>
<span>Barzahlung, Überweisung oder Sammelkasse zuordnen</span>
</a>
<a class="quick-action" href="<?= e(tenant_url($tenant['slug'], 'products')) ?>">
<strong>Produkte verwalten</strong>
<span>Kaffee, Getränke und Preise pflegen</span>
</a>
<?php endif; ?>
</section>
<?php if ($isSelfService): ?> <?php if ($isSelfService): ?>
<section class="metric-cards">
<article class="metric-card">
<span>Produkte</span>
<strong><?= e((string) $summary['product_count']) ?></strong>
</article>
<article class="metric-card">
<span>Offener Betrag</span>
<strong><?= e(money_from_cents((int) $summary['open_balance_cents'])) ?></strong>
</article>
</section>
<div class="two-column"> <div class="two-column">
<section class="card"> <section class="card">
<span class="eyebrow">Was du hier tun kannst</span> <span class="eyebrow">Ihr Kaffeekonto</span>
<ul class="feature-list"> <ul class="feature-list">
<li>Digitale Buchungen fuer deinen Standort erfassen</li> <li>Kaffee, Getränke oder Snacks auf Ihren Namen buchen</li>
<li>Produktpreise vor dem Buchen einsehen</li> <li>Preise vor dem Buchen einsehen</li>
<li>Passwort-Reset oder Buchungskorrekturen ueber das Standortteam anstossen</li> <li>Bei Fragen die verantwortliche Person informieren</li>
</ul> </ul>
<p class="muted">Die Navigation zeigt hier bewusst nur Self-Service-Seiten, damit dieser Zugang nicht wie Volladministration wirkt.</p>
</section> </section>
<section class="card"> <section class="card">
<span class="eyebrow">Pilotbetrieb</span> <span class="eyebrow">Kaffeekasse</span>
<ul class="stack-list"> <ul class="stack-list">
<li> <li>
<strong>Stammdaten und Mitglieder</strong> <strong>Stammdaten und Mitglieder</strong>
<small>Pflegt weiterhin das Standortteam im Backoffice.</small> <small>Pflegen die verantwortlichen Personen der Kaffeeliste.</small>
</li> </li>
<li> <li>
<strong>Einzahlungen und Papierlisten</strong> <strong>Einzahlungen und Papierlisten</strong>
<small>Werden separat verbucht und bei Bedarf dort korrigiert.</small> <small>Werden von den verantwortlichen Personen verbucht und bei Bedarf korrigiert.</small>
</li> </li>
<li> <li>
<strong>Exporte und persoenliche Historie</strong> <strong>Eigener Verlauf</strong>
<small>Werden im Pilot noch schrittweise nachgezogen.</small> <small>Kann über den Export heruntergeladen werden.</small>
</li> </li>
</ul> </ul>
</section> </section>
@@ -64,7 +106,7 @@ require __DIR__ . '/../partials/layout-top.php';
<strong><?= e((string) $summary['paper_sheet_count']) ?></strong> <strong><?= e((string) $summary['paper_sheet_count']) ?></strong>
</article> </article>
<article class="metric-card"> <article class="metric-card">
<span>Offene Salden</span> <span>Offene Beträge</span>
<strong><?= e(money_from_cents((int) $summary['open_balance_cents'])) ?></strong> <strong><?= e(money_from_cents((int) $summary['open_balance_cents'])) ?></strong>
</article> </article>
</section> </section>
@@ -76,25 +118,26 @@ require __DIR__ . '/../partials/layout-top.php';
<?php if ($latestEvents === []): ?> <?php if ($latestEvents === []): ?>
<li> <li>
<strong>Noch keine Buchungen</strong> <strong>Noch keine Buchungen</strong>
<small>Die letzten Ereignisse erscheinen hier, sobald der Pilotstandort aktiv bucht.</small> <small>Die letzten Buchungen erscheinen hier, sobald die erste Kaffeebuchung erfasst wurde.</small>
</li> </li>
<?php endif; ?> <?php endif; ?>
<?php foreach ($latestEvents as $event): ?> <?php foreach ($latestEvents as $event): ?>
<?php $sourceLabel = $sourceLabels[(string) ($event['source_code'] ?? '')] ?? (string) $event['source_name']; ?>
<li> <li>
<strong><?= e($event['member_name']) ?></strong> <strong><?= e($event['member_name']) ?></strong>
<span><?= e($event['product_name']) ?> via <?= e($event['source_name']) ?></span> <span><?= e($event['product_name']) ?>, gebucht über <?= e($sourceLabel) ?></span>
<small><?= e(money_from_cents((int) $event['total_cents'])) ?> am <?= e($event['recorded_at']) ?></small> <small><?= e(money_from_cents((int) $event['total_cents'])) ?> am <?= e($event['recorded_at']) ?></small>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>
</section> </section>
<section class="card"> <section class="card">
<span class="eyebrow">Hohe Salden</span> <span class="eyebrow">Offene Beträge</span>
<ul class="stack-list"> <ul class="stack-list">
<?php if ($memberBalances === []): ?> <?php if ($memberBalances === []): ?>
<li> <li>
<strong>Noch keine Salden</strong> <strong>Noch keine offenen Beträge</strong>
<small>Sobald Buchungen oder Einzahlungen vorliegen, erscheint hier die Uebersicht.</small> <small>Sobald Buchungen oder Einzahlungen vorliegen, erscheint hier die Übersicht.</small>
</li> </li>
<?php endif; ?> <?php endif; ?>
<?php foreach ($memberBalances as $balance): ?> <?php foreach ($memberBalances as $balance): ?>
+11 -11
View File
@@ -5,34 +5,34 @@ require __DIR__ . '/../partials/layout-top.php';
?> ?>
<section class="page-head"> <section class="page-head">
<span class="eyebrow">Standortzugang</span> <span class="eyebrow">Kaffeeliste</span>
<h1><?= e($tenant['name']) ?></h1> <h1><?= e($tenant['name']) ?></h1>
<p>Login fuer Owner, Standortteam und Mitglieder-Self-Service im Pilotbetrieb. Verwaltungsseiten bleiben bewusst dem Standortteam vorbehalten.</p> <p>Melden Sie sich an, um Kaffee zu buchen, den eigenen Verlauf zu sehen oder die Kaffeeliste zu verwalten.</p>
</section> </section>
<div class="two-column"> <div class="two-column">
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'login')) ?>"> <form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'login')) ?>">
<?= csrf_field($csrf) ?> <?= csrf_field($csrf) ?>
<label> <label>
<span>Login-E-Mail</span> <span>E-Mail-Adresse</span>
<input type="email" name="email" value="<?= e((string) old('email')) ?>" required> <input type="email" name="email" value="<?= e((string) old('email')) ?>" required>
</label> </label>
<label> <label>
<span>Passwort</span> <span>Passwort</span>
<input type="password" name="password" required> <input type="password" name="password" required>
</label> </label>
<button class="button button-primary" type="submit">Standort anmelden</button> <button class="button button-primary" type="submit">Anmelden</button>
<p class="muted">Passwort vergessen? <a class="text-link" href="<?= e($resetRequestUrl) ?>">Reset anfragen</a></p> <p class="muted">Passwort vergessen? <a class="text-link" href="<?= e($resetRequestUrl) ?>">Passwort zurücksetzen</a></p>
</form> </form>
<aside class="card" id="reset-help"> <aside class="card" id="reset-help">
<span class="eyebrow">Reset & Hilfe</span> <span class="eyebrow">Zugang</span>
<h2>Member-Self-Service bleibt schlank</h2> <h2>Je nach Rolle buchen oder verwalten</h2>
<p class="muted">Wenn ein Passwort vergessen wurde, prueft das Standortteam die Anfrage im Pilot zunaechst manuell. Die eigene Reset-Seite fuer dieses Login ist bereits vorgesehen.</p> <p class="muted">Mitglieder erfassen ihren Verbrauch. Verantwortliche Personen sehen zusätzlich Mitglieder, Einzahlungen, Papierlisten und Korrekturen.</p>
<ul class="feature-list"> <ul class="feature-list">
<li>Mitglieder buchen ueber ihren Standortzugang, nicht ueber die Plattform</li> <li>Der Zugang zur Kaffeeliste ist von der Plattformverwaltung getrennt</li>
<li>PIN und RFID sind getrennt vom Passwort-Reset</li> <li>PINs und Karten bleiben vom Passwortwechsel unberührt</li>
<li>Korrekturen an Salden, Produkten und Papierlisten bleiben beim Standortteam</li> <li>Bei Fragen hilft die verantwortliche Person der Kaffeeliste</li>
</ul> </ul>
</aside> </aside>
</div> </div>
+17 -22
View File
@@ -1,42 +1,32 @@
<?php require __DIR__ . '/../partials/layout-top.php'; ?> <?php require __DIR__ . '/../partials/layout-top.php'; ?>
<section class="page-head"> <section class="page-head">
<span class="eyebrow">Standortteam</span> <span class="eyebrow">Kaffeeliste</span>
<h1>Mitglieder und Zugaenge</h1> <h1>Mitglieder der Kaffeeliste</h1>
<p>Mitglieder koennen rein fachlich gefuehrt werden oder optional einen Login fuer die Selbstbuchung erhalten. PINs werden nach dem Speichern nicht mehr im Klartext angezeigt.</p> <p>Legen Sie alle Personen an, die Kaffee buchen oder auf Papierlisten stehen. Ein Login ist nur nötig, wenn Mitglieder selbst buchen sollen. PINs werden nach dem Speichern nicht mehr im Klartext angezeigt.</p>
</section>
<section class="card">
<span class="eyebrow">Export & Korrekturen</span>
<p class="muted">Hier ist bewusst Platz fuer spaetere CSV-Exporte, Sammelimporte und Statuskorrekturen reserviert, ohne dafuer heute schon neue Controllerlogik zu verlangen.</p>
<ul class="feature-list">
<li>Mitgliederliste als Export: Platzhalter fuer kuenftigen Download-Link</li>
<li>Login-, Rollen- oder PIN-Korrekturen: Platzhalter fuer Hinweise direkt am Datensatz</li>
<li>Self-Service-Logins bleiben getrennt von den Backoffice-Feldern des Standortteams</li>
</ul>
</section> </section>
<div class="two-column"> <div class="two-column">
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'members')) ?>"> <form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'members')) ?>">
<?= csrf_field($csrf) ?> <?= csrf_field($csrf) ?>
<label> <label>
<span>Anzeigename</span> <span>Name auf der Kaffeeliste</span>
<input type="text" name="display_name" value="<?= e((string) old('display_name')) ?>" required> <input type="text" name="display_name" value="<?= e((string) old('display_name')) ?>" required>
</label> </label>
<label> <label>
<span>Login-E-Mail fuer Self-Service</span> <span>E-Mail für eigenen Login</span>
<input type="email" name="email" value="<?= e((string) old('email')) ?>"> <input type="email" name="email" value="<?= e((string) old('email')) ?>">
</label> </label>
<label> <label>
<span>Startpasswort fuer den ersten Login</span> <span>Startpasswort für Selbstbuchung</span>
<input type="password" name="login_password" minlength="12"> <input type="password" name="login_password" minlength="12">
</label> </label>
<label> <label>
<span>Optionale PIN fuer Kiosk / Tablet</span> <span>PIN für Kiosk oder Tablet</span>
<input type="text" name="access_pin" value="<?= e((string) old('access_pin')) ?>"> <input type="text" name="access_pin" value="<?= e((string) old('access_pin')) ?>">
</label> </label>
<button class="button button-primary" type="submit">Mitglied speichern</button> <button class="button button-primary" type="submit">Mitglied anlegen</button>
<p class="muted">Wenn eine Login-E-Mail gesetzt ist, wird automatisch ein Member-Login fuer die digitale Selbstbuchung angelegt.</p> <p class="muted">Ohne E-Mail bleibt das Mitglied ein Listenprofil für Nachträge und Papierlisten.</p>
</form> </form>
<section class="table-card"> <section class="table-card">
@@ -44,12 +34,17 @@
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Login</th> <th>Selbstbuchung</th>
<th>PIN-Status</th> <th>Kiosk-PIN</th>
<th>Status</th> <th>Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if ($members === []): ?>
<tr>
<td class="table-empty" colspan="4">Noch keine Mitglieder. Legen Sie das erste Mitglied links im Formular an.</td>
</tr>
<?php endif; ?>
<?php foreach ($members as $member): ?> <?php foreach ($members as $member): ?>
<?php $loginEmail = (string) ($member['login_email'] ?: $member['email']); ?> <?php $loginEmail = (string) ($member['login_email'] ?: $member['email']); ?>
<?php $hasPin = trim((string) ($member['access_pin'] ?? '')) !== ''; ?> <?php $hasPin = trim((string) ($member['access_pin'] ?? '')) !== ''; ?>
@@ -60,7 +55,7 @@
<strong>aktiv</strong><br> <strong>aktiv</strong><br>
<small><?= e($loginEmail) ?></small> <small><?= e($loginEmail) ?></small>
<?php else: ?> <?php else: ?>
<span class="muted">nur Listenprofil</span> <span class="muted">nur Verwaltung oder Papierliste</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
+17 -24
View File
@@ -1,30 +1,22 @@
<?php require __DIR__ . '/../partials/layout-top.php'; ?> <?php require __DIR__ . '/../partials/layout-top.php'; ?>
<section class="page-head"> <section class="page-head">
<span class="eyebrow">Backoffice</span> <span class="eyebrow">Papierlisten</span>
<h1>Papierlisten und Nacherfassung</h1> <h1>Papierliste nachtragen</h1>
<p>Die klassische Kaffeeliste bleibt erhalten, wird aber als geordneter Beleg mit sauberem Posting ins Ledger behandelt. Dieser Bereich ist bewusst fuer das Standortteam formuliert, nicht fuer Member-Self-Service.</p> <p>Übertragen Sie Strichlisten oder Thekenzettel in die Kaffeekasse. Erst beim Abschließen werden die Positionen als Buchungen gezählt.</p>
</section>
<section class="card">
<span class="eyebrow">Export & Korrekturen</span>
<ul class="feature-list">
<li>Druck- oder CSV-Export fuer Papierlisten: Platzhalter fuer spaetere Links</li>
<li>Rueckfragen zu einzelnen Positionen: Platzhalter fuer kuenftige Korrekturhinweise am Entwurf</li>
<li>Member-Self-Service bleibt von Entwurf, Posting und Nachbearbeitung getrennt</li>
</ul>
</section> </section>
<div class="grid-three"> <div class="grid-three">
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'paper-sheets')) ?>"> <form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'paper-sheets')) ?>">
<?= csrf_field($csrf) ?> <?= csrf_field($csrf) ?>
<input type="hidden" name="action" value="create_sheet"> <input type="hidden" name="action" value="create_sheet">
<h2>Neue Papierliste</h2>
<label> <label>
<span>Titel</span> <span>Name der Liste</span>
<input type="text" name="title" value="<?= e((string) old('title', 'Wochenliste Kaffee')) ?>" required> <input type="text" name="title" value="<?= e((string) old('title', 'Wochenliste')) ?>" required>
</label> </label>
<label> <label>
<span>Periodenlabel</span> <span>Zeitraum anzeigen als</span>
<input type="text" name="period_label" value="<?= e((string) old('period_label', 'KW ' . gmdate('W'))) ?>"> <input type="text" name="period_label" value="<?= e((string) old('period_label', 'KW ' . gmdate('W'))) ?>">
</label> </label>
<label> <label>
@@ -45,6 +37,7 @@
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'paper-sheets')) ?>"> <form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'paper-sheets')) ?>">
<?= csrf_field($csrf) ?> <?= csrf_field($csrf) ?>
<input type="hidden" name="action" value="add_line"> <input type="hidden" name="action" value="add_line">
<h2>Position von der Liste übernehmen</h2>
<label> <label>
<span>Papierliste</span> <span>Papierliste</span>
<select name="paper_sheet_id" required> <select name="paper_sheet_id" required>
@@ -76,26 +69,26 @@
<input type="number" name="quantity" min="0.25" step="0.25" value="<?= e((string) old('quantity', '1')) ?>" required> <input type="number" name="quantity" min="0.25" step="0.25" value="<?= e((string) old('quantity', '1')) ?>" required>
</label> </label>
<label> <label>
<span>Verbrauchszeitpunkt</span> <span>Wann genommen?</span>
<input type="datetime-local" name="effective_at" value="<?= e((string) old('effective_at', gmdate('Y-m-d\TH:i'))) ?>"> <input type="datetime-local" name="effective_at" value="<?= e((string) old('effective_at', gmdate('Y-m-d\TH:i'))) ?>">
</label> </label>
<label> <label>
<span>Notiz</span> <span>Notiz</span>
<input type="text" name="note" value="<?= e((string) old('note')) ?>"> <input type="text" name="note" value="<?= e((string) old('note')) ?>">
</label> </label>
<button class="button button-primary" type="submit">Position erfassen</button> <button class="button button-primary" type="submit">Position zur Papierliste hinzufügen</button>
</form> </form>
<section class="card"> <section class="card">
<span class="eyebrow">Posting</span> <span class="eyebrow">In Buchungen übernehmen</span>
<p class="muted">Wenn ein Entwurf vollstaendig ist, wird er mit einem Klick in dieselbe Ledger-Logik uebernommen wie digitale Buchungen.</p> <p class="muted">Prüfen Sie die Positionen. Nach dem Abschließen erscheinen sie als normale Buchungen in der Kaffeekasse.</p>
<?php foreach ($sheets as $sheet): ?> <?php foreach ($sheets as $sheet): ?>
<?php if ($sheet['status'] === 'draft'): ?> <?php if ($sheet['status'] === 'draft'): ?>
<form class="inline-form" method="post" action="<?= e(tenant_url($tenant['slug'], 'paper-sheets')) ?>"> <form class="inline-form" method="post" action="<?= e(tenant_url($tenant['slug'], 'paper-sheets')) ?>">
<?= csrf_field($csrf) ?> <?= csrf_field($csrf) ?>
<input type="hidden" name="action" value="post_sheet"> <input type="hidden" name="action" value="post_sheet">
<input type="hidden" name="paper_sheet_id" value="<?= e((string) $sheet['id']) ?>"> <input type="hidden" name="paper_sheet_id" value="<?= e((string) $sheet['id']) ?>">
<button class="button button-secondary" type="submit"><?= e($sheet['title']) ?> verbuchen</button> <button class="button button-secondary" type="submit"><?= e($sheet['title']) ?> als Buchungen übernehmen</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
@@ -109,14 +102,14 @@
<tr> <tr>
<th>Titel</th> <th>Titel</th>
<th>Status</th> <th>Status</th>
<th>Zeilen</th> <th>Positionen</th>
<th>Zeitraum</th> <th>Zeitraum</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if ($sheets === []): ?> <?php if ($sheets === []): ?>
<tr> <tr>
<td colspan="4">Noch keine Papierlisten angelegt.</td> <td class="table-empty" colspan="4">Noch keine Papierlisten. Legen Sie zuerst die erste Liste an.</td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php foreach ($sheets as $sheet): ?> <?php foreach ($sheets as $sheet): ?>
@@ -134,7 +127,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Papierliste</th> <th>Liste</th>
<th>Mitglied</th> <th>Mitglied</th>
<th>Produkt</th> <th>Produkt</th>
<th>Menge</th> <th>Menge</th>
@@ -143,7 +136,7 @@
<tbody> <tbody>
<?php if ($sheetLines === []): ?> <?php if ($sheetLines === []): ?>
<tr> <tr>
<td colspan="4">Noch keine Positionen erfasst.</td> <td class="table-empty" colspan="4">Noch keine Striche oder Positionen übertragen.</td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php foreach ($sheetLines as $line): ?> <?php foreach ($sheetLines as $line): ?>
+13 -22
View File
@@ -1,18 +1,9 @@
<?php require __DIR__ . '/../partials/layout-top.php'; ?> <?php require __DIR__ . '/../partials/layout-top.php'; ?>
<section class="page-head"> <section class="page-head">
<span class="eyebrow">Backoffice</span> <span class="eyebrow">Kaffeekasse</span>
<h1>Einzahlungen verbuchen</h1> <h1>Geld einzahlen</h1>
<p>Diese Seite bleibt bewusst beim Standortteam. Einzahlungen laufen ins selbe Ledger und reduzieren offene Salden nachvollziehbar, waehrend Mitglieder nur die Selbstbuchung sehen.</p> <p>Tragen Sie Bargeld, Überweisungen oder Sammelzahlungen ein. Der offene Betrag des Mitglieds sinkt um diese Einzahlung.</p>
</section>
<section class="card">
<span class="eyebrow">Export & Korrekturen</span>
<ul class="feature-list">
<li>Kassenexport oder Monatsabschluss: Platzhalter fuer kuenftige Download-Links</li>
<li>Rueckfragen zu Bareinzahlungen: Platzhalter fuer spaetere Korrekturhinweise direkt am Vorgang</li>
<li>Member-Self-Service blendet diesen Backoffice-Bereich bewusst aus</li>
</ul>
</section> </section>
<div class="two-column"> <div class="two-column">
@@ -27,18 +18,18 @@
</select> </select>
</label> </label>
<label> <label>
<span>Betrag in EUR</span> <span>Eingezahlter Betrag</span>
<input type="number" name="amount_eur" min="0.50" step="0.50" value="<?= e((string) old('amount_eur', '5.00')) ?>" required> <input type="number" name="amount_eur" min="0.50" step="0.50" value="<?= e((string) old('amount_eur', '5.00')) ?>" required>
</label> </label>
<label> <label>
<span>Zeitpunkt</span> <span>Wann bezahlt?</span>
<input type="datetime-local" name="occurred_at" value="<?= e((string) old('occurred_at', gmdate('Y-m-d\TH:i'))) ?>" required> <input type="datetime-local" name="occurred_at" value="<?= e((string) old('occurred_at', gmdate('Y-m-d\TH:i'))) ?>" required>
</label> </label>
<label> <label>
<span>Notiz</span> <span>Notiz</span>
<input type="text" name="notes" value="<?= e((string) old('notes')) ?>" placeholder="Barzahlung, Monatsbeitrag, ..."> <input type="text" name="notes" value="<?= e((string) old('notes')) ?>" placeholder="Bar, Überweisung, PayPal, Sammelkasse">
</label> </label>
<button class="button button-primary" type="submit">Einzahlung speichern</button> <button class="button button-primary" type="submit">Einzahlung dem Mitglied gutschreiben</button>
</form> </form>
<section class="table-card"> <section class="table-card">
@@ -47,16 +38,16 @@
<tr> <tr>
<th>Zeit</th> <th>Zeit</th>
<th>Mitglied</th> <th>Mitglied</th>
<th>Beschreibung</th> <th>Zahlungsnotiz</th>
<th>Betrag</th> <th>Betrag</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if ($payments === []): ?> <?php if ($payments === []): ?>
<tr> <tr>
<td colspan="4">Noch keine Einzahlungen verbucht.</td> <td class="table-empty" colspan="4">Noch keine Einzahlungen erfasst. Tragen Sie links die erste Zahlung in die Kaffeekasse ein.</td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php foreach ($payments as $payment): ?> <?php foreach ($payments as $payment): ?>
<tr> <tr>
<td><?= e($payment['occurred_at']) ?></td> <td><?= e($payment['occurred_at']) ?></td>
+8 -3
View File
@@ -1,9 +1,9 @@
<?php require __DIR__ . '/../partials/layout-top.php'; ?> <?php require __DIR__ . '/../partials/layout-top.php'; ?>
<section class="page-head"> <section class="page-head">
<span class="eyebrow">Sortiment</span> <span class="eyebrow">Kaffee und Produkte</span>
<h1>Produkte und Preise</h1> <h1>Produkte und Preise</h1>
<p>Die Preislogik bleibt versionierbar. Jede Buchung friert den zum Zeitpunkt gueltigen Preis ein.</p> <p>Legen Sie fest, was gebucht werden kann: Kaffee, Getränke, Snacks oder Pauschalen. Preisänderungen gelten nur für neue Buchungen.</p>
</section> </section>
<div class="two-column"> <div class="two-column">
@@ -25,12 +25,17 @@
<thead> <thead>
<tr> <tr>
<th>Produkt</th> <th>Produkt</th>
<th>SKU</th> <th>Kurzkennung</th>
<th>Preis</th> <th>Preis</th>
<th>Status</th> <th>Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if ($products === []): ?>
<tr>
<td class="table-empty" colspan="4">Noch keine Produkte. Legen Sie zuerst z. B. Kaffee, Espresso oder Tee an.</td>
</tr>
<?php endif; ?>
<?php foreach ($products as $product): ?> <?php foreach ($products as $product): ?>
<tr> <tr>
<td><?= e($product['name']) ?></td> <td><?= e($product['name']) ?></td>
+19 -28
View File
@@ -1,18 +1,9 @@
<?php require __DIR__ . '/../partials/layout-top.php'; ?> <?php require __DIR__ . '/../partials/layout-top.php'; ?>
<section class="page-head"> <section class="page-head">
<span class="eyebrow">Pilot & Integration</span> <span class="eyebrow">RFID-Kaffeekasse</span>
<h1>RFID-Roadmap und Event-Inbox</h1> <h1>Kartenleser einrichten</h1>
<p>Das MVP speichert Geraete, Karten und rohe Events bereits produktionsnah, ohne die spaetere Hardwareentscheidung vorwegzunehmen. Dieser Bereich bleibt klar beim Standortteam und der Pilotbetreuung.</p> <p>Richten Sie Kartenleser ein und verbinden Sie Karten mit Mitgliedern. Eingelesene Karten erscheinen unten als Ereignisse.</p>
</section>
<section class="card">
<span class="eyebrow">Export & Korrekturen</span>
<ul class="feature-list">
<li>Event-Export oder Inbox-Download: Platzhalter fuer spaetere Support-Links</li>
<li>Karten- und Geraetekorrekturen: Platzhalter fuer Hinweise direkt an den Tabellen</li>
<li>Member-Self-Service blendet RFID-Setup bewusst aus, bis der Pilot weiter ist</li>
</ul>
</section> </section>
<div class="grid-three"> <div class="grid-three">
@@ -20,14 +11,14 @@
<?= csrf_field($csrf) ?> <?= csrf_field($csrf) ?>
<input type="hidden" name="action" value="create_device"> <input type="hidden" name="action" value="create_device">
<label> <label>
<span>Geraetename</span> <span>Name für diesen Leser</span>
<input type="text" name="name" value="<?= e((string) old('name')) ?>" required> <input type="text" name="name" value="<?= e((string) old('name')) ?>" required>
</label> </label>
<label> <label>
<span>Standort</span> <span>Ort am Standort</span>
<input type="text" name="location_label" value="<?= e((string) old('location_label')) ?>"> <input type="text" name="location_label" value="<?= e((string) old('location_label')) ?>">
</label> </label>
<button class="button button-primary" type="submit">RFID-Geraet vorbereiten</button> <button class="button button-primary" type="submit">Kartenleser anlegen</button>
</form> </form>
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'rfid')) ?>"> <form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'rfid')) ?>">
@@ -42,26 +33,26 @@
</select> </select>
</label> </label>
<label> <label>
<span>Karten-UID</span> <span>Kartennummer</span>
<input type="text" name="uid" value="<?= e((string) old('uid')) ?>" required> <input type="text" name="uid" value="<?= e((string) old('uid')) ?>" required>
</label> </label>
<label> <label>
<span>Label</span> <span>Kartenlabel</span>
<input type="text" name="label" value="<?= e((string) old('label')) ?>"> <input type="text" name="label" value="<?= e((string) old('label')) ?>">
</label> </label>
<button class="button button-primary" type="submit">Karte zuweisen</button> <button class="button button-primary" type="submit">Karte mit Mitglied verbinden</button>
</form> </form>
<section class="card"> <section class="card">
<span class="eyebrow">API</span> <span class="eyebrow">Schnittstelle für den Leser</span>
<p class="muted">POST `/api/rfid/intake` mit JSON-Body:</p> <p class="muted">Der Leser sendet Ereignisse in diesem Format:</p>
<pre class="code-block">{ <pre class="code-block">{
"device_token": "...", "device_token": "...",
"uid": "04AABBCCDD", "uid": "04AABBCCDD",
"event_id": "reader-0001", "event_id": "reader-0001",
"ts": "2026-06-15T09:30:00Z" "ts": "2026-06-15T09:30:00Z"
}</pre> }</pre>
<p class="muted">Events werden tenant-scharf gespeichert und spaeter in Buchungsregeln ueberfuehrt.</p> <p class="muted">Ein Ereignis ist noch keine Kaffeebuchung. Es zeigt zuerst, welche Karte an welchem Leser erkannt wurde.</p>
</section> </section>
</div> </div>
@@ -70,16 +61,16 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Geraet</th> <th>Kartenleser</th>
<th>Standort</th> <th>Standort</th>
<th>Status</th> <th>Status</th>
<th>Token</th> <th>Geräte-Token</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if ($devices === []): ?> <?php if ($devices === []): ?>
<tr> <tr>
<td colspan="4">Noch keine RFID-Geraete vorbereitet.</td> <td class="table-empty" colspan="4">Noch kein Kartenleser angelegt. Legen Sie oben den ersten Leser an.</td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php foreach ($devices as $device): ?> <?php foreach ($devices as $device): ?>
@@ -106,7 +97,7 @@
<tbody> <tbody>
<?php if ($tags === []): ?> <?php if ($tags === []): ?>
<tr> <tr>
<td colspan="4">Noch keine RFID-Karten zugewiesen.</td> <td class="table-empty" colspan="4">Noch keine Karten mit Mitgliedern verbunden.</td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php foreach ($tags as $tag): ?> <?php foreach ($tags as $tag): ?>
@@ -127,15 +118,15 @@
<thead> <thead>
<tr> <tr>
<th>Zeit</th> <th>Zeit</th>
<th>Geraet</th> <th>Kartenleser</th>
<th>Status</th> <th>Status</th>
<th>Event-ID</th> <th>Ereignis-ID</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if ($events === []): ?> <?php if ($events === []): ?>
<tr> <tr>
<td colspan="4">Noch keine RFID-Events empfangen.</td> <td class="table-empty" colspan="4">Noch keine Karte wurde von einem Leser gemeldet.</td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php foreach ($events as $event): ?> <?php foreach ($events as $event): ?>
Binary file not shown.