Einrichtung erste Welle

This commit is contained in:
2026-03-30 16:19:19 +02:00
parent 5f08fc2eea
commit d408f70819
51 changed files with 4984 additions and 210 deletions
+583
View File
@@ -589,3 +589,586 @@ Wenn du sofort in Umsetzung gehen willst, ist die sauberste Reihenfolge:
5. Survey Admin MVP und danach Survey Publishing
6. Erst danach Payment-Reconciliation, Mailbox-Import und komplexere
Identity-Integrationen
## Ticket-Struktur Fuer Phase 0
Ziel:
- Alle verbleibenden Detailregeln so festziehen, dass danach produktive
Implementierung ohne Grundsatzdiskussion starten kann.
Empfohlene Epics:
### Epic P0-1. Identity Und Benutzerabgleich
Tickets:
- P0-1.1 Erstlogin-Flow fuer lokale Anmeldung und ADFS/OIDC beschreiben
- P0-1.2 E-Mail-Mapping, Dublettenfall und Admin-Klaerprozess definieren
- P0-1.3 Reaktivierung, Benutzerzusammenfuehrung und Fehlerfaelle festlegen
Ergebnis:
- Umsetzbare Identity-Spezifikation fuer Authentifizierung und Mapping
### Epic P0-2. Rollen Und Rechte
Tickets:
- P0-2.1 Rechte-Matrix pro Rolle und Objekt finalisieren
- P0-2.2 Tenant-Grenzen und Admin-Override dokumentieren
- P0-2.3 Spezialrollen `finance_admin`, `support_contact`, `survey_manager`
gegen Fachprozesse pruefen und bestaetigen
Ergebnis:
- Belastbare Rechtebasis fuer alle MVP-Module
### Epic P0-3. Support-Workflow
Tickets:
- P0-3.1 Statusmodell fuer Supportvorgaenge definieren
- P0-3.2 Routing fuer Benutzer, Support-Verantwortliche, Tenant-Admins und
Plattform-Administration festlegen
- P0-3.3 Sichtbarkeit, Eskalation und Abschlussregeln festziehen
Ergebnis:
- Vollstaendiges Vorgangsmodell fuer Support Requests
### Epic P0-4. Buchungskorrekturen Und Schutzmechanismen
Tickets:
- P0-4.1 Regeln fuer Storno von Einzahlungen definieren
- P0-4.2 Regeln fuer Loeschung von Stricheintraegen definieren
- P0-4.3 Begruendung, Auditspur und Historie fuer Korrekturen beschreiben
- P0-4.4 Vorschau, Testmodus und tenantweit deaktivierbare Vier-Augen-Freigabe
fuer kritische Aktionen festlegen
Ergebnis:
- Verbindliches Regelwerk fuer sichere Korrekturen und risikoreiche Aktionen
### Epic P0-5. Survey-Freigabe Und Jahresauswertung
Tickets:
- P0-5.1 Survey-Freigabeprozess mit Snapshot-Veroeffentlichung definieren
- P0-5.2 Rollen fuer Survey-Erstellung, Freigabe und Veroeffentlichung
bestaetigen
- P0-5.3 Jahresauswertung mit Parametern, Vorschau und Freigabe spezifizieren
- P0-5.4 Bonuslogik fuer fixe und flexible Auszahlungen fachlich schneiden
Ergebnis:
- Nutzbare Fachspezifikation fuer Surveys und Jahresauswertung
### Epic P0-6. Zahlungsmodell Und Profilfelder
Tickets:
- P0-6.1 Tenant-Zahlungsdaten und Pflichtfelder pro Zahlungsart definieren
- P0-6.2 Manuelle Klaerfaelle fuer Buchungen und spaetere Reconciliation
beschreiben
- P0-6.3 Tenantweite Zusatzfelder wie `paypalname` fachlich festlegen
Ergebnis:
- Saubere Daten- und Konfigurationsbasis fuer Payment und Profile
## Ticket-Struktur Fuer Das MVP
Ziel:
- Aus den Phase-0-Ergebnissen ein erstes nutzbares Produkt mit stabilem
Tenant-Betrieb bauen.
Empfohlene Epics:
### Epic MVP-1. Identity Baseline
Tickets:
- MVP-1.1 Lokale Anmeldung produktiv umsetzen
- MVP-1.2 ADFS/OIDC als zusaetzlichen Login integrieren
- MVP-1.3 E-Mail-Mapping und Admin-Klaerfall implementieren
- MVP-1.4 Benutzerabgleich, Reaktivierung und Fehlermeldungen absichern
Abhaengigkeit:
- Phase 0 Epic P0-1
### Epic MVP-2. Rollen Und Tenant-Administration
Tickets:
- MVP-2.1 Rollenmodell im System anlegen
- MVP-2.2 Rechtepruefung pro Modul umsetzen
- MVP-2.3 Tenant-Admin- und Spezialrollenverwaltung bereitstellen
Abhaengigkeit:
- Phase 0 Epic P0-2
### Epic MVP-3. Content Und FAQ
Tickets:
- MVP-3.1 Hinweisverwaltung mit CRUD, Aktivierung und Sichtbarkeit umsetzen
- MVP-3.2 FAQ-Verwaltung mit Standardvorlage und Tenant-Override umsetzen
- MVP-3.3 Frontend-Ausspielung fuer Hinweise und FAQ anbinden
Abhaengigkeit:
- Phase 0 Epic P0-2
### Epic MVP-4. Payment Configuration Und Manuelle Buchung
Tickets:
- MVP-4.1 Zahlungsarten pro Tenant konfigurieren
- MVP-4.2 Zahlungsdaten und Hinweise pro Zahlungsart pflegen
- MVP-4.3 Manuelle Einzahlungsbuchung und Self-Service-Anzeige umsetzen
- MVP-4.4 Datenmodell fuer spaetere Reconciliation vorbereiten
Abhaengigkeit:
- Phase 0 Epic P0-6
### Epic MVP-5. Ledger Korrekturen
Tickets:
- MVP-5.1 Storno fuer Einzahlungen implementieren
- MVP-5.2 Loeschung fuer Stricheintraege implementieren
- MVP-5.3 Auditspur, Begruendung und Historie fuer Korrekturen umsetzen
Abhaengigkeit:
- Phase 0 Epic P0-4
### Epic MVP-6. Support Requests
Tickets:
- MVP-6.1 Supportvorgaenge als Datenmodell und UI anlegen
- MVP-6.2 Statuswechsel, Routing und Sichtbarkeit implementieren
- MVP-6.3 Benutzeransicht fuer eigene Vorgaenge umsetzen
- MVP-6.4 Bearbeitungsansicht fuer Verantwortliche und Support-Kontakte
umsetzen
Abhaengigkeit:
- Phase 0 Epic P0-3
### Epic MVP-7. Survey Admin Und Publishing
Tickets:
- MVP-7.1 Survey-Erstellung und Fragenverwaltung umsetzen
- MVP-7.2 Freigabeprozess fuer Tenant- und Survey-Verantwortliche umsetzen
- MVP-7.3 Snapshot-Veroeffentlichung fuer Benutzer implementieren
- MVP-7.4 Grundauswertung fuer Tenant-Admins bereitstellen
Abhaengigkeit:
- Phase 0 Epic P0-5
### Epic MVP-8. Schutzmechanismen Und Betriebsfaehigkeit
Tickets:
- MVP-8.1 Vorschau und Testmodus fuer kritische Sammelaktionen umsetzen
- MVP-8.2 Tenantweite Konfiguration fuer Vier-Augen-Freigabe umsetzen
- MVP-8.3 Logging, Audit und Fehlerbenachrichtigung fuer kritische Prozesse
umsetzen
- MVP-8.4 Abnahmefaelle und Testdaten fuer Kernworkflows anlegen
Abhaengigkeit:
- Phase 0 Epic P0-4
## Empfohlene Reihenfolge Der MVP-Epics
1. MVP-1 Identity Baseline
2. MVP-2 Rollen Und Tenant-Administration
3. MVP-3 Content Und FAQ
4. MVP-4 Payment Configuration Und Manuelle Buchung
5. MVP-5 Ledger Korrekturen
6. MVP-6 Support Requests
7. MVP-7 Survey Admin Und Publishing
8. MVP-8 Schutzmechanismen Und Betriebsfaehigkeit
## Backlog-Format Fuer Tickets
Dieses Format kann direkt fuer Jira, GitHub Issues oder ein internes
Projektboard verwendet werden.
| Feld | Beschreibung |
| --- | --- |
| Ticket-ID | Eindeutige Kennung, z. B. `P0-1.1` oder `MVP-4.2` |
| Titel | Kurzer, umsetzungsorientierter Ticketname |
| Prioritaet | `hoch`, `mittel`, `niedrig` |
| Ziel | Welches Problem oder Ergebnis das Ticket adressiert |
| Beschreibung | Konkreter Umsetzungsumfang |
| Akzeptanzkriterien | Pruefbare Bedingungen fuer Abnahme |
| Abhaengigkeiten | Vorbedingungen oder vorherige Tickets |
## PRD-Backlog Fuer Phase 0
### P0-1.1 Erstlogin-Flow Fuer Lokale Anmeldung Und ADFS/OIDC
Prioritaet:
- hoch
Ziel:
- Den verbindlichen Anmelde- und Erstlogin-Prozess beschreiben.
Beschreibung:
- Beschreibt lokale Anmeldung, zusaetzliche ADFS/OIDC-Anmeldung und die
Benutzerfuehrung beim ersten externen Login.
Akzeptanzkriterien:
- Lokale Anmeldung und ADFS/OIDC sind als erlaubte Login-Wege beschrieben.
- Erstlogin-Schritte fuer neue und bestehende Benutzer sind dokumentiert.
- Fehler- und Sonderfaelle sind benannt.
Abhaengigkeiten:
- keine
### P0-1.2 E-Mail-Mapping Und Dubletten-Klaerfall
Prioritaet:
- hoch
Ziel:
- Den Abgleich externer Identitaeten mit internen Benutzern festlegen.
Beschreibung:
- Definiert E-Mail als primaeren Matching-Schluessel und beschreibt
Dubletten- oder Konfliktfaelle samt Admin-Klaerprozess.
Akzeptanzkriterien:
- Matching-Regel per E-Mail ist eindeutig dokumentiert.
- Dublettenfall fuehrt in einen definierten Admin-Klaerprozess.
- Reaktivierung bestehender Konten ist beschrieben.
Abhaengigkeiten:
- P0-1.1
### P0-2.1 Rechte-Matrix Pro Rolle Und Objekt Finalisieren
Prioritaet:
- hoch
Ziel:
- Eine abnahmefaehige Rechtebasis fuer alle MVP-Module schaffen.
Beschreibung:
- Finalisiert die Rollen `platform_admin`, `tenant_admin`, `finance_admin`,
`support_contact`, `survey_manager`, `member` gegen alle relevanten Module.
Akzeptanzkriterien:
- Jede Rolle hat dokumentierte Rechte pro Modul.
- Tenant-Grenzen und Admin-Override sind beschrieben.
- Sonderfaelle fuer Storno, Loeschung und Freigabe sind enthalten.
Abhaengigkeiten:
- keine
### P0-3.1 Support-Statusmodell Und Routing Festlegen
Prioritaet:
- hoch
Ziel:
- Das Vorgangssystem fuer Support fachlich fertig schneiden.
Beschreibung:
- Definiert Statuswerte, Routing, Sichtbarkeit, Verantwortlichkeiten und
Eskalation fuer Support Requests.
Akzeptanzkriterien:
- Statusmodell ist dokumentiert.
- Benutzer-, Support- und Admin-Sichtbarkeit ist beschrieben.
- Routing zu Tenant-Verantwortlichen und Plattform-Administration ist klar.
Abhaengigkeiten:
- P0-2.1
### P0-4.1 Regeln Fuer Storno, Loeschung Und Audit
Prioritaet:
- hoch
Ziel:
- Korrekturen an Buchungen fachlich und revisionssicher festlegen.
Beschreibung:
- Legt fest, dass Einzahlungen stornierbar und Stricheintraege loeschbar sind,
jeweils nur durch Verantwortliche, inklusive Auditspur und Begruendung.
Akzeptanzkriterien:
- Storno-Regel fuer Einzahlungen ist dokumentiert.
- Loeschregel fuer Stricheintraege ist dokumentiert.
- Audit- und Historienanforderungen sind beschrieben.
Abhaengigkeiten:
- P0-2.1
### P0-4.2 Schutzmechanismen Fuer Kritische Aktionen
Prioritaet:
- hoch
Ziel:
- Risikoarme Ausfuehrung kritischer Sammelaktionen sicherstellen.
Beschreibung:
- Beschreibt Vorschau, Testmodus und tenantweit deaktivierbare
Vier-Augen-Freigabe fuer kritische Aktionen.
Akzeptanzkriterien:
- Kritische Aktionen sind benannt.
- Vorschau/Testmodus-Regeln sind dokumentiert.
- Vier-Augen-Freigabe ist als tenantweite Option beschrieben.
Abhaengigkeiten:
- keine
### P0-5.1 Survey-Freigabe Und Snapshot-Veroeffentlichung
Prioritaet:
- mittel
Ziel:
- Die Freigabe und Sichtbarkeit von Umfrageergebnissen verbindlich festlegen.
Beschreibung:
- Beschreibt Rollen, Freigabeschritte und Snapshot-Veroeffentlichung fuer
Survey-Ergebnisse.
Akzeptanzkriterien:
- Freigabe-Rollen sind benannt.
- Snapshot-Mechanik fuer Benutzer ist beschrieben.
- Ende und Veroeffentlichung einer Umfrage sind geregelt.
Abhaengigkeiten:
- P0-2.1
### P0-5.2 Jahresauswertung Und Bonuslogik Spezifizieren
Prioritaet:
- mittel
Ziel:
- Den bedarfsorientierten Jahresauswertungsprozess fachlich festlegen.
Beschreibung:
- Beschreibt Parameter wie Jahr, Bonusart, Betrag/Formel, Vorschau und
Freigabeprozess.
Akzeptanzkriterien:
- Parameterliste ist dokumentiert.
- Vorschau- und Freigabeschritt sind festgelegt.
- Fixe und flexible Bonuslogik sind fachlich beschrieben.
Abhaengigkeiten:
- P0-4.2
### P0-6.1 Zahlungsdaten, Pflichtfelder Und Profilfelder Festlegen
Prioritaet:
- mittel
Ziel:
- Konfigurations- und Stammdaten fuer Payment und Matching definieren.
Beschreibung:
- Legt Pflichtfelder pro Zahlungsart und tenantweite Zusatzfelder wie
`paypalname` fest.
Akzeptanzkriterien:
- Pflichtfelder je Zahlungsart sind dokumentiert.
- Zusatzfelder sind tenantweit definiert.
- Matching-relevante Felder sind gekennzeichnet.
Abhaengigkeiten:
- P0-1.2
## PRD-Backlog Fuer Das MVP
### MVP-1.1 Lokale Anmeldung Implementieren
Prioritaet:
- hoch
Ziel:
- Die lokale Systemanmeldung produktiv bereitstellen.
Beschreibung:
- Implementiert Login, Fehlerbehandlung und Benutzerfuehrung fuer lokale
Konten.
Akzeptanzkriterien:
- Benutzer koennen sich lokal anmelden.
- Fehlerfaelle werden verstaendlich angezeigt.
- Login ist tenantfaehig eingebunden.
Abhaengigkeiten:
- P0-1.1
### MVP-1.2 ADFS/OIDC-Login Und E-Mail-Mapping
Prioritaet:
- hoch
Ziel:
- Externen Login entsprechend dem Zielbild produktiv aktivieren.
Beschreibung:
- Implementiert ADFS/OIDC, E-Mail-Mapping und Admin-Klaerfall bei Konflikten.
Akzeptanzkriterien:
- Login via ADFS/OIDC funktioniert.
- E-Mail-Mapping wird korrekt angewendet.
- Konfliktfaelle werden in einen Admin-Klaerprozess geleitet.
Abhaengigkeiten:
- P0-1.2
- MVP-1.1
### MVP-2.1 Rollenmodell Und Rechtepruefung Umsetzen
Prioritaet:
- hoch
Ziel:
- Das fachlich definierte Rollenmodell im System verankern.
Beschreibung:
- Implementiert Rollen, Modulrechte und Tenant-Grenzen.
Akzeptanzkriterien:
- Alle definierten Rollen sind im System vorhanden.
- Rechtepruefung greift pro Modul.
- Tenant-Admin hat Vollzugriff im eigenen Tenant.
Abhaengigkeiten:
- P0-2.1
### MVP-3.1 Hinweise Und FAQ Verwalten
Prioritaet:
- mittel
Ziel:
- Ein erstes produktiv nutzbares Content-Modul bereitstellen.
Beschreibung:
- Implementiert CRUD fuer Hinweise und FAQ inklusive Tenant-Override.
Akzeptanzkriterien:
- Hinweise koennen angelegt, bearbeitet und deaktiviert werden.
- FAQ koennen gepflegt werden.
- Standardvorlagen koennen tenantseitig ueberschrieben werden.
Abhaengigkeiten:
- MVP-2.1
### MVP-4.1 Zahlungsarten Und Zahlungsdaten Pro Tenant
Prioritaet:
- hoch
Ziel:
- Tenant-spezifische Zahlungsarten produktiv verwaltbar machen.
Beschreibung:
- Implementiert Bar, Ueberweisung und PayPal als konfigurierbare Zahlungsarten
inklusive Pflichtfeldern und Hinweisen.
Akzeptanzkriterien:
- Zahlungsarten sind tenantweit konfigurierbar.
- Pflichtfelder werden validiert.
- Zahlungsinformationen werden im Frontend korrekt angezeigt.
Abhaengigkeiten:
- P0-6.1
- MVP-2.1
### MVP-4.2 Manuelle Einzahlungsbuchung Und Reconciliation-Vorbereitung
Prioritaet:
- hoch
Ziel:
- Manuelle Zahlungsbuchung stabil umsetzen und spaetere Reconciliation
vorbereiten.
Beschreibung:
- Implementiert manuelle Buchung und bereitet Statusfelder fuer spaetere
Reconciliation vor.
Akzeptanzkriterien:
- Einzahlungen koennen manuell gebucht werden.
- Buchungsstatus fuer spaetere Erweiterung ist im Modell vorhanden.
- Kein automatischer Mailbox-Import ist Teil des MVPs.
Abhaengigkeiten:
- MVP-4.1
### MVP-5.1 Einzahlungs-Storno Und Strich-Loeschung
Prioritaet:
- hoch
Ziel:
- Korrekturen gemaess Fachregel umsetzen.
Beschreibung:
- Implementiert Storno fuer Einzahlungen und Loeschung fuer Stricheintraege
inklusive Auditspur.
Akzeptanzkriterien:
- Nur berechtigte Rollen duerfen korrigieren.
- Einzahlungen werden als Storno gefuehrt.
- Stricheintraege werden nachvollziehbar geloescht.
Abhaengigkeiten:
- P0-4.1
- MVP-2.1
### MVP-6.1 Supportvorgaenge Mit Status Und Routing
Prioritaet:
- hoch
Ziel:
- Support direkt als vollstaendiges Vorgangssystem bereitstellen.
Beschreibung:
- Implementiert Datenmodell, Statuswechsel, Routing und Ansichten fuer Benutzer
und Verantwortliche.
Akzeptanzkriterien:
- Benutzer koennen Vorgaenge anlegen und verfolgen.
- Verantwortliche koennen Vorgaenge bearbeiten.
- Routing und Sichtbarkeit folgen dem Phase-0-Modell.
Abhaengigkeiten:
- P0-3.1
- MVP-2.1
### MVP-7.1 Survey-Verwaltung Und Snapshot-Publishing
Prioritaet:
- mittel
Ziel:
- Surveys fuer Tenant-Betrieb und Benutzerveroeffentlichung bereitstellen.
Beschreibung:
- Implementiert Survey-Erstellung, Freigabe und Snapshot-Ausgabe fuer Benutzer.
Akzeptanzkriterien:
- Surveys koennen angelegt und freigegeben werden.
- Benutzer sehen nur freigegebene Snapshots.
- Tenant-Admins und Survey-Manager koennen Ergebnisse auswerten.
Abhaengigkeiten:
- P0-5.1
- MVP-2.1
### MVP-8.1 Vorschau, Testmodus Und Vier-Augen-Konfiguration
Prioritaet:
- hoch
Ziel:
- Kritische Aktionen sicher und tenantkonfigurierbar ausfuehren.
Beschreibung:
- Implementiert Vorschau, Testmodus und die tenantweite Konfiguration der
Vier-Augen-Freigabe.
Akzeptanzkriterien:
- Kritische Aktionen bieten Vorschau oder Testmodus.
- Vier-Augen-Freigabe ist pro Tenant aktivier- und deaktivierbar.
- Kritische Aktionen werden sauber protokolliert.
Abhaengigkeiten:
- P0-4.2
- MVP-2.1
@@ -26,6 +26,81 @@ class ContentService
];
}
/**
* @return array<string, mixed>
*/
public function publicOverview(string $tenantId): array
{
$announcements = array_map(
static fn (Announcement $announcement): array => $announcement->toArray(),
$this->activeAnnouncements($tenantId)
);
$faq = array_map(
static fn (FaqItem $faqItem): array => $faqItem->toArray(),
$this->faqItems($tenantId)
);
return [
'announcements' => $announcements,
'faq' => $faq,
'policy' => [
'template_policy' => 'Standardvorlage plus Tenant-Override',
'visibility_policy' => 'Freigegebene Inhalte fuer Mitglieder und Dashboard sichtbar',
'admin_scope' => 'Tenant-Admins pflegen Inhalte direkt im Mandantenkontext',
],
'workflow' => [
'Draft',
'Review',
'Publish',
'Expire',
],
'summary' => [
'announcement_count' => count($announcements),
'faq_count' => count($faq),
],
];
}
/**
* @return array<string, mixed>
*/
public function editorialOverview(string $tenantId): array
{
return [
'tenant_id' => $tenantId,
'sections' => [
[
'title' => 'Hinweise',
'description' => 'Tenant-Admins pflegen zeitlich begrenzte Hinweise mit klarer Sichtbarkeit.',
],
[
'title' => 'FAQ',
'description' => 'Fragen und Antworten bilden die Standardvorlage, die je Tenant uebersteuert werden kann.',
],
[
'title' => 'Redaktionsablauf',
'description' => 'Entwurf, Sichtung, Freigabe und Ablauf sind als klarer Content-Workflow gedacht.',
],
],
'rules' => [
'Standardvorlage + Tenant-Override',
'Tenant-Admins verwalten Inhalte selbst',
'Veroeffentlichung steuert Sichtbarkeit auf Dashboard und spaetere Kanaele',
],
'editable_items' => [
'announcement' => array_map(
static fn (Announcement $announcement): array => $announcement->toArray(),
$this->activeAnnouncements($tenantId)
),
'faq' => array_map(
static fn (FaqItem $faqItem): array => $faqItem->toArray(),
$this->faqItems($tenantId)
),
],
];
}
/**
* @return array<int, FaqItem>
*/
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Modules\Content\Controllers;
use App\Modules\Content\Application\ContentService;
class ContentController
{
public function __construct(private readonly ContentService $contentService)
{
}
public function index(string $tenantId): array
{
return [
'view' => 'content.index',
'data' => [
'title' => 'Content',
'content' => $this->contentService->publicOverview($tenantId),
],
];
}
public function editor(string $tenantId): array
{
return [
'view' => 'content.editor',
'data' => [
'title' => 'Content-Redaktion',
'editorial' => $this->contentService->editorialOverview($tenantId),
],
];
}
}
@@ -16,6 +16,21 @@ class Announcement
) {
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id(),
'tenant_id' => $this->tenantId(),
'title' => $this->title(),
'message' => $this->message(),
'visible_until' => $this->visibleUntil(),
'active' => $this->active(),
];
}
public function id(): string
{
return $this->id;
@@ -45,4 +60,9 @@ class Announcement
{
return $this->active;
}
public function isEditableByTenantAdmin(): bool
{
return true;
}
}
@@ -16,6 +16,21 @@ class FaqItem
) {
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id(),
'tenant_id' => $this->tenantId(),
'question' => $this->question(),
'answer' => $this->answer(),
'sort_order' => $this->sortOrder(),
'active' => $this->active(),
];
}
public function id(): string
{
return $this->id;
@@ -45,4 +60,9 @@ class FaqItem
{
return $this->active;
}
public function isEditableByTenantAdmin(): bool
{
return true;
}
}
@@ -19,7 +19,10 @@ class LoginController
'data' => [
'title' => 'Anmeldung',
'providers' => $this->authService->availableProviders(),
'oidcProviders' => $this->authService->configuredOidcProviders(),
'loginPreview' => $this->authService->centralLoginPreview(),
'identityPolicy' => $this->authService->identityPolicy(),
'roleMatrix' => $this->authService->roleMatrix(),
],
];
}
@@ -4,34 +4,46 @@ declare(strict_types=1);
namespace App\Modules\Identity\Controllers;
use App\Modules\Identity\Support\IdentityPolicy;
use App\Modules\Identity\Services\OidcProviderService;
class OidcController
{
public function __construct(private readonly OidcProviderService $providerService)
{
public function __construct(
private readonly OidcProviderService $providerService,
private readonly IdentityPolicy $identityPolicy = new IdentityPolicy()
) {
}
public function providers(): array
{
return [
'providers' => $this->providerService->configuredProviders(),
'identity_policy' => $this->identityPolicy->toArray(),
];
}
public function start(string $providerKey): array
{
$provider = $this->providerService->findProvider($providerKey);
return [
'status' => 'redirect-placeholder',
'status' => $provider !== null ? 'redirect-placeholder' : 'provider-not-found',
'provider' => $providerKey,
'provider_config' => $provider?->toArray(),
'identity_policy' => $this->identityPolicy->toArray(),
];
}
public function callback(string $providerKey, array $claims = []): array
{
$provider = $this->providerService->findProvider($providerKey);
return [
'status' => 'callback-placeholder',
'status' => $provider !== null ? 'callback-placeholder' : 'provider-not-found',
'provider' => $providerKey,
'provider_config' => $provider?->toArray(),
'identity_policy' => $this->identityPolicy->toArray(),
'claims' => $claims,
];
}
@@ -4,28 +4,60 @@ declare(strict_types=1);
namespace App\Modules\Identity\Services;
use App\Modules\Identity\Support\IdentityPolicy;
use App\Modules\Identity\Support\OidcProviderConfig;
use App\Modules\Identity\Support\RoleMatrix;
use App\Modules\Tenants\Services\TenantService;
class AuthService
{
public function __construct(private readonly TenantService $tenantService)
{
public function __construct(
private readonly TenantService $tenantService,
private readonly IdentityPolicy $identityPolicy = new IdentityPolicy(),
private readonly RoleMatrix $roleMatrix = new RoleMatrix(),
private readonly OidcProviderService $oidcProviderService = new OidcProviderService()
) {
}
public function availableProviders(): array
{
return [
'password' => [
'label' => 'Zentraler Passwort-Login',
'label' => 'Lokale Systemanmeldung',
'type' => 'password',
'required' => true,
'description' => 'Pflichtweg fuer lokale Konten und Fallback bei externen Ausfaellen.',
],
'oidc' => [
'label' => 'SSO via OIDC',
'adfs_oidc' => [
'label' => 'ADFS / OIDC',
'type' => 'oidc',
'required' => false,
'description' => 'Zusatzoption fuer angebundene Identitaetsprovider.',
],
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function configuredOidcProviders(): array
{
return array_map(
static fn (OidcProviderConfig $provider): array => $provider->toArray(),
$this->oidcProviderService->configuredProviders()
);
}
public function identityPolicy(): array
{
return $this->identityPolicy->toArray();
}
public function roleMatrix(): array
{
return $this->roleMatrix->toArray();
}
/**
* @return array<string, mixed>
*/
@@ -40,19 +72,19 @@ class AuthService
'email' => $singleEmail,
'status' => 'redirect-tenant',
'matches' => $this->tenantService->lookupTenantsByEmail($singleEmail),
'message' => 'Bei genau einem Treffer wird direkt in den zugeordneten Tenant weitergeleitet.',
'message' => 'Bei genau einem Treffer wird direkt in den zugeordneten Tenant weitergeleitet. Dort folgt dann die lokale Anmeldung oder ADFS/OIDC.',
],
'multiple' => [
'email' => $multipleEmail,
'status' => 'select-tenant',
'matches' => $this->tenantService->lookupTenantsByEmail($multipleEmail),
'message' => 'Bei mehreren Trefferkonten erscheint zuerst eine Tenant-Auswahl fuer denselben Mitarbeiter.',
'message' => 'Bei mehreren Trefferkonten erscheint zuerst eine Tenant-Auswahl fuer denselben Mitarbeiter. Der E-Mail-Abgleich bleibt dabei die gemeinsame Grundlage.',
],
'unknown' => [
'email' => $unknownEmail,
'status' => 'request-tenant',
'matches' => [],
'message' => 'Unbekannte Mail-Adressen werden nicht ins Leere geleitet, sondern klar auf Tenant- oder Admin-Kontakt gefuehrt.',
'message' => 'Unbekannte Mail-Adressen werden nicht ins Leere geleitet, sondern klar auf Tenant- oder Admin-Kontakt gefuehrt. Lokale Anmeldung bleibt weiterhin moeglich.',
],
];
}
@@ -70,6 +102,8 @@ class AuthService
'email' => $email,
'tenant' => $tenant,
'redirect_to' => 'https://' . $tenant['domain'] . '/login',
'login_modes' => ['password', 'adfs_oidc'],
'policy' => $this->identityPolicy(),
];
}
@@ -78,12 +112,16 @@ class AuthService
'status' => 'select-tenant',
'email' => $email,
'tenants' => $matches,
'login_modes' => ['password', 'adfs_oidc'],
'policy' => $this->identityPolicy(),
];
}
return [
'status' => 'request-tenant',
'email' => $email !== '' ? $email : null,
'login_modes' => ['password', 'adfs_oidc'],
'policy' => $this->identityPolicy(),
];
}
}
@@ -15,12 +15,23 @@ class OidcProviderService
{
return [
new OidcProviderConfig(
providerKey: 'entra-default',
providerKey: 'adfs-oidc-default',
driver: 'oidc',
clientId: 'tenant-client-id',
redirectUri: '/auth/oidc/entra-default/callback',
redirectUri: '/auth/oidc/adfs-oidc-default/callback',
scopes: ['openid', 'profile', 'email']
),
];
}
public function findProvider(string $providerKey): ?OidcProviderConfig
{
foreach ($this->configuredProviders() as $provider) {
if ($provider->providerKey === $providerKey) {
return $provider;
}
}
return null;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Support;
class IdentityPolicy
{
public function toArray(): array
{
return [
'local_login_required' => true,
'external_login_mode' => 'ADFS/OIDC',
'supported_modes' => ['password', 'adfs_oidc'],
'email_mapping' => 'E-Mail-Mapping als primaerer Abgleich zwischen externer Identitaet und internem Benutzer',
'fallback' => 'Bei Dubletten oder unklaren Zuordnungen wird ein Admin-Klaerfall erzeugt.',
'recovery' => 'Lokale Anmeldung bleibt immer verfuegbar, auch wenn externe Provider ausfallen.',
'clarification' => 'Der Erstlogin soll bestehende Konten wiederverwenden und nicht stillschweigend neue Duplikate anlegen.',
'admin_clarification' => [
'title' => 'Admin-Klaerfall',
'description' => 'Wenn eine Mail-Adresse nicht eindeutig zugeordnet werden kann, landet der Fall bei den verantwortlichen Admins.',
'steps' => [
'Identitaet pruefen',
'Besten Tenant zuordnen',
'Vorhandenes Konto wiederverwenden oder anlegen',
],
],
];
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Support;
class RoleMatrix
{
public function toArray(): array
{
return [
'roles' => [
[
'key' => 'platform_admin',
'summary' => 'Globaler Zugriff auf Plattform, Tenants und Betriebssteuerung.',
],
[
'key' => 'tenant_admin',
'summary' => 'Vollzugriff im eigenen Tenant inklusive Rollen- und Inhaltsverwaltung.',
],
[
'key' => 'finance_admin',
'summary' => 'Operative Buchungsrolle fuer Einzahlungen, Storno und Finanzreports.',
],
[
'key' => 'support_contact',
'summary' => 'Bearbeitung und Abschluss von Supportvorgaengen.',
],
[
'key' => 'survey_manager',
'summary' => 'Pflege, Freigabe und Auswertung von Surveys.',
],
[
'key' => 'member',
'summary' => 'Mitglied mit Zugriff auf eigenes Profil und freigegebene Inhalte.',
],
],
'rules' => [
'Tenant-Admin hat Vollzugriff im eigenen Tenant.',
'Spezialrollen erhalten nur die fuer ihr Modul noetigen Rechte.',
'Einzahlungen duerfen nur von Verantwortlichen storniert werden.',
'Stricheintraege duerfen nur von Verantwortlichen geloescht werden.',
],
];
}
}
@@ -9,13 +9,16 @@ class DashboardService
public function summary(string $tenantId, string $memberId): array
{
$ledgerService = new LedgerService();
$balance = $ledgerService->balanceForMember($tenantId, $memberId);
$overview = $ledgerService->accountingOverview($tenantId, $memberId);
$balance = (float) ($overview['summary']['balance'] ?? 0);
return [
'balance' => $balance,
'coffee_strokes_this_month' => 5,
'payments_this_month' => 1,
'latest_booking_at' => '2026-03-20 10:30:00',
'payments_this_month' => (int) ($overview['summary']['payments'] ?? 0),
'latest_booking_at' => '2026-03-21 08:00:00',
'corrections_this_month' => (int) ($overview['summary']['corrections'] ?? 0),
'strikes_this_month' => (int) ($overview['summary']['strike_entries'] ?? 0),
];
}
}
@@ -34,6 +34,16 @@ class LedgerService
referenceType: 'coffee_entry',
referenceId: 'coffee-demo-1'
),
new LedgerEntry(
id: 'ledger-demo-3',
tenantId: $tenantId,
memberId: $memberId,
entryType: 'adjustment',
amount: 0.50,
bookedAt: '2026-03-21 08:00:00',
referenceType: 'manual-correction',
referenceId: 'correction-demo-1'
),
];
}
@@ -47,4 +57,29 @@ class LedgerService
return $balance;
}
/**
* @return array<string, mixed>
*/
public function accountingOverview(string $tenantId, string $memberId): array
{
$entries = $this->recentEntries($tenantId, $memberId);
return [
'entries' => array_map(static fn (LedgerEntry $entry): array => $entry->toArray(), $entries),
'summary' => [
'balance' => $this->balanceForMember($tenantId, $memberId),
'payments' => count(array_filter($entries, static fn (LedgerEntry $entry): bool => $entry->isPayment())),
'consumptions' => count(array_filter($entries, static fn (LedgerEntry $entry): bool => $entry->isConsumption())),
'corrections' => count(array_filter($entries, static fn (LedgerEntry $entry): bool => $entry->isCorrection())),
'strike_entries' => count(array_filter($entries, static fn (LedgerEntry $entry): bool => $entry->isStrikeEntry())),
'cancelable_payments' => count(array_filter($entries, static fn (LedgerEntry $entry): bool => $entry->canBeCancelledByResponsible())),
],
'correction_policy' => [
'payments' => 'Einzahlungen werden per Storno korrigiert und nur durch den Verantwortlichen freigegeben.',
'coffee_entries' => 'Stricheintraege bleiben loeschbar, wenn der Verantwortliche das veranlasst.',
'audit' => 'Jede Korrektur braucht einen nachvollziehbaren Ursprungseintrag und bleibt auditiert sichtbar.',
],
];
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Modules\Ledger\Controllers;
use App\Modules\Ledger\Application\LedgerService;
class LedgerController
{
public function __construct(private readonly LedgerService $ledgerService)
{
}
/**
* @return array<string, mixed>
*/
public function index(string $tenantId, string $memberId = 'tenant-member-demo'): array
{
$overview = $this->ledgerService->accountingOverview($tenantId, $memberId);
return [
'view' => 'ledger.index',
'data' => [
'title' => 'Ledger und Buchungsspur',
'ledger' => $overview['entries'] ?? [],
'ledgerOverview' => $overview,
],
];
}
}
@@ -16,8 +16,65 @@ class CoffeeEntry
) {
}
public function id(): string
{
return $this->id;
}
public function tenantId(): string
{
return $this->tenantId;
}
public function memberId(): string
{
return $this->memberId;
}
public function strokes(): int
{
return $this->strokes;
}
public function unitPrice(): float
{
return $this->unitPrice;
}
public function bookedAt(): string
{
return $this->bookedAt;
}
public function totalCost(): float
{
return $this->strokes * $this->unitPrice;
}
public function isDeletable(): bool
{
return true;
}
public function canBeDeletedByResponsible(): bool
{
return $this->isDeletable();
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id(),
'tenant_id' => $this->tenantId(),
'member_id' => $this->memberId(),
'strokes' => $this->strokes(),
'unit_price' => $this->unitPrice(),
'booked_at' => $this->bookedAt(),
'total_cost' => $this->totalCost(),
'deletable' => $this->canBeDeletedByResponsible(),
];
}
}
@@ -18,6 +18,21 @@ class LedgerEntry
) {
}
public function id(): string
{
return $this->id;
}
public function tenantId(): string
{
return $this->tenantId;
}
public function memberId(): string
{
return $this->memberId;
}
public function amount(): float
{
return $this->amount;
@@ -27,4 +42,75 @@ class LedgerEntry
{
return $this->entryType;
}
public function bookedAt(): string
{
return $this->bookedAt;
}
public function referenceType(): string
{
return $this->referenceType;
}
public function referenceId(): ?string
{
return $this->referenceId;
}
public function isCorrection(): bool
{
return in_array($this->entryType, ['adjustment', 'reversal'], true);
}
public function isConsumption(): bool
{
return $this->entryType === 'consumption';
}
public function isPayment(): bool
{
return $this->entryType === 'payment';
}
public function isStrikeEntry(): bool
{
return $this->isConsumption();
}
public function isDeletableStrike(): bool
{
return $this->isStrikeEntry();
}
public function canBeDeletedByResponsible(): bool
{
return $this->isDeletableStrike();
}
public function canBeCancelledByResponsible(): bool
{
return $this->isPayment();
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id(),
'tenant_id' => $this->tenantId(),
'member_id' => $this->memberId(),
'entry_type' => $this->entryType(),
'amount' => $this->amount(),
'booked_at' => $this->bookedAt(),
'reference_type' => $this->referenceType(),
'reference_id' => $this->referenceId(),
'correction' => $this->isCorrection(),
'strike_entry' => $this->isStrikeEntry(),
'deletable_strike' => $this->canBeDeletedByResponsible(),
'cancellable_payment' => $this->canBeCancelledByResponsible(),
];
}
}
@@ -20,8 +20,63 @@ class PaymentService
memberId: $memberId,
amount: 10.00,
method: 'manual',
bookedAt: '2026-03-20 09:00:00'
bookedAt: '2026-03-20 09:00:00',
reconciliationState: 'gematcht'
),
new Payment(
id: 'payment-demo-2',
tenantId: $tenantId,
memberId: $memberId,
amount: 5.00,
method: 'cash',
bookedAt: '2026-03-22 13:15:00',
reconciliationState: 'pruefen'
),
];
}
/**
* @return array<string, mixed>
*/
public function paymentOverview(string $tenantId, string $memberId): array
{
$payments = array_map(
static fn (Payment $payment): array => $payment->toArray(),
$this->recentPayments($tenantId, $memberId)
);
return [
'payments' => $payments,
'methods' => [
[
'key' => 'cash',
'label' => 'Bar',
'state' => 'direkt',
],
[
'key' => 'bank',
'label' => 'Ueberweisung',
'state' => 'vorbereitet',
],
[
'key' => 'paypal',
'label' => 'PayPal',
'state' => 'tenant-konfigurierbar',
],
],
'reconciliation' => [
'status' => 'planned',
'message' => 'Reconciliation wird als Folgepaket vorbereitet, nicht als MVP-Import.',
'states' => ['neu', 'gematcht', 'pruefen', 'storniert'],
'responsible_rule' => 'Storno und Korrekturen werden nur durch Verantwortliche ausgelöst.',
],
'summary' => [
'count' => (string) count($payments),
'total_amount' => array_sum(array_map(static fn (array $payment): float => (float) ($payment['amount'] ?? 0), $payments)),
'latest_booking' => $payments[0]['booked_at'] ?? null,
'cancelable_payments' => count(array_filter($payments, static fn (array $payment): bool => (bool) ($payment['cancellable'] ?? false))),
'reconciliation_pending' => count(array_filter($payments, static fn (array $payment): bool => ($payment['reconciliation_state'] ?? 'neu') !== 'gematcht')),
],
];
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Modules\Payments\Controllers;
use App\Modules\Payments\Application\PaymentService;
class PaymentsController
{
public function __construct(private readonly PaymentService $paymentService)
{
}
/**
* @return array<string, mixed>
*/
public function index(string $tenantId, string $memberId = 'tenant-member-demo'): array
{
$overview = $this->paymentService->paymentOverview($tenantId, $memberId);
return [
'view' => 'payments.index',
'data' => [
'title' => 'Einzahlungen und Zahlungsarten',
'payments' => $overview['payments'] ?? [],
'paymentOverview' => $overview,
],
];
}
}
@@ -12,12 +12,70 @@ class Payment
private readonly string $memberId,
private readonly float $amount,
private readonly string $method,
private readonly string $bookedAt
private readonly string $bookedAt,
private readonly string $reconciliationState = 'neu'
) {
}
public function id(): string
{
return $this->id;
}
public function tenantId(): string
{
return $this->tenantId;
}
public function memberId(): string
{
return $this->memberId;
}
public function amount(): float
{
return $this->amount;
}
public function method(): string
{
return $this->method;
}
public function bookedAt(): string
{
return $this->bookedAt;
}
public function reconciliationState(): string
{
return $this->reconciliationState;
}
public function isCancellable(): bool
{
return $this->reconciliationState !== 'storniert';
}
public function canBeCancelledByResponsible(): bool
{
return $this->isCancellable();
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id(),
'tenant_id' => $this->tenantId(),
'member_id' => $this->memberId(),
'amount' => $this->amount(),
'method' => $this->method(),
'booked_at' => $this->bookedAt(),
'reconciliation_state' => $this->reconciliationState(),
'cancellable' => $this->canBeCancelledByResponsible(),
];
}
}
@@ -0,0 +1,626 @@
<?php
declare(strict_types=1);
namespace App\Modules\Support\Application;
use App\Modules\Support\Domain\SupportRequest;
use App\Modules\Support\Domain\SupportRequestMessage;
use PDO;
use RuntimeException;
class SupportService
{
private const DIRECTIONS = [
'member_to_tenant' => [
'label' => 'Mitglied -> Tenant',
'route_target' => 'tenant_responsible',
],
'tenant_to_platform' => [
'label' => 'Tenant -> Plattform',
'route_target' => 'platform_admin',
],
];
private const STATUSES = [
'new',
'open',
'in_progress',
'waiting_on_user',
'resolved',
'closed',
];
private const PRIORITIES = [
'low',
'normal',
'high',
'urgent',
];
private const CATEGORIES = [
'general',
'payment',
'access',
'content',
'survey',
'technical',
'other',
];
private const ROUTE_TARGETS = [
'tenant_responsible',
'platform_admin',
];
/**
* @return array<string, array<string, string>>
*/
public function directions(): array
{
return self::DIRECTIONS;
}
/**
* @return array<int, string>
*/
public function statuses(): array
{
return self::STATUSES;
}
/**
* @return array<int, string>
*/
public function priorities(): array
{
return self::PRIORITIES;
}
/**
* @return array<int, string>
*/
public function categories(): array
{
return self::CATEGORIES;
}
/**
* @return array<int, string>
*/
public function routeTargets(): array
{
return self::ROUTE_TARGETS;
}
public function canManage(array $auth): bool
{
return $this->isPlatformAdmin($auth) || $this->hasRole($auth, 'tenant_admin') || $this->hasRole($auth, 'support_contact');
}
public function canReply(array $auth, SupportRequest $request): bool
{
if ($this->canManage($auth)) {
return true;
}
return (string) ($auth['user_id'] ?? '') !== ''
&& (
(string) ($request->requesterUserId ?? '') === (string) ($auth['user_id'] ?? '')
|| (string) ($request->requesterTenantUserId ?? '') === (string) ($auth['tenant_user_id'] ?? '')
);
}
/**
* @return array<int, SupportRequest>
*/
public function listForTenant(PDO $pdo, array $auth): array
{
$tenantId = (string) ($auth['tenant_id'] ?? '');
if ($tenantId === '') {
return [];
}
if ($this->canManage($auth)) {
$rows = $this->queryAll(
$pdo,
<<<'SQL'
SELECT *
FROM support_requests
WHERE tenant_id = :tenant_id
ORDER BY last_message_at DESC, created_at DESC
SQL,
['tenant_id' => $tenantId]
);
} else {
$rows = $this->queryAll(
$pdo,
<<<'SQL'
SELECT *
FROM support_requests
WHERE tenant_id = :tenant_id
AND (
requester_user_id = :user_id
OR requester_tenant_user_id = :tenant_user_id
)
ORDER BY last_message_at DESC, created_at DESC
SQL,
[
'tenant_id' => $tenantId,
'user_id' => (string) ($auth['user_id'] ?? ''),
'tenant_user_id' => (string) ($auth['tenant_user_id'] ?? ''),
]
);
}
return array_map(fn(array $row): SupportRequest => $this->hydrateRequest($pdo, $row), $rows);
}
public function find(PDO $pdo, array $auth, string $requestId): ?SupportRequest
{
$tenantId = (string) ($auth['tenant_id'] ?? '');
if ($tenantId === '' || trim($requestId) === '') {
return null;
}
$row = $this->queryOne(
$pdo,
<<<'SQL'
SELECT *
FROM support_requests
WHERE tenant_id = :tenant_id
AND id = :id
LIMIT 1
SQL,
[
'tenant_id' => $tenantId,
'id' => $requestId,
]
);
if ($row === null) {
return null;
}
$request = $this->hydrateRequest($pdo, $row);
if (!$this->canManage($auth) && !$this->canReply($auth, $request)) {
return null;
}
return $request;
}
/**
* @return array<string, int>
*/
public function summary(PDO $pdo, array $auth): array
{
$requests = $this->listForTenant($pdo, $auth);
$summary = [
'all' => count($requests),
'new' => 0,
'open' => 0,
'waiting_on_user' => 0,
'resolved' => 0,
'closed' => 0,
];
foreach ($requests as $request) {
if (array_key_exists($request->status, $summary)) {
$summary[$request->status]++;
}
}
return $summary;
}
public function create(PDO $pdo, array $auth, array $input): SupportRequest
{
$tenantId = (string) ($auth['tenant_id'] ?? '');
$subject = trim((string) ($input['subject'] ?? ''));
$message = trim((string) ($input['message'] ?? ''));
$category = trim((string) ($input['category'] ?? 'general'));
$direction = trim((string) ($input['request_direction'] ?? 'member_to_tenant'));
$priority = trim((string) ($input['priority'] ?? 'normal'));
if ($tenantId === '') {
throw new RuntimeException('Der Support benötigt einen aktiven Tenant.');
}
if ($subject === '') {
throw new RuntimeException('Bitte gib einen Betreff an.');
}
if ($message === '') {
throw new RuntimeException('Bitte beschreibe dein Anliegen.');
}
if (!in_array($category, self::CATEGORIES, true)) {
$category = 'general';
}
if (!array_key_exists($direction, self::DIRECTIONS)) {
$direction = 'member_to_tenant';
}
if (!in_array($priority, self::PRIORITIES, true)) {
$priority = 'normal';
}
$routeTarget = self::DIRECTIONS[$direction]['route_target'];
$requestNumber = 'SR-' . date('YmdHis') . '-' . strtoupper(substr(app_uuid(), 0, 8));
$now = date('Y-m-d H:i:s');
$requestId = app_uuid();
$authorName = trim((string) ($auth['display_name'] ?? $auth['email'] ?? ''));
$authorEmail = strtolower(trim((string) ($auth['email'] ?? '')));
$authorUserId = (string) ($auth['user_id'] ?? '');
$authorTenantUserId = (string) ($auth['tenant_user_id'] ?? '');
if ($authorName === '') {
$authorName = 'Unbekannt';
}
$pdo->beginTransaction();
try {
$this->execute(
$pdo,
<<<'SQL'
INSERT INTO support_requests (
id, tenant_id, request_number, request_direction, route_target,
category, priority, status, subject, message,
requester_user_id, requester_tenant_user_id, requester_name, requester_email,
assigned_to_user_id, assigned_to_tenant_user_id, last_message_at, resolved_at, closed_at,
created_at, updated_at
) VALUES (
:id, :tenant_id, :request_number, :request_direction, :route_target,
:category, :priority, :status, :subject, :message,
:requester_user_id, :requester_tenant_user_id, :requester_name, :requester_email,
NULL, NULL, :last_message_at, NULL, NULL,
:created_at, :updated_at
)
SQL,
[
'id' => $requestId,
'tenant_id' => $tenantId,
'request_number' => $requestNumber,
'request_direction' => $direction,
'route_target' => $routeTarget,
'category' => $category,
'priority' => $priority,
'status' => 'new',
'subject' => $subject,
'message' => $message,
'requester_user_id' => $authorUserId !== '' ? $authorUserId : null,
'requester_tenant_user_id' => $authorTenantUserId !== '' ? $authorTenantUserId : null,
'requester_name' => $authorName,
'requester_email' => $authorEmail,
'last_message_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]
);
$this->appendMessage(
$pdo,
$tenantId,
$requestId,
[
'author_user_id' => $authorUserId !== '' ? $authorUserId : null,
'author_tenant_user_id' => $authorTenantUserId !== '' ? $authorTenantUserId : null,
'author_name' => $authorName,
'author_role' => $this->resolveRoleLabel($auth),
'message_kind' => 'request_created',
'body' => $message,
'new_status' => 'new',
'created_at' => $now,
]
);
$pdo->commit();
} catch (\Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $exception;
}
$created = $this->find($pdo, $auth, $requestId);
if ($created === null) {
throw new RuntimeException('Der Support-Vorgang konnte nicht geladen werden.');
}
return $created;
}
public function addMessage(PDO $pdo, array $auth, string $requestId, array $input): SupportRequest
{
$request = $this->find($pdo, $auth, $requestId);
if ($request === null) {
throw new RuntimeException('Der ausgewählte Support-Vorgang wurde nicht gefunden.');
}
$body = trim((string) ($input['body'] ?? ''));
$messageKind = trim((string) ($input['message_kind'] ?? 'public_reply'));
if ($body === '') {
throw new RuntimeException('Bitte gib eine Nachricht an.');
}
if (!$this->canManage($auth) && !$this->canReply($auth, $request)) {
throw new RuntimeException('Für diesen Support-Vorgang hast du keine Schreibrechte.');
}
if ($messageKind !== 'internal_note' && $messageKind !== 'public_reply') {
$messageKind = $this->canManage($auth) ? 'internal_note' : 'public_reply';
}
if (!$this->canManage($auth)) {
$messageKind = 'public_reply';
}
$now = date('Y-m-d H:i:s');
$authorName = trim((string) ($auth['display_name'] ?? $auth['email'] ?? ''));
if ($authorName === '') {
$authorName = 'Unbekannt';
}
$pdo->beginTransaction();
try {
$this->appendMessage(
$pdo,
(string) $request->tenantId,
$request->id,
[
'author_user_id' => (string) ($auth['user_id'] ?? '') !== '' ? (string) ($auth['user_id'] ?? '') : null,
'author_tenant_user_id' => (string) ($auth['tenant_user_id'] ?? '') !== '' ? (string) ($auth['tenant_user_id'] ?? '') : null,
'author_name' => $authorName,
'author_role' => $this->resolveRoleLabel($auth),
'message_kind' => $messageKind,
'body' => $body,
'new_status' => null,
'created_at' => $now,
]
);
$this->execute(
$pdo,
'UPDATE support_requests SET last_message_at = :last_message_at, updated_at = :updated_at WHERE id = :id',
[
'last_message_at' => $now,
'updated_at' => $now,
'id' => $request->id,
]
);
$pdo->commit();
} catch (\Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $exception;
}
$updated = $this->find($pdo, $auth, $request->id);
if ($updated === null) {
throw new RuntimeException('Der Support-Vorgang konnte nach der Antwort nicht geladen werden.');
}
return $updated;
}
public function updateRequest(PDO $pdo, array $auth, string $requestId, array $input): SupportRequest
{
if (!$this->canManage($auth)) {
throw new RuntimeException('Nur Verantwortliche dürfen Support-Vorgänge bearbeiten.');
}
$request = $this->find($pdo, $auth, $requestId);
if ($request === null) {
throw new RuntimeException('Der ausgewählte Support-Vorgang wurde nicht gefunden.');
}
$status = trim((string) ($input['status'] ?? $request->status));
$routeTarget = trim((string) ($input['route_target'] ?? $request->routeTarget));
$note = trim((string) ($input['note'] ?? ''));
if (!in_array($status, self::STATUSES, true)) {
throw new RuntimeException('Bitte wähle einen gültigen Status.');
}
if (!in_array($routeTarget, self::ROUTE_TARGETS, true)) {
$routeTarget = $request->routeTarget;
}
$authorName = trim((string) ($auth['display_name'] ?? $auth['email'] ?? ''));
if ($authorName === '') {
$authorName = 'Verantwortlich';
}
$now = date('Y-m-d H:i:s');
$resolvedAt = in_array($status, ['resolved', 'closed'], true) ? $now : null;
$closedAt = $status === 'closed' ? $now : null;
$assigneeUserId = (string) ($auth['user_id'] ?? '') !== '' ? (string) ($auth['user_id'] ?? '') : null;
$assigneeTenantUserId = (string) ($auth['tenant_user_id'] ?? '') !== '' ? (string) ($auth['tenant_user_id'] ?? '') : null;
$pdo->beginTransaction();
try {
$this->execute(
$pdo,
<<<'SQL'
UPDATE support_requests
SET status = :status,
route_target = :route_target,
assigned_to_user_id = :assigned_to_user_id,
assigned_to_tenant_user_id = :assigned_to_tenant_user_id,
resolved_at = :resolved_at,
closed_at = :closed_at,
last_message_at = :last_message_at,
updated_at = :updated_at
WHERE id = :id
SQL,
[
'status' => $status,
'route_target' => $routeTarget,
'assigned_to_user_id' => $assigneeUserId,
'assigned_to_tenant_user_id' => $assigneeTenantUserId,
'resolved_at' => $resolvedAt,
'closed_at' => $closedAt,
'last_message_at' => $now,
'updated_at' => $now,
'id' => $request->id,
]
);
$this->appendMessage(
$pdo,
(string) $request->tenantId,
$request->id,
[
'author_user_id' => $assigneeUserId,
'author_tenant_user_id' => $assigneeTenantUserId,
'author_name' => $authorName,
'author_role' => $this->resolveRoleLabel($auth),
'message_kind' => 'status_change',
'body' => $note !== '' ? $note : 'Status auf ' . $status . ' gesetzt.',
'new_status' => $status,
'created_at' => $now,
]
);
$pdo->commit();
} catch (\Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $exception;
}
$updated = $this->find($pdo, $auth, $request->id);
if ($updated === null) {
throw new RuntimeException('Der Support-Vorgang konnte nach dem Update nicht geladen werden.');
}
return $updated;
}
/**
* @param array<string, mixed> $row
*/
private function hydrateRequest(PDO $pdo, array $row): SupportRequest
{
$messages = array_map(
static fn(array $messageRow): SupportRequestMessage => SupportRequestMessage::fromRow($messageRow),
$this->queryAll(
$pdo,
'SELECT * FROM support_request_messages WHERE support_request_id = :support_request_id ORDER BY created_at ASC',
['support_request_id' => (string) ($row['id'] ?? '')]
)
);
return SupportRequest::fromRow($row, $messages);
}
/**
* @param array<string, mixed> $payload
*/
private function appendMessage(PDO $pdo, string $tenantId, string $requestId, array $payload): void
{
$this->execute(
$pdo,
<<<'SQL'
INSERT INTO support_request_messages (
id, support_request_id, tenant_id, author_user_id, author_tenant_user_id,
author_name, author_role, message_kind, new_status, body, created_at
) VALUES (
:id, :support_request_id, :tenant_id, :author_user_id, :author_tenant_user_id,
:author_name, :author_role, :message_kind, :new_status, :body, :created_at
)
SQL,
[
'id' => app_uuid(),
'support_request_id' => $requestId,
'tenant_id' => $tenantId,
'author_user_id' => $payload['author_user_id'] ?? null,
'author_tenant_user_id' => $payload['author_tenant_user_id'] ?? null,
'author_name' => $payload['author_name'] ?? 'Unbekannt',
'author_role' => $payload['author_role'] ?? 'member',
'message_kind' => $payload['message_kind'] ?? 'public_reply',
'new_status' => $payload['new_status'] ?? null,
'body' => $payload['body'] ?? '',
'created_at' => $payload['created_at'] ?? date('Y-m-d H:i:s'),
]
);
}
private function resolveRoleLabel(array $auth): string
{
if ($this->isPlatformAdmin($auth)) {
return 'platform_admin';
}
$roles = array_map('strval', $auth['roles'] ?? []);
foreach (['tenant_admin', 'support_contact', 'finance_admin', 'survey_manager'] as $role) {
if (in_array($role, $roles, true)) {
return $role;
}
}
return 'member';
}
private function hasRole(array $auth, string $role): bool
{
return in_array($role, array_map('strval', $auth['roles'] ?? []), true);
}
private function isPlatformAdmin(array $auth): bool
{
return !empty($auth['is_platform_admin']);
}
/**
* @return array<int, array<string, mixed>>
*/
private function queryAll(PDO $pdo, string $sql, array $params = []): array
{
$statement = $pdo->prepare($sql);
$statement->execute($params);
return $statement->fetchAll() ?: [];
}
/**
* @return array<string, mixed>|null
*/
private function queryOne(PDO $pdo, string $sql, array $params = []): ?array
{
$statement = $pdo->prepare($sql);
$statement->execute($params);
$row = $statement->fetch();
return is_array($row) ? $row : null;
}
private function execute(PDO $pdo, string $sql, array $params = []): void
{
$statement = $pdo->prepare($sql);
$statement->execute($params);
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Modules\Support\Controllers;
use App\Modules\Support\Application\SupportService;
use PDO;
class SupportController
{
public function __construct(private readonly SupportService $supportService)
{
}
/**
* @return array<string, mixed>
*/
public function index(PDO $pdo, array $auth, array $query = []): array
{
$requests = $this->supportService->listForTenant($pdo, $auth);
$requestId = trim((string) ($query['request'] ?? ''));
$detail = $requestId !== '' ? $this->supportService->find($pdo, $auth, $requestId) : null;
if ($detail === null && $requestId !== '') {
$detailNotice = 'Der ausgewählte Vorgang wurde nicht gefunden oder ist nicht sichtbar.';
} else {
$detailNotice = null;
}
return [
'title' => 'Support',
'can_manage' => $this->supportService->canManage($auth),
'summary' => $this->supportService->summary($pdo, $auth),
'requests' => array_map(static fn($request): array => $request->toArray(), $requests),
'detail' => $detail?->toArray(),
'detail_notice' => $detailNotice,
'categories' => $this->supportService->categories(),
'directions' => $this->supportService->directions(),
'statuses' => $this->supportService->statuses(),
'priorities' => $this->supportService->priorities(),
'route_targets' => $this->supportService->routeTargets(),
];
}
public function handlePost(PDO $pdo, array $auth, array $post, array $query = []): void
{
$action = (string) ($post['action'] ?? '');
$requestId = trim((string) ($post['request_id'] ?? ''));
if ($action === 'create-request') {
$request = $this->supportService->create($pdo, $auth, $post);
app_flash('Der Support-Vorgang ' . $request->requestNumber . ' wurde angelegt.', 'success');
app_redirect('/support/?request=' . $request->id);
}
if ($action === 'add-message' && $requestId !== '') {
$request = $this->supportService->addMessage($pdo, $auth, $requestId, $post);
app_flash('Deine Nachricht wurde zum Vorgang hinzugefügt.', 'success');
app_redirect('/support/?request=' . $request->id);
}
if ($action === 'update-request' && $requestId !== '') {
$request = $this->supportService->updateRequest($pdo, $auth, $requestId, $post);
app_flash('Der Vorgang wurde aktualisiert.', 'success');
app_redirect('/support/?request=' . $request->id);
}
app_flash('Die Aktion konnte nicht verarbeitet werden.', 'error');
app_redirect('/support/' . (isset($query['request']) && $query['request'] !== '' ? '?request=' . rawurlencode((string) $query['request']) : ''));
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Modules\Support\Domain;
class SupportRequest
{
/**
* @param array<int, SupportRequestMessage> $messages
*/
public function __construct(
public readonly string $id,
public readonly string $tenantId,
public readonly string $requestNumber,
public readonly string $requestDirection,
public readonly string $routeTarget,
public readonly string $category,
public readonly string $priority,
public readonly string $status,
public readonly string $subject,
public readonly string $message,
public readonly string $requesterName,
public readonly string $requesterEmail,
public readonly string $lastMessageAt,
public readonly string $createdAt,
public readonly string $updatedAt,
public readonly ?string $requesterUserId = null,
public readonly ?string $requesterTenantUserId = null,
public readonly ?string $assignedToUserId = null,
public readonly ?string $assignedToTenantUserId = null,
public readonly ?string $resolvedAt = null,
public readonly ?string $closedAt = null,
public readonly array $messages = [],
) {
}
/**
* @param array<string, mixed> $row
* @param array<int, SupportRequestMessage> $messages
*/
public static function fromRow(array $row, array $messages = []): self
{
return new self(
(string) ($row['id'] ?? ''),
(string) ($row['tenant_id'] ?? ''),
(string) ($row['request_number'] ?? ''),
(string) ($row['request_direction'] ?? 'member_to_tenant'),
(string) ($row['route_target'] ?? 'tenant_responsible'),
(string) ($row['category'] ?? 'general'),
(string) ($row['priority'] ?? 'normal'),
(string) ($row['status'] ?? 'new'),
(string) ($row['subject'] ?? ''),
(string) ($row['message'] ?? ''),
(string) ($row['requester_name'] ?? ''),
(string) ($row['requester_email'] ?? ''),
(string) ($row['last_message_at'] ?? ''),
(string) ($row['created_at'] ?? ''),
(string) ($row['updated_at'] ?? ''),
isset($row['requester_user_id']) ? (string) $row['requester_user_id'] : null,
isset($row['requester_tenant_user_id']) ? (string) $row['requester_tenant_user_id'] : null,
isset($row['assigned_to_user_id']) ? (string) $row['assigned_to_user_id'] : null,
isset($row['assigned_to_tenant_user_id']) ? (string) $row['assigned_to_tenant_user_id'] : null,
isset($row['resolved_at']) && $row['resolved_at'] !== '' ? (string) $row['resolved_at'] : null,
isset($row['closed_at']) && $row['closed_at'] !== '' ? (string) $row['closed_at'] : null,
$messages,
);
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'tenant_id' => $this->tenantId,
'request_number' => $this->requestNumber,
'request_direction' => $this->requestDirection,
'route_target' => $this->routeTarget,
'category' => $this->category,
'priority' => $this->priority,
'status' => $this->status,
'subject' => $this->subject,
'message' => $this->message,
'requester_name' => $this->requesterName,
'requester_email' => $this->requesterEmail,
'last_message_at' => $this->lastMessageAt,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
'requester_user_id' => $this->requesterUserId,
'requester_tenant_user_id' => $this->requesterTenantUserId,
'assigned_to_user_id' => $this->assignedToUserId,
'assigned_to_tenant_user_id' => $this->assignedToTenantUserId,
'resolved_at' => $this->resolvedAt,
'closed_at' => $this->closedAt,
'messages' => array_map(
static fn (SupportRequestMessage $message): array => $message->toArray(),
$this->messages
),
];
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Modules\Support\Domain;
class SupportRequestMessage
{
public function __construct(
public readonly string $id,
public readonly string $supportRequestId,
public readonly string $tenantId,
public readonly string $authorName,
public readonly string $authorRole,
public readonly string $messageKind,
public readonly string $body,
public readonly string $createdAt,
public readonly ?string $authorUserId = null,
public readonly ?string $authorTenantUserId = null,
public readonly ?string $newStatus = null,
) {
}
/**
* @param array<string, mixed> $row
*/
public static function fromRow(array $row): self
{
return new self(
(string) ($row['id'] ?? ''),
(string) ($row['support_request_id'] ?? ''),
(string) ($row['tenant_id'] ?? ''),
(string) ($row['author_name'] ?? ''),
(string) ($row['author_role'] ?? 'member'),
(string) ($row['message_kind'] ?? 'public_reply'),
(string) ($row['body'] ?? ''),
(string) ($row['created_at'] ?? ''),
isset($row['author_user_id']) ? (string) $row['author_user_id'] : null,
isset($row['author_tenant_user_id']) ? (string) $row['author_tenant_user_id'] : null,
isset($row['new_status']) && $row['new_status'] !== '' ? (string) $row['new_status'] : null,
);
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'support_request_id' => $this->supportRequestId,
'tenant_id' => $this->tenantId,
'author_name' => $this->authorName,
'author_role' => $this->authorRole,
'message_kind' => $this->messageKind,
'body' => $this->body,
'created_at' => $this->createdAt,
'author_user_id' => $this->authorUserId,
'author_tenant_user_id' => $this->authorTenantUserId,
'new_status' => $this->newStatus,
];
}
}
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/Domain/SupportRequest.php';
require_once __DIR__ . '/Domain/SupportRequestMessage.php';
require_once __DIR__ . '/Application/SupportService.php';
require_once __DIR__ . '/Controllers/SupportController.php';
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Modules\Surveys\Application;
use App\Modules\Surveys\Domain\Survey;
use App\Modules\Surveys\Domain\SurveyPublication;
use App\Modules\Surveys\Domain\SurveyQuestion;
class SurveyService
@@ -19,7 +20,7 @@ class SurveyService
id: 'survey-demo-1',
tenantId: $tenantId,
title: 'Zufriedenheit mit dem Kaffeeangebot',
status: 'active',
status: 'draft',
questions: [
new SurveyQuestion(
id: 'survey-question-demo-1',
@@ -27,9 +28,155 @@ class SurveyService
question: 'Wie zufrieden bist du mit dem aktuellen Angebot?',
questionType: 'scale',
required: true,
options: ['1 - sehr unzufrieden', '2', '3', '4', '5 - sehr zufrieden'],
),
new SurveyQuestion(
id: 'survey-question-demo-2',
surveyId: 'survey-demo-1',
question: 'Welche Sorten sollen erhalten bleiben?',
questionType: 'multi_select',
required: false,
options: ['Espresso', 'Filter', 'Haferdrink', 'Decaf'],
),
],
),
new Survey(
id: 'survey-demo-2',
tenantId: $tenantId,
title: 'Team-Organisation und Office-Ablauf',
status: 'in_review',
questions: [
new SurveyQuestion(
id: 'survey-question-demo-3',
surveyId: 'survey-demo-2',
question: 'Was soll im Arbeitsablauf vereinfacht werden?',
questionType: 'text',
required: true,
),
],
),
];
}
/**
* @return array<string, mixed>
*/
public function managerBoard(string $tenantId): array
{
$drafts = $this->activeSurveys($tenantId);
$snapshots = $this->publishedSnapshots($tenantId);
return [
'metrics' => [
[
'label' => 'Entwuerfe',
'value' => (string) count(array_filter($drafts, static fn (Survey $survey): bool => $survey->isDraft() || $survey->status() === 'in_review')),
'detail' => 'Survey-Entwuerfe und Veroeffentlichungsvorstufen.',
],
[
'label' => 'Snapshots',
'value' => (string) count($snapshots),
'detail' => 'Freigegebene Versionen fuer Mitglieder und Tenant-Admins.',
],
[
'label' => 'Antworten',
'value' => (string) array_sum(array_map(static fn (SurveyPublication $publication): int => $publication->responseCount(), $snapshots)),
'detail' => 'Aggregierte Ruemeldungen pro freigegebenem Stand.',
],
[
'label' => 'Veroeffentlichung',
'value' => 'Snapshot',
'detail' => 'Mitglieder sehen freigegebene Versionen statt Live-Entwuerfe.',
],
],
'draft_surveys' => $drafts,
'published_snapshots' => $snapshots,
'workflow' => [
[
'step' => '1. Entwurf',
'detail' => 'Tenant-Admins oder Survey-Manager legen Fragen, Antworttypen und Laufzeit an.',
],
[
'step' => '2. Pruefung',
'detail' => 'Die Freigabe erfolgt durch berechtigte Tenant-Verantwortliche.',
],
[
'step' => '3. Snapshot',
'detail' => 'Zur Veroeffentlichung wird ein stabiler Stand eingefroren.',
],
[
'step' => '4. Auswertung',
'detail' => 'Tenant-Admins sehen Auswertungen, Mitglieder nur freigegebene Ergebnisse.',
],
],
'roles' => [
[
'role' => 'tenant_admin',
'capability' => 'Vollzugriff auf Erfassung, Freigabe, Auswertung und Veroeffentlichung.',
],
[
'role' => 'survey_manager',
'capability' => 'Umfragen anlegen, pflegen und zur Freigabe vorbereiten.',
],
[
'role' => 'member',
'capability' => 'Freigegebene Snapshots lesen und an aktiven Umfragen teilnehmen.',
],
],
'publishing_rules' => [
'Fachseite bearbeitet Entwuerfe live.',
'Mitglieder sehen nur freigegebene Snapshots.',
'Veroeffentlicht wird ein stabiler Stand mit nachvollziehbarer Version.',
'Ende und Freischaltung koennen tenantweit gesteuert werden.',
],
];
}
/**
* @return array<string, mixed>
*/
public function memberBoard(string $tenantId): array
{
$snapshots = $this->publishedSnapshots($tenantId);
return [
'snapshots' => $snapshots,
'participation' => [
'Antworten laufen tenantbezogen zusammen.',
'Mitglieder sehen nur freigegebene Versionen.',
'Mehrfachauswahl, Freitext und Bewertungsfragen sind als Zielbild vorgesehen.',
],
];
}
/**
* @return array<int, SurveyPublication>
*/
public function publishedSnapshots(string $tenantId): array
{
return [
new SurveyPublication(
id: 'survey-publication-demo-1',
surveyId: 'survey-demo-1',
surveyTitle: 'Zufriedenheit mit dem Kaffeeangebot',
versionLabel: 'Snapshot v3',
publishedAt: '2026-03-25 09:00:00',
publishedBy: 'Tenant Admin',
memberVisible: true,
responseCount: 28,
summary: 'Die Mehrheit bewertet das Angebot positiv. Prioritaet sind Sortiment und Nachschub.',
),
new SurveyPublication(
id: 'survey-publication-demo-2',
surveyId: 'survey-demo-2',
surveyTitle: 'Team-Organisation und Office-Ablauf',
versionLabel: 'Snapshot v1',
publishedAt: '2026-03-28 15:30:00',
publishedBy: 'Survey Manager',
memberVisible: true,
responseCount: 16,
summary: 'Der Ablauf wird als solide beschrieben, es werden klarere Verantwortlichkeiten gewuenscht.',
),
];
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Modules\Surveys\Controllers;
use App\Modules\Surveys\Application\SurveyService;
class SurveyController
{
public function __construct(private readonly SurveyService $surveyService)
{
}
public function index(string $tenantId): array
{
return [
'view' => 'surveys.index',
'data' => [
'title' => 'Survey Admin und Snapshot Publishing',
'board' => $this->surveyService->managerBoard($tenantId),
'memberBoard' => $this->surveyService->memberBoard($tenantId),
'draftSurveys' => $this->surveyService->activeSurveys($tenantId),
],
];
}
}
@@ -45,4 +45,19 @@ class Survey
{
return $this->questions;
}
public function questionCount(): int
{
return count($this->questions);
}
public function isPublished(): bool
{
return $this->status === 'published';
}
public function isDraft(): bool
{
return $this->status === 'draft';
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Modules\Surveys\Domain;
class SurveyPublication
{
public function __construct(
private readonly string $id,
private readonly string $surveyId,
private readonly string $surveyTitle,
private readonly string $versionLabel,
private readonly string $publishedAt,
private readonly string $publishedBy,
private readonly bool $memberVisible,
private readonly int $responseCount,
private readonly string $summary,
) {
}
public function id(): string
{
return $this->id;
}
public function surveyId(): string
{
return $this->surveyId;
}
public function surveyTitle(): string
{
return $this->surveyTitle;
}
public function versionLabel(): string
{
return $this->versionLabel;
}
public function publishedAt(): string
{
return $this->publishedAt;
}
public function publishedBy(): string
{
return $this->publishedBy;
}
public function memberVisible(): bool
{
return $this->memberVisible;
}
public function responseCount(): int
{
return $this->responseCount;
}
public function summary(): string
{
return $this->summary;
}
}
@@ -12,6 +12,7 @@ class SurveyQuestion
private readonly string $question,
private readonly string $questionType,
private readonly bool $required,
private readonly array $options = [],
) {
}
@@ -39,4 +40,12 @@ class SurveyQuestion
{
return $this->required;
}
/**
* @return array<int, string>
*/
public function options(): array
{
return $this->options;
}
}
@@ -5,11 +5,14 @@ declare(strict_types=1);
namespace App\Modules\Tenants\Controllers;
use App\Modules\Tenants\Services\TenantService;
use App\Modules\Tenants\Services\TenantRoleService;
class TenantConsoleController
{
public function __construct(private readonly TenantService $tenantService)
{
public function __construct(
private readonly TenantService $tenantService,
private readonly TenantRoleService $tenantRoleService
) {
}
public function index(): array
@@ -19,6 +22,18 @@ class TenantConsoleController
'data' => [
'title' => 'Tenant Console',
'overview' => $this->tenantService->adminOverview(),
'roleOverview' => $this->tenantRoleService->roleOverview(),
],
];
}
public function roles(?string $tenantId = null): array
{
return [
'view' => 'tenants.roles',
'data' => [
'title' => 'Tenant Rollen und Rechte',
'overview' => $this->tenantRoleService->roleOverview($tenantId),
],
];
}
@@ -21,4 +21,51 @@ class TenantUser
{
return $this->status === 'active';
}
public function hasRole(string $role): bool
{
return in_array($role, $this->roles, true);
}
public function isTenantAdmin(): bool
{
return $this->hasRole('tenant_admin');
}
public function isFinanceAdmin(): bool
{
return $this->hasRole('finance_admin');
}
public function isSupportContact(): bool
{
return $this->hasRole('support_contact');
}
public function isSurveyManager(): bool
{
return $this->hasRole('survey_manager');
}
/**
* @return array<int, string>
*/
public function roleLabels(): array
{
if ($this->roles === []) {
return ['Mitglied'];
}
$labels = [
'tenant_admin' => 'Tenant-Admin',
'finance_admin' => 'finance_admin',
'support_contact' => 'support_contact',
'survey_manager' => 'survey_manager',
];
return array_map(
static fn (string $role): string => $labels[$role] ?? $role,
$this->roles
);
}
}
@@ -17,4 +17,24 @@ class TenantMembershipService
roles: ['tenant_admin']
);
}
/**
* @return array<int, string>
*/
public function specialRoles(): array
{
return [
'finance_admin',
'support_contact',
'survey_manager',
];
}
/**
* @return array<int, string>
*/
public function delegableRoles(): array
{
return $this->specialRoles();
}
}
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace App\Modules\Tenants\Services;
class TenantRoleService
{
public function __construct(
private readonly TenantService $tenantService,
private readonly TenantMembershipService $membershipService
) {
}
/**
* @return array<int, array<string, mixed>>
*/
public function roleMatrix(): array
{
return [
[
'key' => 'tenant_admin',
'label' => 'Tenant-Admin',
'scope' => 'Gesamter Tenant',
'priority' => 'Vollzugriff',
'rights' => [
'Mitglieder, Rollen und Einladungen verwalten',
'Content, FAQ, Surveys und Support steuern',
'Buchungen, Einzahlungen und Korrekturen freigeben',
'Tenant-Einstellungen und Vier-Augen-Optionen konfigurieren',
],
'delegation' => 'Kann finance_admin, support_contact und survey_manager vergeben oder entziehen.',
'delegable_roles' => $this->membershipService->delegableRoles(),
'can_delegate' => true,
'tenant_wide_toggle' => true,
'notes' => 'Ein Tenant-Admin hat im Tenant Vollzugriff und koordiniert die fachlichen Spezialrollen.',
],
[
'key' => 'finance_admin',
'label' => 'finance_admin',
'scope' => 'Buchungen und Einzahlungen',
'priority' => 'Spezialrolle',
'rights' => [
'Einzahlungen erfassen und stornieren',
'Buchungen und Striche im Ledger prüfen',
'Korrekturen an Verantwortliche melden',
],
'delegation' => 'Wird vom Tenant-Admin vergeben. Keine weitere Delegation.',
'delegable_roles' => [],
'can_delegate' => false,
'tenant_wide_toggle' => true,
'notes' => 'Stornierungen bleiben an den Verantwortlichen gebunden.',
],
[
'key' => 'support_contact',
'label' => 'support_contact',
'scope' => 'Support und Routing',
'priority' => 'Spezialrolle',
'rights' => [
'Supportvorgänge lesen, routen und abschliessen',
'Mitglieder bei fachlichen Rueckfragen begleiten',
'Statuswechsel und Antworten dokumentieren',
],
'delegation' => 'Wird vom Tenant-Admin vergeben. Keine weitere Delegation.',
'delegable_roles' => [],
'can_delegate' => false,
'tenant_wide_toggle' => true,
'notes' => 'Ideal fuer fachliche Ansprechpartner im Tenant.',
],
[
'key' => 'survey_manager',
'label' => 'survey_manager',
'scope' => 'Umfragen und Freigaben',
'priority' => 'Spezialrolle',
'rights' => [
'Umfragen entwerfen und bearbeiten',
'Freigaben und Snapshots auslösen',
'Mitgliederansichten kontrollieren',
],
'delegation' => 'Wird vom Tenant-Admin vergeben. Keine weitere Delegation.',
'delegable_roles' => [],
'can_delegate' => false,
'tenant_wide_toggle' => true,
'notes' => 'Trennt Entwurf und Veröffentlichung sauber.',
],
[
'key' => 'member',
'label' => 'Mitglied',
'scope' => 'Eigener Tenant-Kontext',
'priority' => 'Standard',
'rights' => [
'Eigene Buchungen und Kontostand einsehen',
'Freigegebene Inhalte und Snapshots lesen',
'Support anstossen und Rückfragen stellen',
],
'delegation' => 'Keine Delegation.',
'delegable_roles' => [],
'can_delegate' => false,
'tenant_wide_toggle' => false,
'notes' => 'Lesend und auf den eigenen Kontext beschränkt.',
],
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function delegationRules(): array
{
return [
[
'from' => 'platform_admin',
'to' => 'tenant_admin',
'rule' => 'Initiale Freischaltung und Notfall-Override',
'approval' => 'immer',
'note' => 'Plattform-Admins legen den ersten Tenant-Admin an und sichern den Betriebszugang.',
],
[
'from' => 'tenant_admin',
'to' => 'finance_admin',
'rule' => 'Buchungskorrekturen und Einzahlungen',
'approval' => 'tenantweit konfigurierbar',
'note' => 'Vier-Augen-Freigabe kann pro Tenant aktiviert oder deaktiviert werden.',
],
[
'from' => 'tenant_admin',
'to' => 'support_contact',
'rule' => 'Supportvorgänge und Routing',
'approval' => 'tenantweit konfigurierbar',
'note' => 'Sorgt für eine klare, fachliche Bearbeitung von Vorgängen.',
],
[
'from' => 'tenant_admin',
'to' => 'survey_manager',
'rule' => 'Umfragen und Snapshot-Freigaben',
'approval' => 'tenantweit konfigurierbar',
'note' => 'Trennt Erstellung, Prüfung und Veröffentlichung sauber.',
],
];
}
/**
* @return array<string, mixed>
*/
public function roleOverview(?string $tenantId = null): array
{
$tenant = $this->resolveTenant($tenantId);
$roles = $this->roleMatrix();
return [
'tenant' => $tenant,
'metrics' => [
[
'label' => 'Rollensätze',
'value' => (string) count($roles),
'detail' => 'Ein lesbarer Rollenkatalog fuer Tenant-Admins und Betreiber.',
],
[
'label' => 'Spezialrollen',
'value' => (string) count($this->membershipService->specialRoles()),
'detail' => 'finance_admin, support_contact und survey_manager sind separat steuerbar.',
],
[
'label' => 'Tenant-Admin',
'value' => 'Vollzugriff',
'detail' => 'Tenant-Admins duerfen alle fachlichen Bereiche verwalten.',
],
[
'label' => 'Delegation',
'value' => 'Konfigurierbar',
'detail' => 'Vier-Augen-Freigaben koennen pro Tenant aktiviert oder deaktiviert werden.',
],
],
'roles' => $roles,
'delegation_rules' => $this->delegationRules(),
'tenant_snapshots' => $this->tenantSnapshots(),
'permission_groups' => [
[
'title' => 'Fachliche Kernrechte',
'items' => [
'Mitglieder',
'Buchungen',
'Einzahlungen',
'Content',
'Support',
'Surveys',
],
],
[
'title' => 'Betriebliche Rechte',
'items' => [
'Tenant-Einstellungen',
'Branding',
'Freigaben',
'Delegation',
'Vier-Augen-Regeln',
],
],
],
'notes' => [
'Tenant-Admins behalten den Gesamtueberblick und koennen Spezialrollen im Tenant vergeben.',
'Die Delegation ist tenantweit steuerbar und kann fuer bestimmte Aktionen mit Vier-Augen-Prinzip abgesichert werden.',
'Wenn eine Anwendung einen Bereich noch nicht direkt abbildet, wird er als Tenant-Funktion eingerichtet.',
],
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function tenantSnapshots(): array
{
$snapshots = [];
foreach ($this->tenantService->tenantPortfolio() as $tenant) {
$snapshots[] = [
'tenant_key' => $tenant['tenant_key'],
'name' => $tenant['name'],
'status' => $tenant['status'],
'admin_count' => $tenant['admin_count'],
'special_roles' => $tenant['tenant_key'] === 'finance-hub'
? ['tenant_admin']
: ['tenant_admin', 'finance_admin', 'support_contact'],
'delegation_state' => $tenant['tenant_key'] === 'finance-hub'
? 'Vier-Augen-Prinzip aktiv'
: 'Tenant-Admin delegiert Spezialrollen',
'primary_contact' => $tenant['primary_contact'],
];
}
return $snapshots;
}
/**
* @return array<string, mixed>
*/
private function resolveTenant(?string $tenantId): array
{
if ($tenantId !== null && $tenantId !== '') {
$tenant = $this->tenantService->findTenantById($tenantId);
if ($tenant !== null) {
return $tenant;
}
$tenant = $this->tenantService->findTenantByKey($tenantId);
if ($tenant !== null) {
return $tenant;
}
}
$fallback = $this->tenantService->findTenantByKey('berlin');
if ($fallback !== null) {
return $fallback;
}
return $this->tenantService->tenantPortfolio()[0];
}
}
@@ -45,6 +45,34 @@ class TenantService
);
}
/**
* @return array<string, mixed>|null
*/
public function findTenantById(string $tenantId): ?array
{
foreach ($this->tenantPortfolio() as $tenant) {
if (($tenant['id'] ?? '') === $tenantId) {
return $tenant;
}
}
return null;
}
/**
* @return array<string, mixed>|null
*/
public function findTenantByKey(string $tenantKey): ?array
{
foreach ($this->tenantPortfolio() as $tenant) {
if (($tenant['tenant_key'] ?? '') === $tenantKey) {
return $tenant;
}
}
return null;
}
/**
* @return array<string, mixed>
*/
@@ -70,7 +98,7 @@ class TenantService
[
'label' => 'SSO-Abdeckung',
'value' => (string) $providerCount,
'detail' => 'Konfigurierte OIDC-Provider fuer zentrale oder tenantbezogene Anmeldung.',
'detail' => 'Lokale Anmeldung plus ADFS/OIDC fuer zentrale oder tenantbezogene Anmeldung.',
],
[
'label' => 'Betriebsstatus',
@@ -83,7 +111,7 @@ class TenantService
[
'email' => 'leitung@kaffeeliste.example',
'tenants' => ['Werk Berlin', 'Werk Koeln', 'Shared Services'],
'next_step' => 'Tenant-Auswahl vor Passwort oder SSO anzeigen',
'next_step' => 'Tenant-Auswahl vor lokaler Anmeldung oder ADFS/OIDC anzeigen',
'status' => 'mehrfach',
],
[
@@ -161,7 +189,7 @@ class TenantService
'oidc_provider_count' => 1,
'tenant_health' => 'stabil',
'billing_state' => 'aktiv',
'login_mode' => 'oidc-first',
'login_mode' => 'Lokal + ADFS/OIDC',
'primary_contact' => 'Office Operations Berlin',
],
[
@@ -175,7 +203,7 @@ class TenantService
'oidc_provider_count' => 1,
'tenant_health' => 'stabil',
'billing_state' => 'aktiv',
'login_mode' => 'oidc-first',
'login_mode' => 'Lokal + ADFS/OIDC',
'primary_contact' => 'Backoffice Koeln',
],
[
@@ -189,7 +217,7 @@ class TenantService
'oidc_provider_count' => 2,
'tenant_health' => 'stabil',
'billing_state' => 'aktiv',
'login_mode' => 'central-routing',
'login_mode' => 'Lokal + ADFS/OIDC',
'primary_contact' => 'Platform Operations',
],
[
@@ -203,7 +231,7 @@ class TenantService
'oidc_provider_count' => 0,
'tenant_health' => 'boarding',
'billing_state' => 'test',
'login_mode' => 'password-fallback',
'login_mode' => 'Lokal + ADFS/OIDC',
'primary_contact' => 'Finance Enablement',
],
];
@@ -0,0 +1,37 @@
<?php
return <<<'SQL'
CREATE TABLE support_requests (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
request_number VARCHAR(40) NOT NULL,
request_direction VARCHAR(50) NOT NULL DEFAULT 'member_to_tenant',
route_target VARCHAR(50) NOT NULL DEFAULT 'tenant_responsible',
category VARCHAR(80) NOT NULL DEFAULT 'general',
priority VARCHAR(20) NOT NULL DEFAULT 'normal',
status VARCHAR(50) NOT NULL DEFAULT 'new',
subject VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
requester_user_id CHAR(36) NULL,
requester_tenant_user_id CHAR(36) NULL,
requester_name VARCHAR(255) NOT NULL,
requester_email VARCHAR(255) NOT NULL,
assigned_to_user_id CHAR(36) NULL,
assigned_to_tenant_user_id CHAR(36) NULL,
last_message_at DATETIME NOT NULL,
resolved_at DATETIME NULL,
closed_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY support_requests_tenant_number_unique (tenant_id, request_number),
KEY support_requests_tenant_status_index (tenant_id, status),
KEY support_requests_tenant_direction_index (tenant_id, request_direction),
KEY support_requests_requester_index (requester_user_id),
KEY support_requests_assignee_index (assigned_to_user_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (requester_user_id) REFERENCES users(id),
FOREIGN KEY (requester_tenant_user_id) REFERENCES tenant_users(id),
FOREIGN KEY (assigned_to_user_id) REFERENCES users(id),
FOREIGN KEY (assigned_to_tenant_user_id) REFERENCES tenant_users(id)
);
SQL;
@@ -0,0 +1,23 @@
<?php
return <<<'SQL'
CREATE TABLE support_request_messages (
id CHAR(36) NOT NULL PRIMARY KEY,
support_request_id CHAR(36) NOT NULL,
tenant_id CHAR(36) NOT NULL,
author_user_id CHAR(36) NULL,
author_tenant_user_id CHAR(36) NULL,
author_name VARCHAR(255) NOT NULL,
author_role VARCHAR(50) NOT NULL DEFAULT 'member',
message_kind VARCHAR(50) NOT NULL DEFAULT 'public_reply',
new_status VARCHAR(50) NULL,
body TEXT NOT NULL,
created_at DATETIME NOT NULL,
KEY support_request_messages_request_index (support_request_id, created_at),
KEY support_request_messages_tenant_index (tenant_id),
FOREIGN KEY (support_request_id) REFERENCES support_requests(id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (author_user_id) REFERENCES users(id),
FOREIGN KEY (author_tenant_user_id) REFERENCES tenant_users(id)
);
SQL;
+34 -1
View File
@@ -52,6 +52,8 @@ if ($requestedPage === null) {
'/ledger' => 'ledger',
'/payments' => 'payments',
'/content' => 'content',
'/support' => 'support',
'/surveys' => 'surveys',
'/settings' => 'settings',
'/exports' => 'exports',
'/logout' => 'logout',
@@ -61,7 +63,7 @@ if ($requestedPage === null) {
$page = (string) $requestedPage;
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content', 'settings', 'exports'];
$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content', 'support', 'surveys', 'settings', 'exports'];
if ($page === 'logout' && $requestMethod === 'POST') {
app_logout();
@@ -106,6 +108,14 @@ if (in_array($page, $tenantPages, true)) {
$auth = app_require_auth();
}
if ($page === 'support') {
app_redirect('/support/');
}
if ($page === 'surveys') {
app_redirect('/surveys/');
}
if ($page === 'login' && $pdo instanceof PDO) {
try {
$loginFlow = app_handle_login($pdo);
@@ -233,6 +243,8 @@ $canManageTenant = app_can_manage_tenant($auth);
<?php else: ?>
<a href="/dashboard/" class="<?= $page === 'dashboard' ? 'active' : '' ?>">Dashboard</a>
<a href="/content/" class="<?= $page === 'content' ? 'active' : '' ?>">Hinweise</a>
<a href="/surveys/" class="<?= $page === 'surveys' ? 'active' : '' ?>">Umfragen</a>
<a href="/support/" class="<?= $page === 'support' ? 'active' : '' ?>">Support</a>
<?php if ($canManageTenant): ?>
<a href="/members/" class="<?= $page === 'members' ? 'active' : '' ?>">Mitglieder</a>
<a href="/ledger/" class="<?= $page === 'ledger' ? 'active' : '' ?>">Buchungen</a>
@@ -436,6 +448,27 @@ $canManageTenant = app_can_manage_tenant($auth);
<?php endif; ?>
</section>
<section class="grid grid-3" style="margin-top:18px">
<article class="card">
<div class="eyebrow">Content</div>
<h2>Hinweise und FAQ</h2>
<p>Tenant-Admins pflegen Meldungen, Standardvorlagen und FAQ direkt im Mandantenkontext.</p>
<div class="actions" style="margin-top:14px"><a class="button secondary" href="/content/">Öffnen</a></div>
</article>
<article class="card">
<div class="eyebrow">Support</div>
<h2>Vorgänge mit Status</h2>
<p>Benutzer und Verantwortliche sehen Supportanfragen mit Routing, Verlauf und Bearbeitungsstand.</p>
<div class="actions" style="margin-top:14px"><a class="button secondary" href="/support/">Öffnen</a></div>
</article>
<article class="card">
<div class="eyebrow">Surveys</div>
<h2>Snapshots und Freigaben</h2>
<p>Umfragen werden live bearbeitet, Mitglieder sehen freigegebene Snapshots statt Entwürfe.</p>
<div class="actions" style="margin-top:14px"><a class="button secondary" href="/surveys/">Öffnen</a></div>
</article>
</section>
<section class="grid grid-2" style="margin-top:18px">
<?php if ($canManageTenant): ?>
<article class="card">
+67 -2
View File
@@ -2,6 +2,71 @@
declare(strict_types=1);
$_GET['page'] = 'logout';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require dirname(__DIR__) . '/index.php';
require_once __DIR__ . '/../app-support.php';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
app_logout();
app_flash('Du wurdest erfolgreich abgemeldet.', 'success');
app_redirect('/login/');
}
$auth = app_auth_user();
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kaffeeliste SaaS - Abmelden</title>
<style>
:root{--bg:#f4efe6;--card:#fffaf3;--ink:#24160e;--muted:#675546;--brand:#0c6b66;--line:rgba(36,22,14,.12);--shadow:0 24px 54px rgba(57,35,22,.11);--radius:24px}
*{box-sizing:border-box}
body{margin:0;font-family:"Segoe UI",sans-serif;color:var(--ink);background:linear-gradient(180deg,#fbf8f2 0%,var(--bg) 100%)}
.shell{width:min(900px,calc(100vw - 32px));margin:40px auto}
.card{padding:28px;border:1px solid var(--line);border-radius:var(--radius);background:var(--card);box-shadow:var(--shadow)}
.eyebrow{display:inline-block;margin-bottom:12px;color:#a34b12;font-size:.82rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}
h1,h2{font-family:Georgia,serif;margin:0 0 12px}
p{margin:0;color:var(--muted);line-height:1.65}
.actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:18px}
.button{display:inline-flex;align-items:center;justify-content:center;padding:11px 16px;border-radius:999px;text-decoration:none;font-weight:700;border:0;cursor:pointer;background:linear-gradient(135deg,var(--brand),#084d49);color:#fff}
.button.secondary{background:transparent;color:var(--brand);border:1px solid rgba(12,107,102,.18)}
.note{margin-top:18px;padding:16px 18px;border-radius:18px;background:rgba(12,107,102,.08);border:1px solid rgba(12,107,102,.12);color:#134e4a}
.stack{display:grid;gap:12px;margin-top:18px}
.chip{display:inline-flex;align-items:center;padding:7px 12px;border-radius:999px;background:rgba(12,107,102,.1);color:#134e4a;font-weight:700;font-size:.86rem}
</style>
</head>
<body>
<main class="shell">
<section class="card">
<div class="eyebrow">Abmeldung</div>
<h1><?= $auth !== null ? 'Sicher abmelden' : 'Bereits abgemeldet' ?></h1>
<p>
<?= $auth !== null
? 'Die Abmeldung entfernt den aktiven Sitzungszustand. Die lokale Anmeldung und ADFS/OIDC bleiben danach weiterhin als Einstieg verfuegbar.'
: 'Es ist kein aktiver Login mehr vorhanden. Du kannst direkt zur zentralen Anmeldung zurueckkehren.' ?>
</p>
<div class="stack">
<span class="chip">Lokale Anmeldung bleibt Pflicht</span>
<span class="chip">ADFS/OIDC als Zusatzoption</span>
<span class="chip">Email-Mapping und Klaerfall bleiben aktiv</span>
</div>
<div class="note">
Wenn du in mehreren Tenants arbeitest oder ein externer Provider nicht erreichbar ist, kannst du dich weiterhin mit dem lokalen Konto anmelden.
</div>
<div class="actions">
<form method="post" action="/logout/">
<button type="submit" class="button"><?= $auth !== null ? 'Jetzt abmelden' : 'Nochmal abmelden' ?></button>
</form>
<a class="button secondary" href="/login/">Zur zentralen Anmeldung</a>
</div>
</section>
</main>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/../app-support.php';
require_once __DIR__ . '/../../app/Modules/Support/bootstrap.php';
if (!isset($_SESSION['support_csrf'])) {
$_SESSION['support_csrf'] = bin2hex(random_bytes(24));
}
function support_h(string $value): string
{
return app_h($value);
}
function support_dt(?string $value): string
{
if ($value === null || trim($value) === '') {
return '-';
}
$time = strtotime($value);
return $time === false ? $value : date('d.m.Y H:i', $time);
}
function support_badge(string $label, string $tone = 'neutral'): string
{
return '<span class="badge badge--' . support_h($tone) . '">' . support_h($label) . '</span>';
}
$auth = app_require_auth();
$flash = app_flash();
$pdo = null;
$dbError = null;
$supportTablesReady = false;
$controller = new \App\Modules\Support\Controllers\SupportController(new \App\Modules\Support\Application\SupportService());
$pageData = [
'title' => 'Support',
'can_manage' => false,
'summary' => [
'all' => 0,
'new' => 0,
'open' => 0,
'waiting_on_user' => 0,
'resolved' => 0,
'closed' => 0,
],
'requests' => [],
'detail' => null,
'detail_notice' => null,
'categories' => [],
'directions' => [],
'statuses' => [],
'priorities' => [],
'route_targets' => [],
];
try {
$pdo = app_pdo();
$supportTablesReady = scripts_table_exists($pdo, 'support_requests')
&& scripts_table_exists($pdo, 'support_request_messages');
} catch (\Throwable $exception) {
$dbError = $exception->getMessage();
}
if ($pdo instanceof PDO && $supportTablesReady && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
if (!hash_equals((string) ($_SESSION['support_csrf'] ?? ''), (string) ($_POST['csrf'] ?? ''))) {
app_flash('Die Sitzung ist abgelaufen. Bitte lade die Seite neu.', 'error');
app_redirect('/support/');
}
$controller->handlePost($pdo, $auth, $_POST, $_GET);
}
if ($pdo instanceof PDO && $supportTablesReady) {
$pageData = $controller->index($pdo, $auth, $_GET);
}
$csrf = (string) ($_SESSION['support_csrf'] ?? '');
$isManager = (bool) ($pageData['can_manage'] ?? false);
$requests = $pageData['requests'] ?? [];
$detail = $pageData['detail'] ?? null;
$summary = $pageData['summary'] ?? [];
$detailNotice = $pageData['detail_notice'] ?? null;
$categories = $pageData['categories'] ?? [];
$directions = $pageData['directions'] ?? [];
$statuses = $pageData['statuses'] ?? [];
$priorities = $pageData['priorities'] ?? [];
$routeTargets = $pageData['route_targets'] ?? [];
$selectedRequestId = (string) ($_GET['request'] ?? '');
$template = dirname(__DIR__, 2) . '/resources/views/support/index.blade.php';
if (!is_file($template)) {
throw new RuntimeException('Die Support-Ansicht konnte nicht gefunden werden.');
}
ob_start();
require $template;
$output = ob_get_clean();
echo $output;
+293
View File
@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
use App\Modules\Surveys\Application\SurveyService;
use App\Modules\Surveys\Controllers\SurveyController;
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once dirname(__DIR__) . '/app-support.php';
require_once dirname(__DIR__, 2) . '/app/Modules/Surveys/Domain/Survey.php';
require_once dirname(__DIR__, 2) . '/app/Modules/Surveys/Domain/SurveyQuestion.php';
require_once dirname(__DIR__, 2) . '/app/Modules/Surveys/Domain/SurveyPublication.php';
require_once dirname(__DIR__, 2) . '/app/Modules/Surveys/Application/SurveyService.php';
require_once dirname(__DIR__, 2) . '/app/Modules/Surveys/Controllers/SurveyController.php';
if (!function_exists('h')) {
function h(string $value): string
{
return app_h($value);
}
}
if (!function_exists('dt_short')) {
function dt_short(string $value): string
{
$timestamp = strtotime($value);
return $timestamp === false ? $value : date('d.m.Y H:i', $timestamp);
}
}
$auth = app_auth_user();
$tenantId = (string) ($auth['tenant_id'] ?? 'tenant-demo');
$tenantName = (string) ($auth['tenant_name'] ?? 'Demo Tenant');
$tenantUser = (string) ($auth['display_name'] ?? 'Survey-Verantwortliche');
$controller = new SurveyController(new SurveyService());
$payload = $controller->index($tenantId);
$data = $payload['data'];
$board = $data['board'];
$memberBoard = $data['memberBoard'];
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kaffeeliste SaaS - Surveys</title>
<style>
:root{--bg:#f4efe6;--card:#fffaf4;--ink:#1f2933;--muted:#667085;--brand:#0f766e;--accent:#b45309;--line:rgba(31,41,51,.12);--shadow:0 24px 60px rgba(15,23,42,.08);--radius:26px;--radius-lg:20px;--content-width:1200px}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;font-family:"Aptos","Segoe UI","Trebuchet MS",sans-serif;color:var(--ink);background:radial-gradient(circle at top left,rgba(180,83,9,.12),transparent 30%),radial-gradient(circle at top right,rgba(15,118,110,.12),transparent 26%),linear-gradient(180deg,#faf7f1 0%,var(--bg) 100%)}
a{color:inherit;text-decoration:none}
h1,h2,h3,.brand__title{font-family:"Palatino Linotype","Book Antiqua",Georgia,serif;letter-spacing:-.02em}
.shell{width:min(var(--content-width),calc(100vw - 32px));margin:24px auto 40px}
.bar,.hero,.panel,.table-card,.note{border:1px solid var(--line);border-radius:var(--radius);background:var(--card);box-shadow:var(--shadow)}
.bar{display:flex;justify-content:space-between;gap:16px;align-items:center;padding:18px 22px;margin-bottom:18px}
.brand{display:grid;gap:4px}
.brand__title{font-size:1.25rem;font-weight:700}
.brand__subtitle{color:var(--muted)}
.toolbar,.meta,.stack{display:flex;flex-wrap:wrap;gap:10px}
.badge,.pill{display:inline-flex;align-items:center;padding:7px 12px;border-radius:999px;background:rgba(15,118,110,.08);color:var(--brand);font-weight:700;font-size:.86rem}
.badge--solid{background:linear-gradient(135deg,var(--brand),#0f5752);color:#fff}
.hero{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(260px,.65fr);gap:20px;padding:28px;margin-bottom:18px}
.hero__kicker{margin:0 0 10px;color:var(--accent);text-transform:uppercase;letter-spacing:.15em;font-size:.8rem;font-weight:800}
.hero__title{margin:0 0 12px;font-size:clamp(2rem,4vw,3.5rem);line-height:1.05}
.hero__lead{margin:0;color:var(--muted);max-width:70ch;line-height:1.7}
.hero__actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:16px}
.button{display:inline-flex;align-items:center;justify-content:center;padding:11px 16px;border-radius:999px;border:0;background:linear-gradient(135deg,var(--brand),#0f5752);color:#fff;font-weight:700}
.button--ghost{background:transparent;color:var(--brand);border:1px solid rgba(15,118,110,.18)}
.grid{display:grid;gap:18px}
.grid--2{grid-template-columns:repeat(2,minmax(0,1fr))}
.grid--3{grid-template-columns:repeat(3,minmax(0,1fr))}
.card,.panel,.table-card{padding:22px}
.metric{padding:18px;border:1px solid var(--line);border-radius:20px;background:#fffdf8}
.metric__label,.muted{color:var(--muted)}
.metric__value{font-size:1.8rem;font-weight:800;margin:6px 0 8px}
.table-card__header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:16px}
table{width:100%;border-collapse:collapse}
th,td{padding:12px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}
th{font-size:.82rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
.status{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;background:rgba(15,118,110,.1);color:var(--brand);font-weight:700;font-size:.84rem}
.status--warning{background:rgba(180,83,9,.12);color:#a34b12}
.status--success{background:rgba(17,98,61,.12);color:#11623d}
.feature-list__item{display:flex;gap:12px;padding:14px 16px;border-radius:16px;background:rgba(255,255,255,.78);border:1px solid rgba(31,41,51,.08)}
.feature-list__badge{flex:0 0 auto;width:34px;height:34px;border-radius:12px;display:grid;place-items:center;background:rgba(15,118,110,.1);color:var(--brand);font-weight:800}
.feature-list__title{margin:0 0 4px;font-weight:700}
.timeline{display:grid;gap:12px}
.timeline__item{padding:14px 16px;border-radius:16px;background:rgba(255,255,255,.72);border:1px solid rgba(31,41,51,.08)}
.timeline__title{margin:0 0 4px;font-weight:700}
.timeline__meta{margin:0;color:var(--muted)}
.note{padding:16px 18px;margin-top:16px;background:rgba(15,118,110,.06)}
.list-reset{margin:0;padding-left:18px}
.list-reset li+li{margin-top:10px}
@media(max-width:960px){.hero,.grid--2,.grid--3{grid-template-columns:1fr}.bar{align-items:flex-start;flex-direction:column}}
</style>
</head>
<body>
<main class="shell">
<header class="bar">
<div class="brand">
<strong class="brand__title">Kaffeeliste SaaS</strong>
<span class="brand__subtitle">Surveys als tenantfaehiger Verwaltungsbereich mit Snapshot-Publishing.</span>
</div>
<div class="toolbar">
<span class="badge"><?= h($tenantName) ?></span>
<span class="badge">Tenant-Admin / Survey-Manager</span>
<span class="badge badge--solid">Snapshot-Modus</span>
</div>
</header>
<section class="hero">
<div>
<p class="hero__kicker">Survey Admin</p>
<h1 class="hero__title">Umfragen werden fachlich gepflegt und als veroeffentlichte Version ausgeliefert.</h1>
<p class="hero__lead">
Entwuerfe bleiben im Admin-Bereich bearbeitbar. Erst nach Freigabe wird ein Snapshot erzeugt,
den Mitglieder lesen koennen. So bleibt die Fachseite flexibel und die Mitgliederansicht stabil.
</p>
<div class="hero__actions">
<a class="button" href="#admin-surveys">Admin Uebersicht</a>
<a class="button button--ghost" href="#member-surveys">Mitgliederansicht</a>
</div>
</div>
<div class="stack">
<?php foreach (array_slice($board['metrics'] ?? [], 0, 4) as $metric): ?>
<article class="metric">
<div class="metric__label"><?= h((string) ($metric['label'] ?? 'Kennzahl')) ?></div>
<div class="metric__value"><?= h((string) ($metric['value'] ?? '-')) ?></div>
<div class="muted"><?= h((string) ($metric['detail'] ?? '')) ?></div>
</article>
<?php endforeach; ?>
</div>
</section>
<section id="admin-surveys" class="grid grid--2">
<article class="table-card">
<div class="table-card__header">
<div>
<p class="hero__kicker">Verwaltung</p>
<h2>Entwuerfe und Freigaben</h2>
</div>
<span class="pill">Draft -> Review -> Snapshot</span>
</div>
<table>
<thead>
<tr>
<th>Umfrage</th>
<th>Fragen</th>
<th>Status</th>
<th>Kommentar</th>
</tr>
</thead>
<tbody>
<?php foreach (($board['draft_surveys'] ?? []) as $survey): ?>
<tr>
<td>
<strong><?= h($survey->title()) ?></strong>
<div class="muted">ID <?= h($survey->id()) ?></div>
</td>
<td><?= (int) $survey->questionCount() ?></td>
<td>
<?php if ($survey->isDraft()): ?>
<span class="status">Entwurf</span>
<?php elseif ($survey->status() === 'in_review'): ?>
<span class="status status--warning">Pruefung</span>
<?php else: ?>
<span class="status status--success">Freigegeben</span>
<?php endif; ?>
</td>
<td class="muted">
<?= h($survey->isDraft() ? 'Live bearbeitbar, noch nicht freigegeben.' : 'Bereit fuer Freigabe oder Snapshot.') ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="note">
Die Fachseite pflegt Entwuerfe live. Mitglieder sehen spaeter nur den veroeffentlichten Snapshot.
</div>
</article>
<article class="panel">
<h2>Freigabe-Workflow</h2>
<div class="timeline">
<?php foreach (($board['workflow'] ?? []) as $step): ?>
<div class="timeline__item">
<p class="timeline__title"><?= h((string) ($step['step'] ?? 'Schritt')) ?></p>
<p class="timeline__meta"><?= h((string) ($step['detail'] ?? '')) ?></p>
</div>
<?php endforeach; ?>
</div>
<div class="note">
Freigaben koennen tenantweit konfigurierbar bleiben. Snapshot und Entwurf sind sauber getrennt.
</div>
</article>
</section>
<section id="member-surveys" class="grid grid--2" style="margin-top:18px;">
<article class="table-card">
<div class="table-card__header">
<div>
<p class="hero__kicker">Veroeffentlicht</p>
<h2>Snapshots fuer Mitglieder</h2>
</div>
<span class="pill">Read only</span>
</div>
<table>
<thead>
<tr>
<th>Snapshot</th>
<th>Stand</th>
<th>Antworten</th>
<th>Freigabe</th>
</tr>
</thead>
<tbody>
<?php foreach (($board['published_snapshots'] ?? []) as $snapshot): ?>
<tr>
<td>
<strong><?= h($snapshot->surveyTitle()) ?></strong>
<div class="muted"><?= h($snapshot->versionLabel()) ?></div>
</td>
<td>
<div><?= h(dt_short($snapshot->publishedAt())) ?></div>
<div class="muted">von <?= h($snapshot->publishedBy()) ?></div>
</td>
<td><?= (int) $snapshot->responseCount() ?></td>
<td>
<?php if ($snapshot->memberVisible()): ?>
<span class="status status--success">Sichtbar</span>
<?php else: ?>
<span class="status status--warning">Gesperrt</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</article>
<article class="panel">
<h2>Mitglieder-Sicht</h2>
<div class="stack">
<?php foreach (($memberBoard['participation'] ?? []) as $item): ?>
<div class="feature-list__item">
<div class="feature-list__badge">S</div>
<div>
<p class="feature-list__title"><?= h((string) $item) ?></p>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="note">
Mitglieder sehen nur freigegebene Snapshots. Entwuerfe bleiben fuer Tenant-Admins und Survey-Manager editierbar.
</div>
</article>
</section>
<section class="grid grid--2" style="margin-top:18px;">
<article class="panel">
<h2>Rollen Im Modul</h2>
<div class="stack">
<?php foreach (($board['roles'] ?? []) as $role): ?>
<div class="feature-list__item">
<div class="feature-list__badge"><?= h(strtoupper(substr((string) ($role['role'] ?? ''), 0, 1))) ?></div>
<div>
<p class="feature-list__title"><?= h((string) ($role['role'] ?? '')) ?></p>
<p class="muted"><?= h((string) ($role['capability'] ?? '')) ?></p>
</div>
</div>
<?php endforeach; ?>
</div>
</article>
<article class="panel">
<h2>Publishing-Regeln</h2>
<ul class="list-reset">
<?php foreach (($board['publishing_rules'] ?? []) as $rule): ?>
<li><span class="status">Regel</span> <?= h((string) $rule) ?></li>
<?php endforeach; ?>
</ul>
<div class="note">
Tenant-Verantwortliche koennen Freigaben steuern und bei Bedarf den Vier-Augen-Mechanismus tenantweit deaktivieren.
</div>
</article>
</section>
</main>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/../../app-support.php';
require_once dirname(__DIR__, 3) . '/app/Support/TenantResolver.php';
require_once dirname(__DIR__, 3) . '/app/Modules/Tenants/Models/Tenant.php';
require_once dirname(__DIR__, 3) . '/app/Modules/Tenants/Models/TenantUser.php';
require_once dirname(__DIR__, 3) . '/app/Modules/Tenants/Services/TenantService.php';
require_once dirname(__DIR__, 3) . '/app/Modules/Tenants/Services/TenantMembershipService.php';
require_once dirname(__DIR__, 3) . '/app/Modules/Tenants/Services/TenantRoleService.php';
require_once dirname(__DIR__, 3) . '/app/Modules/Tenants/Controllers/TenantConsoleController.php';
if (!function_exists('tenant_roles_h')) {
function tenant_roles_h(string $value): string
{
return app_h($value);
}
}
if (!function_exists('tenant_roles_implode')) {
/**
* @param array<int, string> $items
*/
function tenant_roles_implode(array $items): string
{
return implode(', ', array_map('tenant_roles_h', $items));
}
}
$auth = app_require_auth();
if (!app_can_manage_tenant($auth)) {
app_flash('Dieser Bereich ist nur für Tenant-Admins oder Global-Admins verfügbar.', 'warning');
app_redirect('/dashboard/');
}
$tenantService = new \App\Modules\Tenants\Services\TenantService(new \App\Support\TenantResolver());
$roleService = new \App\Modules\Tenants\Services\TenantRoleService(
$tenantService,
new \App\Modules\Tenants\Services\TenantMembershipService()
);
$controller = new \App\Modules\Tenants\Controllers\TenantConsoleController($tenantService, $roleService);
$tenantId = (string) ($_GET['tenant'] ?? $auth['tenant_id'] ?? '');
$payload = $controller->roles($tenantId !== '' ? $tenantId : null);
$pageData = $payload['data'] ?? [];
$overview = $pageData['overview'] ?? [];
$tenant = $overview['tenant'] ?? [];
$metrics = $overview['metrics'] ?? [];
$roles = $overview['roles'] ?? [];
$delegationRules = $overview['delegation_rules'] ?? [];
$tenantSnapshots = $overview['tenant_snapshots'] ?? [];
$permissionGroups = $overview['permission_groups'] ?? [];
$notes = $overview['notes'] ?? [];
$template = dirname(__DIR__, 3) . '/resources/views/tenants/roles.blade.php';
if (!is_file($template)) {
throw new RuntimeException('Die Rollen-Ansicht konnte nicht gefunden werden.');
}
ob_start();
require $template;
$output = ob_get_clean();
echo $output;
@@ -24,6 +24,59 @@
'matches' => [],
],
];
$identityPolicy = $identityPolicy ?? [
'local_login_required' => true,
'external_login_mode' => 'ADFS/OIDC',
'supported_modes' => ['password', 'adfs_oidc'],
'email_mapping' => 'E-Mail-Mapping als primaerer Abgleich zwischen externer Identitaet und internem Benutzer',
'fallback' => 'Bei Dubletten oder unklaren Zuordnungen wird ein Admin-Klaerfall erzeugt.',
'recovery' => 'Lokale Anmeldung bleibt immer verfuegbar, auch wenn externe Provider ausfallen.',
'clarification' => 'Der Erstlogin soll bestehende Konten wiederverwenden und nicht stillschweigend neue Duplikate anlegen.',
'admin_clarification' => [
'title' => 'Admin-Klaerfall',
'description' => 'Wenn eine E-Mail-Adresse nicht eindeutig zugeordnet werden kann, wird der Fall an die zuständigen Admins eskaliert.',
'steps' => ['Identitaet pruefen', 'Tenant zuordnen', 'Konto bestaetigen oder neu anlegen'],
],
];
$roleMatrix = $roleMatrix ?? [
'roles' => [
['key' => 'platform_admin', 'summary' => 'Globaler Zugriff auf Plattform, Tenants und Betriebssteuerung.'],
['key' => 'tenant_admin', 'summary' => 'Vollzugriff im eigenen Tenant inklusive Rollen- und Inhaltsverwaltung.'],
['key' => 'finance_admin', 'summary' => 'Operative Buchungsrolle fuer Einzahlungen, Storno und Finanzreports.'],
['key' => 'support_contact', 'summary' => 'Bearbeitung und Abschluss von Supportvorgaengen.'],
['key' => 'survey_manager', 'summary' => 'Pflege, Freigabe und Auswertung von Surveys.'],
['key' => 'member', 'summary' => 'Mitglied mit Zugriff auf eigenes Profil und freigegebene Inhalte.'],
],
'rules' => [
'Tenant-Admin hat Vollzugriff im eigenen Tenant.',
'Spezialrollen erhalten nur die fuer ihr Modul noetigen Rechte.',
'Einzahlungen duerfen nur von Verantwortlichen storniert werden.',
'Stricheintraege duerfen nur von Verantwortlichen geloescht werden.',
],
];
$providers = $providers ?? [
'password' => [
'label' => 'Lokale Systemanmeldung',
'type' => 'password',
'required' => true,
'description' => 'Pflichtweg fuer lokale Konten und Fallback bei externen Ausfaellen.',
],
'adfs_oidc' => [
'label' => 'ADFS / OIDC',
'type' => 'oidc',
'required' => false,
'description' => 'Zusatzoption fuer angebundene Identitaetsprovider.',
],
];
$oidcProviders = $oidcProviders ?? [
[
'provider_key' => 'adfs-oidc-default',
'driver' => 'oidc',
'client_id' => 'tenant-client-id',
'redirect_uri' => '/auth/oidc/adfs-oidc-default/callback',
'scopes' => ['openid', 'profile', 'email'],
],
];
@endphp
@section('content')
@@ -45,6 +98,8 @@
<div class="hero__meta">
<span class="badge">E-Mail first</span>
<span class="badge">Tenant-Erkennung</span>
<span class="badge">Lokaler Login Pflicht</span>
<span class="badge">ADFS/OIDC zusaetzlich</span>
<span class="badge badge--solid">Mehrfachzuordnung unterstuetzt</span>
</div>
</div>
@@ -111,6 +166,29 @@
<p class="timeline__meta">Statt Sackgasse gibt es einen klaren Rueckweg zu Tenant-Admin, Einladung oder Support.</p>
</div>
</div>
<div class="stack" style="margin-top:18px;">
@foreach ($providers as $providerKey => $provider)
<div class="feature-list__item" style="align-items:flex-start;">
<div class="feature-list__badge">{{ strtoupper(substr((string) $providerKey, 0, 1)) }}</div>
<div>
<p class="feature-list__title">{{ $provider['label'] ?? $providerKey }}</p>
<p class="feature-list__copy muted">{{ $provider['description'] ?? '' }}</p>
</div>
<span class="status {{ !empty($provider['required']) ? 'status--warning' : 'status--neutral' }}">
{{ !empty($provider['required']) ? 'Pflicht' : 'Optional' }}
</span>
</div>
@endforeach
</div>
<div class="callout" style="margin-top:18px;">
<strong>{{ $identityPolicy['admin_clarification']['title'] ?? 'Admin-Klaerfall' }}</strong>
<p class="muted" style="margin-top:8px;">{{ $identityPolicy['admin_clarification']['description'] ?? $identityPolicy['fallback'] ?? '' }}</p>
<ul class="list-reset" style="margin-top:12px;">
@foreach (($identityPolicy['admin_clarification']['steps'] ?? []) as $step)
<li style="margin-bottom:8px;">- {{ $step }}</li>
@endforeach
</ul>
</div>
</article>
</section>
@@ -159,4 +237,85 @@
</div>
</article>
</section>
<section class="grid grid--2" style="margin-top: 18px;">
<article class="panel">
<p class="card__eyebrow">Identity-Policy</p>
<h3>Lokale Anmeldung plus ADFS/OIDC</h3>
<div class="timeline timeline--tight" style="margin-top: 18px;">
<div class="timeline__item">
<p class="timeline__title">Pflicht</p>
<p class="timeline__meta">{{ $identityPolicy['local_login_required'] ? 'Lokale Anmeldung bleibt immer verfuegbar.' : 'Lokale Anmeldung ist optional.' }}</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Zusaetzliche Option</p>
<p class="timeline__meta">{{ $identityPolicy['external_login_mode'] ?? 'ADFS/OIDC' }}</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Abgleich</p>
<p class="timeline__meta">{{ $identityPolicy['email_mapping'] ?? '' }}</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Klärfall</p>
<p class="timeline__meta">{{ $identityPolicy['fallback'] ?? '' }}</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Admin-Klaerfall</p>
<p class="timeline__meta">{{ $identityPolicy['admin_clarification']['description'] ?? '' }}</p>
</div>
</div>
</article>
<article class="panel">
<p class="card__eyebrow">Rollenmodell</p>
<h3>Vorgeschlagene Rechtebasis</h3>
<div class="stack" style="margin-top: 18px;">
@foreach ($roleMatrix['roles'] ?? [] as $role)
<div class="feature-list__item">
<div class="feature-list__badge">{{ strtoupper(substr((string) ($role['key'] ?? ''), 0, 1)) }}</div>
<div>
<p class="feature-list__title">{{ $role['key'] ?? '' }}</p>
<p class="feature-list__copy muted">{{ $role['summary'] ?? '' }}</p>
</div>
</div>
@endforeach
</div>
</article>
</section>
<section class="grid grid--2" style="margin-top: 18px;">
<article class="panel">
<p class="card__eyebrow">Provider-Details</p>
<h3>Konfigurierte ADFS/OIDC-Verbindungen</h3>
<div class="stack" style="margin-top: 18px;">
@foreach ($oidcProviders as $provider)
<div class="timeline__item">
<p class="timeline__title">{{ $provider['provider_key'] ?? 'provider' }}</p>
<p class="timeline__meta">Driver: {{ $provider['driver'] ?? 'oidc' }} | Client-ID: {{ $provider['client_id'] ?? '-' }}</p>
<p class="timeline__meta">Redirect: {{ $provider['redirect_uri'] ?? '-' }}</p>
<p class="timeline__meta">Scopes: {{ implode(', ', $provider['scopes'] ?? []) }}</p>
</div>
@endforeach
</div>
</article>
<article class="panel">
<p class="card__eyebrow">Mindestregel</p>
<h3>Lokale Anmeldung bleibt immer verfuegbar.</h3>
<div class="callout" style="margin-top: 18px;">
Selbst wenn ein externer Provider ausfaellt, bleibt der lokale Weg aktiv. So kann der Tenant weiterarbeiten und der Admin-Klaerfall bleibt handhabbar.
</div>
</article>
</section>
<section class="panel" style="margin-top: 18px;">
<h3>Regeln fuer Rollen und Abgrenzung</h3>
<ul class="list-reset">
@foreach ($roleMatrix['rules'] ?? [] as $rule)
<li style="margin-bottom: 12px;">
<span class="status">Regel</span> {{ $rule }}
</li>
@endforeach
</ul>
</section>
@endsection
@@ -0,0 +1,88 @@
@extends('layouts.app')
@section('page_title', 'Kaffeeliste SaaS - Content Redaktion')
@php
$editorial = $editorial ?? [
'sections' => [],
'rules' => [],
'editable_items' => [
'announcement' => [],
'faq' => [],
],
];
@endphp
@section('content')
<section class="hero hero--split">
<div class="hero__content">
<div>
<p class="hero__kicker">Tenant-Admin-Perspektive</p>
<h2 class="hero__title">Content-Redaktion mit Standardvorlage und Tenant-Override.</h2>
<p class="hero__lead">
Diese Sicht ist fuer Tenant-Admins gedacht: Hinweise, FAQ und spaetere Textbausteine
folgen einer klaren Redaktionslogik mit Freigabe, Sichtbarkeit und tenantbezogener Pflege.
</p>
</div>
<div class="hero__actions">
<span class="badge badge--solid">Redaktion</span>
<span class="badge">Freigabe</span>
<span class="badge">Tenant Override</span>
</div>
</div>
<aside class="hero__aside">
@foreach (($editorial['rules'] ?? []) as $rule)
<article class="card metric metric--compact">
<p class="metric__label">Redaktionsregel</p>
<div class="metric__value">OK</div>
<p class="muted">{{ $rule }}</p>
</article>
@endforeach
</aside>
</section>
<section class="grid grid--2">
<article class="panel">
<p class="card__eyebrow">Workflow</p>
<h3>Wie Inhalte durch den Tenant laufen</h3>
<div class="timeline" style="margin-top: 18px;">
@foreach (($editorial['sections'] ?? []) as $section)
<div class="timeline__item">
<p class="timeline__title">{{ $section['title'] ?? '-' }}</p>
<p class="timeline__meta">{{ $section['description'] ?? '-' }}</p>
</div>
@endforeach
</div>
</article>
<article class="panel">
<p class="card__eyebrow">Bearbeitbar</p>
<h3>Was Tenant-Admins erfassen koennen</h3>
<ul class="list-reset" style="margin-top: 18px;">
@foreach (($editorial['editable_items']['announcement'] ?? []) as $announcement)
<li>
<strong style="display: block;">Hinweis: {{ $announcement['title'] ?? '-' }}</strong>
<span class="muted">{{ $announcement['visible_until'] ?? '-' }}</span>
</li>
@endforeach
@foreach (($editorial['editable_items']['faq'] ?? []) as $faqItem)
<li style="margin-top: 14px;">
<strong style="display: block;">FAQ: {{ $faqItem['question'] ?? '-' }}</strong>
<span class="muted">Sortierung {{ $faqItem['sort_order'] ?? '-' }}</span>
</li>
@endforeach
</ul>
</article>
</section>
<section class="card" style="margin-top: 18px;">
<p class="card__eyebrow">Empfehlung</p>
<h3>Standardvorlage plus Tenant-Override</h3>
<p class="muted">
Die Redaktion startet mit Standardinhalten, erlaubt aber je Tenant eigene Anpassungen.
Sichtbarkeit, Ablauf und Ausspielkanal werden im selben Modell gepflegt, damit spaeter
Mailtexte und weitere Redaktionskanaele nahtlos anschliessen koennen.
</p>
</section>
@endsection
+155 -39
View File
@@ -2,32 +2,71 @@
@section('page_title', 'Kaffeeliste SaaS - Content')
@php
$data = $content ?? [
'announcements' => [],
'faq' => [],
'policy' => [
'template_policy' => 'Standardvorlage plus Tenant-Override',
'visibility_policy' => 'Freigegebene Inhalte werden auf Dashboard und spaetere Kanaele ausgespielt',
'admin_scope' => 'Tenant-Admins pflegen Inhalte direkt im Mandantenkontext',
],
'workflow' => ['Draft', 'Review', 'Publish', 'Expire'],
'summary' => ['announcement_count' => 0, 'faq_count' => 0],
];
$announcements = $data['announcements'] ?? [];
$faqItems = $data['faq'] ?? [];
$policy = $data['policy'] ?? [];
$workflow = $data['workflow'] ?? [];
$summary = $data['summary'] ?? [];
@endphp
@section('content')
<section class="hero">
<div>
<p class="hero__kicker">Content</p>
<h2 class="hero__title">Hinweise, Banner und FAQ werden tenantbezogene Inhalte.</h2>
<p class="hero__lead">
Die globale Hinweislogik aus der alten Oberflaeche wird zu einem
eigenstaendigen Redaktionsbereich. Banner, FAQ und Hilfetexte sind nicht
mehr im Root versteckt, sondern pro Mandant pflegbar.
</p>
</div>
<div class="toolbar">
<span class="badge">Announcements</span>
<span class="badge">FAQ</span>
<span class="badge badge--solid">Tenant scoped</span>
<section class="hero hero--split">
<div class="hero__content">
<div>
<p class="hero__kicker">Content-MVP</p>
<h2 class="hero__title">Hinweise und FAQ werden zu tenantfaehigen Redaktionsobjekten.</h2>
<p class="hero__lead">
Das Content-Modul zeigt nicht mehr nur Demo-Kacheln, sondern die fachliche
Struktur fuer freigabefaehige Hinweise, tenantbezogene FAQ und eine klare
Standardvorlage-plus-Tenant-Override-Logik.
</p>
</div>
<div class="hero__actions">
<span class="badge badge--solid">Tenant scoped</span>
<span class="badge">Standardvorlage</span>
<span class="badge">Override</span>
</div>
<div class="hero__meta">
<span class="pill">{{ (int) ($summary['announcement_count'] ?? 0) }} Hinweise</span>
<span class="pill">{{ (int) ($summary['faq_count'] ?? 0) }} FAQ-Eintraege</span>
<span class="pill">Freigabe steuert Sichtbarkeit</span>
</div>
</div>
<aside class="hero__aside">
<article class="card metric">
<p class="metric__label">Redaktionsmodell</p>
<div class="metric__value">Draft bis Publish</div>
<p class="muted">Tenant-Admins bearbeiten Inhalte im eigenen Mandantenbereich.</p>
</article>
<article class="card metric">
<p class="metric__label">Sichtbarkeit</p>
<div class="metric__value">Dashboard + FAQ</div>
<p class="muted">Freigegebene Inhalte erscheinen auf der Startseite und im Hilfebereich.</p>
</article>
</aside>
</section>
<section class="split">
<section class="grid grid--2">
<article class="table-card">
<div class="table-card__header">
<div>
<p class="card__eyebrow">Aktiv</p>
<h3>Hinweise im Umlauf</h3>
<p class="card__eyebrow">Hinweise</p>
<h3>Aktive Meldungen im Umlauf</h3>
</div>
<span class="pill">Header + Dashboard</span>
<span class="pill">Tenant-Admin View</span>
</div>
<div class="table-card__body">
<table>
@@ -35,44 +74,121 @@
<tr>
<th>Titel</th>
<th>Sichtbar bis</th>
<th>Kanal</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Monatsabschluss am Freitag</td>
<td>31.03.2026</td>
<td>Dashboard</td>
<td><span class="status">Aktiv</span></td>
</tr>
<tr>
<td>Neuer Preis pro Strich</td>
<td>15.04.2026</td>
<td>Header</td>
<td><span class="status status--warning">Geplant</span></td>
</tr>
@forelse ($announcements as $announcement)
<tr>
<td>
<strong>{{ $announcement['title'] ?? '-' }}</strong><br>
<span class="muted">{{ $announcement['message'] ?? '-' }}</span>
</td>
<td>{{ $announcement['visible_until'] ?? '-' }}</td>
<td>
@if (array_key_exists('active', $announcement) ? (bool) $announcement['active'] : true)
<span class="status">Aktiv</span>
@else
<span class="status status--warning">Inaktiv</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="3">
<span class="muted">Noch keine aktiven Hinweise fuer diesen Tenant hinterlegt.</span>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</article>
<article class="panel">
<h3>Redaktioneller Nutzen</h3>
<div class="timeline">
<p class="card__eyebrow">Redaktionsregeln</p>
<h3>Standardvorlage plus Tenant-Override</h3>
<div class="timeline" style="margin-top: 18px;">
<div class="timeline__item">
<p class="timeline__title">Hinweise ohne Code-Aenderung</p>
<p class="timeline__meta">Tenant-Admins koennen sichtbare Meldungen direkt verwalten.</p>
<p class="timeline__title">Vorlage zuerst</p>
<p class="timeline__meta">{{ $policy['template_policy'] ?? 'Standardvorlage plus Tenant-Override' }}</p>
</div>
<div class="timeline__item">
<p class="timeline__title">FAQ je Organisation</p>
<p class="timeline__meta">Hilfeinhalte koennen pro Mandant und Prozess gepflegt werden.</p>
<p class="timeline__title">Sichtbarkeit steuern</p>
<p class="timeline__meta">{{ $policy['visibility_policy'] ?? 'Freigegebene Inhalte werden sichtbar' }}</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Saubere Sichtbarkeit</p>
<p class="timeline__meta">Dashboard, Header und spaetere Mailtexte greifen auf denselben Content-Pool zu.</p>
<p class="timeline__title">Tenant-Admin Verantwortung</p>
<p class="timeline__meta">{{ $policy['admin_scope'] ?? 'Tenant-Admins pflegen Inhalte im eigenen Mandanten' }}</p>
</div>
</div>
<div class="toolbar" style="margin-top: 18px;">
@foreach ($workflow as $step)
<span class="badge">{{ $step }}</span>
@endforeach
</div>
</article>
</section>
<section class="table-card" style="margin-top: 18px;">
<div class="table-card__header">
<div>
<p class="card__eyebrow">FAQ</p>
<h3>Antworten, die tenantbezogen gepflegt werden</h3>
</div>
<span class="pill">Standard + Override</span>
</div>
<div class="table-card__body">
<table>
<thead>
<tr>
<th>Frage</th>
<th>Antwort</th>
<th>Reihenfolge</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@forelse ($faqItems as $item)
<tr>
<td><strong>{{ $item['question'] ?? '-' }}</strong></td>
<td>{{ $item['answer'] ?? '-' }}</td>
<td>{{ $item['sort_order'] ?? '-' }}</td>
<td>
@if (array_key_exists('active', $item) ? (bool) $item['active'] : true)
<span class="status">Aktiv</span>
@else
<span class="status status--warning">Entwurf</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="4">
<span class="muted">FAQ-Inhalte werden tenantbezogen gepflegt und sind noch leer.</span>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</section>
<section class="grid grid--3" style="margin-top: 18px;">
<article class="card">
<p class="card__eyebrow">Tenant-Admin</p>
<h3>Redaktion im Mandantenkontext</h3>
<p class="muted">Tenant-Admins sollen Hinweise, FAQ und spaetere Mailtexte ohne Code-Aenderung pflegen koennen.</p>
</article>
<article class="card">
<p class="card__eyebrow">Freigabe</p>
<h3>Publikation mit klarer Sichtbarkeit</h3>
<p class="muted">Freigaben steuern, was Mitglieder sehen und wann Inhalte automatisch auslaufen.</p>
</article>
<article class="card">
<p class="card__eyebrow">Erweiterung</p>
<h3>Spaetere Kanaele ohne neue Struktur</h3>
<p class="muted">Die gleiche Content-Basis kann spaeter auch fuer Benachrichtigungen und FAQ-Sync genutzt werden.</p>
</article>
</section>
@endsection
+169 -60
View File
@@ -2,80 +2,189 @@
@section('page_title', 'Kaffeeliste SaaS - Ledger')
@php
$overview = $ledgerOverview ?? [];
$journal = $ledger ?? ($overview['entries'] ?? []);
$summary = $overview['summary'] ?? [];
$entryCount = count($journal);
$paymentCount = (int) ($summary['payments'] ?? count(array_filter($journal, static fn (array $row): bool => ($row['entry_type'] ?? '') === 'payment')));
$strikeCount = (int) ($summary['strike_entries'] ?? count(array_filter($journal, static fn (array $row): bool => ($row['entry_type'] ?? '') === 'consumption')));
$correctionCount = (int) ($summary['corrections'] ?? count(array_filter($journal, static fn (array $row): bool => in_array((string) ($row['entry_type'] ?? ''), ['adjustment', 'reversal'], true))));
$balance = (float) ($summary['balance'] ?? array_sum(array_map(static fn (array $row): float => (float) ($row['amount'] ?? 0), $journal)));
$latestEntry = $journal[0]['booked_at'] ?? null;
$policy = $overview['correction_policy'] ?? [];
$policyPoints = [
$policy['payments'] ?? 'Einzahlungen werden per Storno korrigiert und nur durch den Verantwortlichen freigegeben.',
$policy['coffee_entries'] ?? 'Stricheintraege bleiben loeschbar, wenn der Verantwortliche das veranlasst.',
$policy['audit'] ?? 'Jede Korrektur bleibt im Audit sichtbar und braucht einen Ursprungseintrag.',
];
$entryTypes = [
'payment' => ['label' => 'Einzahlung', 'tone' => 'success'],
'consumption' => ['label' => 'Strich', 'tone' => 'warning'],
'adjustment' => ['label' => 'Korrektur', 'tone' => 'neutral'],
'reversal' => ['label' => 'Storno', 'tone' => 'warning'],
];
@endphp
@section('content')
<section class="hero">
<div>
<p class="hero__kicker">Accounting flow</p>
<h2 class="hero__title">Ledger fuer Buchungen, Verbrauch und Korrekturen.</h2>
<p class="hero__lead">
Die fachliche Kernlogik der alten Kaffeeliste bleibt erhalten: Einzahlungen, Striche und Saldo
werden in einer nachvollziehbaren Buchungsspur zusammengefuehrt.
</p>
</div>
<div class="toolbar">
<span class="badge">Saldo: 7,50 EUR</span>
<span class="badge">Korrekturen: 2</span>
<span class="badge badge--solid">Buchungsspur geordnet</span>
<section class="hero hero--split">
<div class="hero__content">
<div>
<p class="hero__kicker">Accounting flow</p>
<h2 class="hero__title">Ledger fuer Buchungen, Verbrauch, Storno und loeschbare Striche.</h2>
<p class="hero__lead">
Das Ledger bildet die fachliche Buchungsspur der Kaffeeliste ab. Einzahlungen sind als Storno
sichtbar, Striche bleiben als verantwortbare Loeschung erkennbar und Korrekturen bleiben auditierbar.
</p>
</div>
<div class="hero__actions">
<span class="badge badge--solid">Audit trail</span>
<span class="badge">Storno nur durch Verantwortliche</span>
<span class="badge">Striche loeschbar</span>
</div>
<div class="hero__meta">
<span class="pill">{{ $entryCount }} Buchungen</span>
<span class="pill">{{ number_format($balance, 2, ',', '.') }} EUR Saldo</span>
<span class="pill">Letzte Buchung: {{ $latestEntry ?? '-' }}</span>
</div>
</div>
<aside class="hero__aside">
<article class="card metric">
<p class="metric__label">Einzahlungen</p>
<div class="metric__value">{{ $paymentCount }}</div>
<p class="muted">Einzahlungen koennen durch Verantwortliche storniert werden.</p>
</article>
<article class="card metric">
<p class="metric__label">Striche</p>
<div class="metric__value">{{ $strikeCount }}</div>
<p class="muted">Stricheintraege bleiben als loeschbare Fachaktion sichtbar.</p>
</article>
</aside>
</section>
<section class="grid grid--3">
<article class="card metric">
<p class="metric__label">Offener Saldo</p>
<div class="metric__value">7,50 EUR</div>
<p class="muted">Positiv, daher kein Handlungsdruck.</p>
<div class="metric__value">{{ number_format($balance, 2, ',', '.') }} EUR</div>
<p class="muted">Die Buchungsspur bleibt sofort nachvollziehbar.</p>
</article>
<article class="card metric">
<p class="metric__label">Verbrauchsumfang</p>
<div class="metric__value">5 Striche</div>
<p class="muted">Aktueller Buchungsumfang im laufenden Zeitraum.</p>
<p class="metric__label">Korrekturen</p>
<div class="metric__value">{{ $correctionCount }}</div>
<p class="muted">Stornos und manuelle Anpassungen sind im Audit sichtbar.</p>
</article>
<article class="card metric">
<p class="metric__label">Letzte Korrektur</p>
<div class="metric__value">Heute</div>
<p class="muted">Sichtbar fuer Admins und Audit.</p>
<p class="metric__label">Buchungstypen</p>
<div class="metric__value">4 Pfade</div>
<p class="muted">Einzahlung, Verbrauch, Anpassung und Reversal bleiben getrennt erkennbar.</p>
</article>
</section>
<section class="table-card" style="margin-top: 18px;">
<div class="table-card__header">
<div>
<p class="card__eyebrow">Buchungen</p>
<h3>Ledger-Eintraege</h3>
<section class="grid grid--2" style="margin-top: 18px;">
<article class="table-card">
<div class="table-card__header">
<div>
<p class="card__eyebrow">Buchungen</p>
<h3>Ledger-Eintraege</h3>
</div>
<span class="pill">Tenant scoped</span>
</div>
<span class="pill">Audit trail</span>
</div>
<div class="table-card__body">
<table>
<thead>
<tr>
<th>Datum</th>
<th>Beschreibung</th>
<th>Typ</th>
<th>Betrag</th>
</tr>
</thead>
<tbody>
<tr>
<td>21.03.2026</td>
<td>Einzahlung Demo-Workspace</td>
<td>Payment</td>
<td>+10,00 EUR</td>
</tr>
<tr>
<td>21.03.2026</td>
<td>2 Striche Kaffee</td>
<td>Consumption</td>
<td>-2,50 EUR</td>
</tr>
<tr>
<td>20.03.2026</td>
<td>Manuelle Korrektur</td>
<td>Adjustment</td>
<td>+0,50 EUR</td>
</tr>
</tbody>
</table>
</div>
<div class="table-card__body">
<table>
<thead>
<tr>
<th>Datum</th>
<th>Mitglied</th>
<th>Typ</th>
<th>Referenz</th>
<th>Betrag</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
@forelse ($journal as $entry)
@php
$entryType = (string) ($entry['entry_type'] ?? '');
$typeMeta = $entryTypes[$entryType] ?? ['label' => ucfirst($entryType ?: 'Unbekannt'), 'tone' => 'neutral'];
@endphp
<tr>
<td>{{ $entry['booked_at'] ?? '-' }}</td>
<td>{{ $entry['member_name'] ?? '-' }}</td>
<td><span class="status">{{ $typeMeta['label'] }}</span></td>
<td>{{ $entry['reference_type'] ?? '-' }}</td>
<td>{{ number_format((float) ($entry['amount'] ?? 0), 2, ',', '.') }} EUR</td>
<td>
@if ($entryType === 'payment')
<span class="status status--warning">stornierbar</span>
@elseif ($entryType === 'consumption')
<span class="status status--warning">loeschbar</span>
@elseif (in_array($entryType, ['adjustment', 'reversal'], true))
<span class="status">auditpflichtig</span>
@else
<span class="status">gebucht</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="6"><span class="muted">Noch keine Ledger-Eintraege vorhanden.</span></td>
</tr>
@endforelse
</tbody>
</table>
</div>
</article>
<article class="panel">
<p class="card__eyebrow">Korrekturregeln</p>
<h3>Was im MVP sichtbar sein muss</h3>
<div class="timeline" style="margin-top: 18px;">
<div class="timeline__item">
<p class="timeline__title">Einzahlungen</p>
<p class="timeline__meta">Storno ist fachlich vorgesehen und nur durch Verantwortliche ausloesbar.</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Stricheintraege</p>
<p class="timeline__meta">Striche bleiben loeschbar, wenn der Verantwortliche das explizit veranlasst.</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Audit</p>
<p class="timeline__meta">Jede Korrektur bleibt mit Ursprungseintrag und Zeitstempel nachvollziehbar.</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Reconciliation</p>
<p class="timeline__meta">Zahlungsabgleich wird vorbereitet, aber noch nicht als automatischer MVP-Import ausgerollt.</p>
</div>
</div>
<div class="note" style="margin-top: 18px;">
Das Ledger bleibt damit fachlich vollständig genug fuer das MVP, ohne die spaetere Zahlungsabstimmung vorwegzunehmen.
</div>
<ul class="list" style="margin-top: 12px;">
@foreach ($policyPoints as $policyPoint)
<li>{{ $policyPoint }}</li>
@endforeach
</ul>
</article>
</section>
<section class="grid grid--3" style="margin-top: 18px;">
@foreach ($entryTypes as $key => $type)
<article class="card">
<p class="card__eyebrow">{{ $type['label'] }}</p>
<h3>{{ ucfirst($key) }}</h3>
<p class="muted">
@if ($key === 'payment')
Verantwortliche koennen diese Buchung stornieren.
@elseif ($key === 'consumption')
Diese Stricheintraege koennen durch Verantwortliche geloescht werden.
@elseif ($key === 'adjustment')
Manuelle Anpassungen bleiben auditierbar.
@else
Ruecknahmen bleiben als eigener Buchungstyp sichtbar.
@endif
</p>
</article>
@endforeach
</section>
@endsection
+127 -52
View File
@@ -2,43 +2,82 @@
@section('page_title', 'Kaffeeliste SaaS - Payments')
@php
$overview = $paymentOverview ?? [];
$journal = $payments ?? ($overview['payments'] ?? []);
$summary = $overview['summary'] ?? [];
$paymentCount = (int) ($summary['count'] ?? count($journal));
$paymentTotal = (float) ($summary['total_amount'] ?? array_sum(array_map(static fn (array $row): float => (float) ($row['amount'] ?? 0), $journal)));
$latestPayment = $summary['latest_booking'] ?? ($journal[0]['booked_at'] ?? null);
$cancelableCount = (int) ($summary['cancelable_payments'] ?? count(array_filter($journal, static fn (array $row): bool => (bool) ($row['cancellable'] ?? false))));
$reconciliationPendingCount = (int) ($summary['reconciliation_pending'] ?? count(array_filter($journal, static fn (array $row): bool => ($row['reconciliation_state'] ?? 'neu') !== 'gematcht')));
$reconciliation = $overview['reconciliation'] ?? [];
$paymentMethods = [
['label' => 'Bar', 'state' => 'direkt'],
['label' => 'Ueberweisung', 'state' => 'tenant-konfigurierbar'],
['label' => 'PayPal', 'state' => 'spaeteres Folgepaket'],
];
$reconciliationStates = ['neu', 'gematcht', 'pruefen', 'storniert'];
@endphp
@section('content')
<section class="hero">
<div>
<p class="hero__kicker">Payments</p>
<h2 class="hero__title">Einzahlungen und Zahlungswege werden zu einem eigenen SaaS-Modul.</h2>
<p class="hero__lead">
Die fruehere Sammelerfassung bleibt als Kernfunktion erhalten, wird aber um
Status, Referenzen und tenantbezogene Bezahlwege erweitert. So passen
manuelle Buchung, PayPal und spaetere Automatisierungen in denselben Ablauf.
</p>
</div>
<div class="toolbar">
<span class="badge">PayPal optional</span>
<span class="badge">Finance workflow</span>
<span class="badge badge--solid">Saldo-relevant</span>
<section class="hero hero--split">
<div class="hero__content">
<div>
<p class="hero__kicker">Payments</p>
<h2 class="hero__title">Einzahlungen werden tenantfaehig verwaltet und fuer spaetere Abgleiche vorbereitet.</h2>
<p class="hero__lead">
Das Payments-Modul bildet die fachlich gewollte Konfiguration fuer Bar, Ueberweisung und PayPal ab.
Reconciliation bleibt bewusst als Folgepaket vorbereitet, damit der MVP bei manuellen Buchungen stabil bleibt.
</p>
</div>
<div class="hero__actions">
<span class="badge badge--solid">Finance workflow</span>
<span class="badge">Tenant scoped</span>
<span class="badge">Reconciliation vorbereitet</span>
<span class="badge">Storno nur durch Verantwortliche</span>
</div>
<div class="hero__meta">
<span class="pill">{{ $paymentCount }} Einzahlungen</span>
<span class="pill">{{ number_format($paymentTotal, 2, ',', '.') }} EUR</span>
<span class="pill">Letzte Buchung: {{ $latestPayment ?? '-' }}</span>
<span class="pill">{{ $cancelableCount }} stornierbar</span>
</div>
</div>
<aside class="hero__aside">
<article class="card metric">
<p class="metric__label">Zahlungsarten</p>
<div class="metric__value">3 Pfade</div>
<p class="muted">Bar, Ueberweisung und PayPal werden tenantbezogen konfigurierbar.</p>
</article>
<article class="card metric">
<p class="metric__label">Abgleich</p>
<div class="metric__value">Folgepaket</div>
<p class="muted">Statusfelder fuer spaetere Matching- und Import-Prozesse sind vorgesehen.</p>
</article>
</aside>
</section>
<section class="grid grid--3">
<article class="card metric">
<p class="metric__label">Offene Zahlungen</p>
<div class="metric__value">6</div>
<p class="muted">Noch zu bestaetigende oder abzugleichende Vorgaege.</p>
<p class="metric__label">Gebuchte Einzahlungen</p>
<div class="metric__value">{{ $paymentCount }}</div>
<p class="muted">Manuelle Zahlungen und importierte Eintraege werden hier zusammengefuehrt.</p>
</article>
<article class="card metric">
<p class="metric__label">Monatssumme</p>
<div class="metric__value">245 EUR</div>
<p class="muted">Gebuchte Einzahlungen im aktuellen Abrechnungszeitraum.</p>
<p class="metric__label">Saldo-Effekt</p>
<div class="metric__value">{{ number_format($paymentTotal, 2, ',', '.') }} EUR</div>
<p class="muted">Der Betrag wirkt unmittelbar auf den Mitgliedssaldo im Ledger.</p>
</article>
<article class="card metric">
<p class="metric__label">Schnellwege</p>
<div class="metric__value">3</div>
<p class="muted">Bar, PayPal und spaetere Referenzimporte aus Drittsystemen.</p>
</article>
</section>
<article class="card metric">
<p class="metric__label">Reconciliation-Status</p>
<div class="metric__value">{{ $reconciliationPendingCount }}</div>
<p class="muted">Eintraege sind bereits mit vorbereiteten Statusfeldern versehen.</p>
</article>
</section>
<section class="split" style="margin-top: 18px;">
<section class="grid grid--2" style="margin-top: 18px;">
<article class="table-card">
<div class="table-card__header">
<div>
@@ -55,42 +94,78 @@
<th>Methode</th>
<th>Betrag</th>
<th>Status</th>
<th>Buchung</th>
</tr>
</thead>
<tbody>
<tr>
<td>Max Beispiel</td>
<td>PayPal</td>
<td>10,00 EUR</td>
<td><span class="status">Gebucht</span></td>
</tr>
<tr>
<td>Julia Betrieb</td>
<td>Bar</td>
<td>5,00 EUR</td>
<td><span class="status">Bestaetigt</span></td>
</tr>
<tr>
<td>Rene Muster</td>
<td>SEPA Import</td>
<td>15,00 EUR</td>
<td><span class="status status--warning">Pruefen</span></td>
</tr>
@forelse ($journal as $entry)
<tr>
<td>{{ $entry['member_name'] ?? '-' }}</td>
<td>{{ ucfirst((string) ($entry['payment_method'] ?? 'manual')) }}</td>
<td>{{ number_format((float) ($entry['amount'] ?? 0), 2, ',', '.') }} EUR</td>
<td>
@if (($entry['reconciliation_state'] ?? 'neu') === 'gematcht')
<span class="status">Gematcht</span>
@elseif (($entry['reconciliation_state'] ?? 'neu') === 'pruefen')
<span class="status status--warning">Pruefen</span>
@else
<span class="status">Stornierbar</span>
@endif
</td>
<td>
@if (!empty($entry['cancellable']))
<span class="status status--warning">Nur Verantwortliche</span>
@else
<span class="status">Archiviert</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5"><span class="muted">Noch keine Zahlungen vorhanden.</span></td>
</tr>
@endforelse
</tbody>
</table>
</div>
</article>
<article class="panel">
<h3>Was aus dem Legacy-System bleibt</h3>
<ul class="list-reset">
<li><span class="status">Sammelerfassung</span> Admins buchen mehrere Einzahlungen in einem Schritt.</li>
<li><span class="status">Dashboard-Link</span> Direkte Einzahlung oder Schuldenausgleich bleibt moeglich.</li>
<li><span class="status">Ledger Sync</span> Jede Einzahlung wirkt unmittelbar auf den Kontostand.</li>
</ul>
<p class="card__eyebrow">Reconciliation</p>
<h3>Vorbereitung fuer spaetere Abgleiche</h3>
<div class="timeline" style="margin-top: 18px;">
<div class="timeline__item">
<p class="timeline__title">Statusmodell</p>
<p class="timeline__meta">{{ implode(', ', $reconciliation['states'] ?? $reconciliationStates) }}</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Storno-Regel</p>
<p class="timeline__meta">{{ $reconciliation['responsible_rule'] ?? 'Einzahlungen koennen nur durch Verantwortliche storniert werden.' }}</p>
</div>
<div class="timeline__item">
<p class="timeline__title">MVP</p>
<p class="timeline__meta">{{ $reconciliation['message'] ?? 'Nur Konfiguration und manuelle Buchung, kein automatischer Mailbox-Import.' }}</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Spaeter</p>
<p class="timeline__meta">Matching, externe Referenz und Klaerungsfaelle werden als Folgepaket ergaenzt.</p>
</div>
</div>
<div class="note" style="margin-top: 18px;">
Das Payments-Modul ersetzt keine Funktionalitaet, sondern gibt ihr eine saubere Prozessstruktur.
Das Payments-Modul trennt die fachliche Konfiguration von spaeterer Automatisierung, damit der MVP stabil bleibt.
</div>
</article>
</section>
<section class="grid grid--3" style="margin-top: 18px;">
@foreach ($paymentMethods as $method)
<article class="card">
<p class="card__eyebrow">{{ $method['label'] }}</p>
<h3>{{ $method['state'] }}</h3>
<p class="muted">
{{ $method['label'] }} bleibt tenantbezogen konfigurierbar und kann mit sichtbaren Pflichtfeldern versehen werden.
</p>
</article>
@endforeach
</section>
@endsection
@@ -0,0 +1,377 @@
<?php
/** @var array<string, mixed> $pageData */
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kaffeeliste SaaS - Support</title>
<style>
:root{
--bg:#f5efe6;
--card:#fffaf3;
--ink:#24160e;
--muted:#6a5848;
--brand:#0c6b66;
--brand-2:#084d49;
--accent:#a34b12;
--line:rgba(36,22,14,.12);
--shadow:0 24px 54px rgba(57,35,22,.11);
--radius:24px;
}
*{box-sizing:border-box}
body{
margin:0;
color:var(--ink);
font-family:"Aptos","Segoe UI",sans-serif;
background:
radial-gradient(circle at top left, rgba(163,75,18,.10), transparent 26%),
radial-gradient(circle at top right, rgba(12,107,102,.14), transparent 24%),
linear-gradient(180deg,#fbf8f2 0%,var(--bg) 100%);
}
a{color:inherit}
.shell{width:min(1280px,calc(100vw - 32px));margin:24px auto 40px}
.bar,.hero,.card,.alert{border:1px solid var(--line);border-radius:var(--radius);background:var(--card);box-shadow:var(--shadow)}
.bar{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:18px 22px;margin-bottom:18px;flex-wrap:wrap}
.brand strong,.hero h1,.card h2,.card h3{font-family:Georgia,serif}
.brand strong{font-size:1.28rem}
.brand span,p,.muted{color:var(--muted)}
.links,.actions,.context{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
.links a,.button,button{display:inline-flex;align-items:center;justify-content:center;padding:11px 16px;border-radius:999px;text-decoration:none;font-weight:700;border:0;cursor:pointer}
.links a{background:rgba(12,107,102,.08);color:var(--brand)}
.links a.active{background:linear-gradient(135deg,var(--brand),var(--brand-2));color:#fff}
.button,button{background:linear-gradient(135deg,var(--brand),var(--brand-2));color:#fff}
.button.secondary{background:transparent;color:var(--brand);border:1px solid rgba(12,107,102,.18)}
.hero{padding:28px;margin-bottom:18px;display:grid;gap:18px}
.hero__kicker{text-transform:uppercase;letter-spacing:.16em;color:var(--accent);font-size:.8rem;font-weight:800;margin:0}
.hero__title{margin:0;font-size:clamp(2rem,4vw,3.6rem);line-height:1.05}
.hero__lead{margin:0;max-width:72ch;font-size:1.02rem;line-height:1.65}
.grid{display:grid;gap:18px}
.grid--2{grid-template-columns:repeat(2,minmax(0,1fr))}
.grid--3{grid-template-columns:repeat(3,minmax(0,1fr))}
.grid--4{grid-template-columns:repeat(4,minmax(0,1fr))}
.card{padding:22px}
.card h2,.card h3{margin:0 0 12px}
.eyebrow{display:inline-block;margin-bottom:10px;color:var(--accent);font-size:.82rem;font-weight:800;letter-spacing:.12em;text-transform:uppercase}
.metric{padding:18px;border-radius:20px;border:1px solid var(--line);background:#fffdf9;display:grid;gap:8px}
.metric strong{font-size:1.8rem}
.badge{display:inline-flex;align-items:center;padding:7px 12px;border-radius:999px;font-size:.86rem;font-weight:700}
.badge--neutral{background:rgba(12,107,102,.1);color:var(--brand)}
.badge--success{background:rgba(17,98,61,.12);color:#11623d}
.badge--warning{background:rgba(163,75,18,.12);color:#98510c}
.badge--danger{background:rgba(154,31,31,.12);color:#9a1f1f}
.alert{padding:16px 18px;margin-bottom:18px}
.alert-success{background:rgba(17,98,61,.08)}
.alert-warning{background:rgba(163,75,18,.08)}
.alert-error{background:rgba(154,31,31,.08)}
.split{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(360px,.95fr);gap:18px}
.stack{display:grid;gap:12px}
.table{overflow-x:auto}
table{width:100%;border-collapse:collapse;min-width:860px}
th,td{padding:13px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}
th{font-size:.85rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
label{display:flex;flex-direction:column;gap:8px;font-weight:700}
input,select,textarea{width:100%;padding:12px 14px;border-radius:16px;border:1px solid rgba(36,22,14,.15);font:inherit;background:#fffdfa;color:var(--ink)}
textarea{min-height:120px}
.timeline{display:grid;gap:12px}
.timeline__item{padding:14px 16px;border-radius:18px;background:#fffdf9;border:1px solid rgba(31,41,51,.08)}
.timeline__meta{margin:0;color:var(--muted);font-size:.94rem;line-height:1.6}
.timeline__title{margin:0 0 6px;font-weight:800}
.pill{display:inline-flex;align-items:center;padding:7px 12px;border-radius:999px;border:1px solid rgba(12,107,102,.15);background:rgba(12,107,102,.07);color:var(--brand);font-size:.84rem;font-weight:700}
.status-grid{display:grid;gap:8px}
.status{display:inline-flex;align-items:center;gap:8px}
.status::before{content:"";width:8px;height:8px;border-radius:999px;background:var(--brand)}
.status.is-new::before{background:var(--accent)}
.status.is-waiting::before{background:#9a1f1f}
.status.is-closed::before{background:#11623d}
.mono{font-family:Consolas,monospace}
.footer{margin-top:18px;text-align:center;color:var(--muted);font-size:.92rem}
@media(max-width:1040px){
.split,.grid--2,.grid--3,.grid--4{grid-template-columns:1fr}
table{min-width:0}
}
</style>
</head>
<body>
<main class="shell">
<header class="bar">
<div class="brand">
<strong>Kaffeeliste Support</strong>
<span>Vollstaendiges Vorgangssystem fuer Tenant-Anfragen und zentrale Rueckmeldungen.</span>
</div>
<nav class="links">
<a href="/dashboard/">Dashboard</a>
<a href="/content/">Hinweise</a>
<a href="/support/" class="active">Support</a>
</nav>
</header>
<section class="hero">
<p class="hero__kicker">Support Requests</p>
<h1 class="hero__title">Anfragen sichtbar machen, bearbeiten und nachvollziehbar abschließen.</h1>
<p class="hero__lead">
Das Modul bildet Support als echten Vorgang ab: mit Status, Routing, Rückmeldungen und einer sauberen Sicht
für Mitglieder sowie Verantwortliche. Mitglieder sehen nur ihre eigenen Vorgänge, Verantwortliche den
gesamten Tenant-Kontext.
</p>
<div class="context">
<?= support_badge($isManager ? 'Verantwortlichen-Sicht' : 'Mitgliedersicht', 'success') ?>
<?= support_badge('Tenant-scoped') ?>
<?= support_badge('Status + Routing vorbereitet', 'warning') ?>
</div>
</section>
<?php if ($flash !== null): ?>
<section class="alert alert-<?= support_h((string) ($flash['type'] ?? 'success')) ?>">
<?= support_h((string) ($flash['message'] ?? '')) ?>
</section>
<?php endif; ?>
<?php if ($dbError !== null): ?>
<section class="alert alert-warning"><?= support_h($dbError) ?></section>
<?php elseif (!$supportTablesReady): ?>
<section class="alert alert-warning">
Das Support-Modul ist strukturell vorbereitet, aber die Support-Tabellen sind noch nicht angelegt.
Bitte fuehre die Migrationen aus, bevor du das Modul produktiv nutzt.
</section>
<?php endif; ?>
<?php if ($supportTablesReady): ?>
<section class="grid grid--4">
<article class="metric"><strong><?= support_h((string) ($summary['all'] ?? 0)) ?></strong><h3>Alle Vorgänge</h3><p>Support-Anfragen im aktuellen Tenant.</p></article>
<article class="metric"><strong><?= support_h((string) ($summary['new'] ?? 0)) ?></strong><h3>Neu</h3><p>Neue Vorgänge ohne Bearbeitung.</p></article>
<article class="metric"><strong><?= support_h((string) ($summary['waiting_on_user'] ?? 0)) ?></strong><h3>Warten auf Antwort</h3><p>Vorgänge mit Rueckfrage an Mitglieder.</p></article>
<article class="metric"><strong><?= support_h((string) ($summary['resolved'] ?? 0)) ?></strong><h3>Erledigt</h3><p>Abgeschlossene oder gelöste Anfragen.</p></article>
</section>
<section class="split" style="margin-top:18px">
<article class="card">
<p class="eyebrow"><?= $isManager ? 'Neuen Vorgang anlegen' : 'Anfrage anlegen' ?></p>
<h2>Support-Vorgang erstellen</h2>
<form method="post" action="/support/" class="stack">
<input type="hidden" name="csrf" value="<?= support_h($csrf) ?>">
<input type="hidden" name="action" value="create-request">
<label>Betreff<input name="subject" maxlength="255" placeholder="Worum geht es?" required></label>
<label>Kategorie
<select name="category">
<?php foreach ($categories as $category): ?>
<option value="<?= support_h($category) ?>"><?= support_h(ucfirst(str_replace('_', ' ', $category))) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>Priorität
<select name="priority">
<?php foreach ($priorities as $priority): ?>
<option value="<?= support_h($priority) ?>"><?= support_h(ucfirst($priority)) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>Richtung
<select name="request_direction" <?= $isManager ? '' : 'disabled' ?>>
<?php foreach ($directions as $directionKey => $direction): ?>
<option value="<?= support_h($directionKey) ?>" <?= $directionKey === 'member_to_tenant' ? 'selected' : '' ?>>
<?= support_h((string) ($direction['label'] ?? $directionKey)) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php if (!$isManager): ?>
<input type="hidden" name="request_direction" value="member_to_tenant">
<?php endif; ?>
<label>Nachricht<textarea name="message" placeholder="Beschreibe dein Anliegen so konkret wie möglich." required></textarea></label>
<div class="actions">
<button type="submit">Vorgang anlegen</button>
</div>
</form>
</article>
<article class="card">
<p class="eyebrow">Statusmodell</p>
<h2>Bearbeitungslogik im Vorgang</h2>
<div class="timeline">
<div class="timeline__item">
<p class="timeline__title">Neu bis in Bearbeitung</p>
<p class="timeline__meta">Verantwortliche setzen Status, Routing und bei Bedarf eine Zuordnung.</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Warten auf Rückmeldung</p>
<p class="timeline__meta">Mitglieder können auf offene Rückfragen antworten, ohne den Vorgang zu verlieren.</p>
</div>
<div class="timeline__item">
<p class="timeline__title">Lösen und schließen</p>
<p class="timeline__meta">Jede Statusänderung landet im Verlauf und bleibt nachvollziehbar.</p>
</div>
</div>
</article>
</section>
<section class="card" style="margin-top:18px">
<p class="eyebrow"><?= $isManager ? 'Alle Vorgänge' : 'Meine Vorgänge' ?></p>
<h2><?= $isManager ? 'Tenant-Übersicht der Support-Anfragen' : 'Deine offenen und abgeschlossenen Vorgänge' ?></h2>
<div class="table">
<table>
<thead>
<tr>
<th>Nummer</th>
<th>Betreff</th>
<th>Status</th>
<th>Richtung</th>
<th>Routing</th>
<th>Letzte Aktivität</th>
<th>Dialog</th>
</tr>
</thead>
<tbody>
<?php foreach ($requests as $request): ?>
<?php
$statusClass = match ((string) ($request['status'] ?? 'new')) {
'new' => 'is-new',
'waiting_on_user' => 'is-waiting',
'resolved', 'closed' => 'is-closed',
default => '',
};
?>
<tr>
<td class="mono"><?= support_h((string) ($request['request_number'] ?? '')) ?></td>
<td>
<strong><a href="/support/?request=<?= support_h((string) ($request['id'] ?? '')) ?>"><?= support_h((string) ($request['subject'] ?? '')) ?></a></strong>
<small><?= support_h((string) ($request['category'] ?? 'general')) ?>, <?= support_h((string) ($request['priority'] ?? 'normal')) ?></small>
</td>
<td><span class="status <?= support_h($statusClass) ?>"><?= support_h((string) ($request['status'] ?? 'new')) ?></span></td>
<td><?= support_h((string) ($request['request_direction'] ?? 'member_to_tenant')) ?></td>
<td><?= support_h((string) ($request['route_target'] ?? 'tenant_responsible')) ?></td>
<td><?= support_dt((string) ($request['last_message_at'] ?? '')) ?></td>
<td><?= count((array) ($request['messages'] ?? [])) ?> Einträge</td>
</tr>
<?php endforeach; ?>
<?php if ($requests === []): ?>
<tr>
<td colspan="7">Noch keine Support-Vorgänge vorhanden.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="grid grid--2" style="margin-top:18px">
<article class="card">
<p class="eyebrow">Detailansicht</p>
<h2>Ausgewählter Vorgang</h2>
<?php if ($detailNotice !== null): ?>
<section class="alert alert-warning"><?= support_h((string) $detailNotice) ?></section>
<?php endif; ?>
<?php if (is_array($detail)): ?>
<div class="status-grid">
<div class="context">
<?= support_badge((string) ($detail['request_number'] ?? ''), 'success') ?>
<?= support_badge((string) ($detail['status'] ?? 'new'), 'warning') ?>
<?= support_badge((string) ($detail['route_target'] ?? 'tenant_responsible')) ?>
</div>
<p><strong>Betreff:</strong> <?= support_h((string) ($detail['subject'] ?? '')) ?></p>
<p><strong>Absender:</strong> <?= support_h((string) ($detail['requester_name'] ?? '')) ?> <span class="muted">(<?= support_h((string) ($detail['requester_email'] ?? '')) ?>)</span></p>
<p><strong>Richtung:</strong> <?= support_h((string) ($detail['request_direction'] ?? 'member_to_tenant')) ?>, <strong>Kategorie:</strong> <?= support_h((string) ($detail['category'] ?? 'general')) ?></p>
<p><strong>Letzte Aktivität:</strong> <?= support_dt((string) ($detail['last_message_at'] ?? '')) ?></p>
</div>
<div class="timeline" style="margin-top:18px">
<?php foreach (($detail['messages'] ?? []) as $message): ?>
<div class="timeline__item">
<p class="timeline__title">
<?= support_h((string) ($message['author_name'] ?? 'Unbekannt')) ?>
<span class="muted">· <?= support_h((string) ($message['author_role'] ?? 'member')) ?> · <?= support_dt((string) ($message['created_at'] ?? '')) ?></span>
</p>
<p class="timeline__meta"><?= nl2br(support_h((string) ($message['body'] ?? ''))) ?></p>
<div class="context" style="margin-top:10px">
<?= support_badge((string) ($message['message_kind'] ?? 'public_reply')) ?>
<?php if (!empty($message['new_status'])): ?>
<?= support_badge('Status: ' . (string) $message['new_status'], 'warning') ?>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php if (($detail['messages'] ?? []) === []): ?>
<div class="timeline__item">
<p class="timeline__title">Noch kein Verlauf</p>
<p class="timeline__meta">Sobald geantwortet oder aktualisiert wird, erscheint hier die Historie.</p>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<p class="muted">Wähle einen Vorgang aus der Liste, um Details, Verlauf und Bearbeitungsmöglichkeiten zu sehen.</p>
<?php endif; ?>
</article>
<article class="card">
<p class="eyebrow">Bearbeiten</p>
<h2>Antworten und Statuswechsel</h2>
<?php if (is_array($detail)): ?>
<?php if ($isManager): ?>
<form method="post" action="/support/" class="stack">
<input type="hidden" name="csrf" value="<?= support_h($csrf) ?>">
<input type="hidden" name="action" value="update-request">
<input type="hidden" name="request_id" value="<?= support_h((string) ($detail['id'] ?? '')) ?>">
<label>Status
<select name="status">
<?php foreach ($statuses as $status): ?>
<option value="<?= support_h($status) ?>" <?= (string) ($detail['status'] ?? '') === $status ? 'selected' : '' ?>><?= support_h($status) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>Routingziel
<select name="route_target">
<?php foreach ($routeTargets as $routeTarget): ?>
<option value="<?= support_h($routeTarget) ?>" <?= (string) ($detail['route_target'] ?? '') === $routeTarget ? 'selected' : '' ?>><?= support_h($routeTarget) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>Interne Notiz<textarea name="note" placeholder="Optional: kurze Begründung oder interner Kontext."></textarea></label>
<div class="actions">
<button type="submit">Vorgang aktualisieren</button>
</div>
</form>
<hr style="border:none;border-top:1px solid var(--line);margin:18px 0">
<form method="post" action="/support/" class="stack">
<input type="hidden" name="csrf" value="<?= support_h($csrf) ?>">
<input type="hidden" name="action" value="add-message">
<input type="hidden" name="request_id" value="<?= support_h((string) ($detail['id'] ?? '')) ?>">
<input type="hidden" name="message_kind" value="internal_note">
<label>Interne Antwort<textarea name="body" placeholder="Interne Rückfrage oder Zusatzinfo an das Verantwortlichen-Team."></textarea></label>
<div class="actions">
<button type="submit">Interne Notiz speichern</button>
</div>
</form>
<?php elseif ((string) ($detail['status'] ?? '') === 'waiting_on_user' || (string) ($detail['status'] ?? '') === 'open'): ?>
<form method="post" action="/support/" class="stack">
<input type="hidden" name="csrf" value="<?= support_h($csrf) ?>">
<input type="hidden" name="action" value="add-message">
<input type="hidden" name="request_id" value="<?= support_h((string) ($detail['id'] ?? '')) ?>">
<input type="hidden" name="message_kind" value="public_reply">
<label>Antwort<textarea name="body" placeholder="Deine Rückmeldung zum Vorgang."></textarea></label>
<div class="actions">
<button type="submit">Antwort senden</button>
</div>
</form>
<?php else: ?>
<section class="alert alert-info">
Für diesen Vorgang ist aktuell keine neue Antwort nötig. Sobald ein Verantwortlicher den Status auf
"waiting_on_user" setzt, erscheint hier wieder ein Eingabefeld.
</section>
<?php endif; ?>
<?php else: ?>
<section class="alert alert-info">
Wähle zuerst einen Vorgang aus der Liste. Dann kannst du ihn im Detail bearbeiten oder beantworten.
</section>
<?php endif; ?>
</article>
</section>
<?php endif; ?>
<p class="footer">Support-Modul als tenantfähiges Vorgangssystem, vorbereitet für Routing, Statuswechsel und spätere Erweiterungen.</p>
</main>
</body>
</html>
+197 -28
View File
@@ -1,42 +1,211 @@
@extends('layouts.app')
@section('page_title', 'Kaffeeliste SaaS - Surveys')
@section('page_title', $title ?? 'Kaffeeliste SaaS - Surveys')
@php
$board = $board ?? [
'metrics' => [],
'draft_surveys' => [],
'published_snapshots' => [],
'workflow' => [],
'roles' => [],
'publishing_rules' => [],
];
$memberBoard = $memberBoard ?? ['snapshots' => [], 'participation' => []];
@endphp
@section('content')
<section class="hero">
<div>
<p class="hero__kicker">Optionales Modul</p>
<h2 class="hero__title">Umfragen bleiben moeglich, sind aber nicht mehr Teil des Pflichtkerns.</h2>
<p class="hero__lead">
Das Survey-Modul ist weiterhin vorgesehen, wird aber bewusst als optionaler
Baustein gefuehrt. So bleibt der eigentliche Produktkern schlank und die
fruehere Zusatzfunktion geht nicht verloren.
</p>
</div>
<div class="toolbar">
<span class="badge">Feature flag</span>
<span class="badge">Tenant scoped</span>
<span class="badge badge--solid">Optional</span>
<section class="hero hero--split">
<div class="hero__content">
<div>
<p class="hero__kicker">Tenant Survey Admin</p>
<h2 class="hero__title">Umfragen werden tenantfaehig verwaltet und als Snapshot veroeffentlicht.</h2>
<p class="hero__lead">
Das Survey-Modul ist jetzt nicht mehr nur eine Zusatzfunktion, sondern ein klarer
Verwaltungsbereich fuer Tenant-Admins und Survey-Manager. Entwuerfe werden live gepflegt,
Mitglieder sehen nach Freigabe einen stabilen Snapshot.
</p>
</div>
<div class="hero__actions">
<a class="button" href="#survey-board">Admin-Uebersicht</a>
<a class="button button--ghost" href="#member-surveys">Mitglieder-Sicht</a>
</div>
<div class="hero__meta">
<span class="badge">Tenant scoped</span>
<span class="badge">Snapshot publishing</span>
<span class="badge badge--solid">Freigabe gesteuert</span>
</div>
</div>
<aside class="hero__aside">
@foreach (array_slice($board['metrics'] ?? [], 0, 3) as $metric)
<article class="card metric metric--compact">
<p class="metric__label">{{ $metric['label'] ?? 'Kennzahl' }}</p>
<div class="metric__value">{{ $metric['value'] ?? '-' }}</div>
<p class="muted">{{ $metric['detail'] ?? '' }}</p>
</article>
@endforeach
</aside>
</section>
<section class="grid grid--2">
<article class="panel">
<h3>Typische Einsatzfaelle</h3>
<ul class="list-reset">
<li><span class="status">Feedback</span> Kurze Stimmungsbilder zu Kaffee, Preisen oder Ausstattung.</li>
<li><span class="status">Organisation</span> Abstimmungen zu Office-Regeln und Betriebsroutinen.</li>
</ul>
<section id="survey-board" class="grid grid--2">
<article class="table-card">
<div class="table-card__header">
<div>
<p class="card__eyebrow">Administration</p>
<h3>Aktive Entwuerfe und Freigabestufen</h3>
</div>
<span class="pill">Draft -> Review -> Snapshot</span>
</div>
<div class="table-card__body">
<table>
<thead>
<tr>
<th>Umfrage</th>
<th>Fragen</th>
<th>Status</th>
<th>Hinweis</th>
</tr>
</thead>
<tbody>
@forelse ($board['draft_surveys'] ?? [] as $survey)
<tr>
<td>
<strong>{{ $survey->title() }}</strong>
<div class="muted">ID {{ $survey->id() }}</div>
</td>
<td>{{ $survey->questionCount() }}</td>
<td>
@if ($survey->isDraft())
<span class="status">Entwurf</span>
@elseif ($survey->status() === 'in_review')
<span class="status status--warning">Pruefung</span>
@else
<span class="status status--success">Freigegeben</span>
@endif
</td>
<td class="muted">
{{ $survey->isDraft() ? 'Kann live bearbeitet werden.' : 'Bereit fuer Snapshot und Freigabe.' }}
</td>
</tr>
@empty
<tr>
<td colspan="4" class="muted">Keine Umfragen vorhanden.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</article>
<article class="panel">
<h3>Produktentscheidung</h3>
<p class="muted">
Das Modul ist vorbereitet, blockiert aber weder Migration noch Go-live. Aktiviert wird es nur,
wenn ein Mandant den Bedarf wirklich hat.
</p>
<h3>Freigabe-Workflow</h3>
<div class="timeline">
@foreach ($board['workflow'] ?? [] as $step)
<div class="timeline__item">
<p class="timeline__title">{{ $step['step'] ?? 'Schritt' }}</p>
<p class="timeline__meta">{{ $step['detail'] ?? '' }}</p>
</div>
@endforeach
</div>
<div class="note" style="margin-top: 18px;">
Surveys bleiben ein Erweiterungsmodul und ueberlagern nicht mehr Dashboard, Ledger oder Payments.
Freigaben koennen tenantweit deaktivierbar bleiben, der veroeffentlichte Stand bleibt trotzdem ein eigener Snapshot.
</div>
</article>
</section>
<section id="member-surveys" class="grid grid--2" style="margin-top: 18px;">
<article class="table-card">
<div class="table-card__header">
<div>
<p class="card__eyebrow">Veroeffentlicht</p>
<h3>Snapshots fuer Mitglieder</h3>
</div>
<span class="pill">Read only</span>
</div>
<div class="table-card__body">
<table>
<thead>
<tr>
<th>Snapshot</th>
<th>Stand</th>
<th>Antworten</th>
<th>Freigabe</th>
</tr>
</thead>
<tbody>
@forelse ($board['published_snapshots'] ?? [] as $snapshot)
<tr>
<td>
<strong>{{ $snapshot->surveyTitle() }}</strong>
<div class="muted">{{ $snapshot->versionLabel() }}</div>
</td>
<td>
<div>{{ $snapshot->publishedAt() }}</div>
<div class="muted">von {{ $snapshot->publishedBy() }}</div>
</td>
<td>{{ $snapshot->responseCount() }}</td>
<td>
@if ($snapshot->memberVisible())
<span class="status status--success">Sichtbar</span>
@else
<span class="status status--warning">Gesperrt</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="4" class="muted">Noch kein Snapshot veroeffentlicht.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</article>
<article class="panel">
<h3>Mitglieder-Sicht</h3>
<div class="stack">
@foreach ($memberBoard['participation'] ?? [] as $item)
<div class="feature-list__item">
<div class="feature-list__badge">S</div>
<div>
<p class="feature-list__title">{{ $item }}</p>
</div>
</div>
@endforeach
</div>
<div class="note" style="margin-top: 18px;">
Mitglieder sehen nur freigegebene Snapshots. Entwuerfe bleiben fuer die Fachseite bearbeitbar.
</div>
</article>
</section>
<section class="grid grid--2" style="margin-top: 18px;">
<article class="panel">
<h3>Rollen Im Modul</h3>
<div class="stack">
@foreach ($board['roles'] ?? [] as $role)
<div class="feature-list__item">
<div class="feature-list__badge">{{ strtoupper(substr((string) ($role['role'] ?? ''), 0, 1)) }}</div>
<div>
<p class="feature-list__title">{{ $role['role'] ?? '' }}</p>
<p class="feature-list__copy muted">{{ $role['capability'] ?? '' }}</p>
</div>
</div>
@endforeach
</div>
</article>
<article class="panel">
<h3>Regeln Fuer Das Publishing</h3>
<ul class="list-reset">
@foreach ($board['publishing_rules'] ?? [] as $rule)
<li style="margin-bottom: 12px;">
<span class="status">Regel</span> {{ $rule }}
</li>
@endforeach
</ul>
</article>
</section>
@endsection
@@ -8,7 +8,21 @@
'tenants' => [],
'shared_access' => [],
'operations' => [],
'roleOverview' => [
'metrics' => [],
'roles' => [],
'delegation_rules' => [],
'tenant_snapshots' => [],
'notes' => [],
],
];
$roleOverview = $roleOverview ?? ($data['roleOverview'] ?? [
'metrics' => [],
'roles' => [],
'delegation_rules' => [],
'tenant_snapshots' => [],
'notes' => [],
]);
@endphp
@section('content')
@@ -138,4 +152,51 @@
</div>
</article>
</section>
<section class="table-card" style="margin-top: 18px;">
<div class="table-card__header">
<div>
<p class="card__eyebrow">Rollenmodell</p>
<h3>Tenant-Admin Vollzugriff plus Spezialrollen mit klarer Delegation.</h3>
</div>
<a class="button button--ghost" href="/tenants/roles/">Rollenansicht öffnen</a>
</div>
<div class="grid grid--4" style="margin-bottom: 18px;">
@foreach ($roleOverview['metrics'] as $metric)
<article class="metric metric--compact">
<p class="metric__label">{{ $metric['label'] }}</p>
<div class="metric__value">{{ $metric['value'] }}</div>
<p class="muted">{{ $metric['detail'] }}</p>
</article>
@endforeach
</div>
<div class="table-card__body">
<table>
<thead>
<tr>
<th>Rolle</th>
<th>Bereich</th>
<th>Rechte</th>
<th>Delegation</th>
</tr>
</thead>
<tbody>
@foreach ($roleOverview['roles'] as $role)
<tr>
<td>
<strong>{{ $role['label'] }}</strong><br>
<span class="muted">{{ $role['key'] }}</span>
</td>
<td>{{ $role['scope'] }}</td>
<td>{{ implode(', ', $role['rights']) }}</td>
<td>
{{ $role['delegation'] }}<br>
<span class="muted">{{ $role['notes'] }}</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</section>
@endsection
@@ -0,0 +1,248 @@
<?php
/** @var array<string, mixed> $pageData */
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kaffeeliste SaaS - Tenant Rollen</title>
<style>
:root{
--bg:#f4efe6;
--card:#fffaf3;
--ink:#22160f;
--muted:#675546;
--brand:#0c6b66;
--brand-2:#084d49;
--accent:#a34b12;
--line:rgba(34,22,15,.12);
--shadow:0 24px 54px rgba(57,35,22,.11);
--radius:24px;
}
*{box-sizing:border-box}
body{
margin:0;
min-height:100vh;
font-family:"Aptos","Segoe UI",sans-serif;
color:var(--ink);
background:
radial-gradient(circle at top left, rgba(163,75,18,.10), transparent 26%),
radial-gradient(circle at top right, rgba(12,107,102,.14), transparent 24%),
linear-gradient(180deg,#fbf8f2 0%,var(--bg) 100%);
}
a{color:inherit;text-decoration:none}
h1,h2,h3{font-family:Georgia,serif;letter-spacing:-.02em}
.shell{width:min(1300px,calc(100vw - 32px));margin:24px auto 40px}
.bar,.hero,.card,.table-card,.note{border:1px solid var(--line);border-radius:var(--radius);background:var(--card);box-shadow:var(--shadow)}
.bar{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:18px 22px;margin-bottom:18px;flex-wrap:wrap}
.brand{display:grid;gap:4px}
.brand strong{font-size:1.26rem}
.brand span,.muted{color:var(--muted)}
.toolbar,.actions,.context,.meta{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
.badge,.pill{display:inline-flex;align-items:center;padding:7px 12px;border-radius:999px;background:rgba(12,107,102,.08);color:var(--brand);font-weight:700;font-size:.86rem}
.badge--solid{background:linear-gradient(135deg,var(--brand),var(--brand-2));color:#fff}
.hero{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(260px,.6fr);gap:20px;padding:28px;margin-bottom:18px}
.hero__kicker{margin:0 0 10px;color:var(--accent);text-transform:uppercase;letter-spacing:.15em;font-size:.8rem;font-weight:800}
.hero__title{margin:0 0 12px;font-size:clamp(2rem,4vw,3.5rem);line-height:1.05}
.hero__lead{margin:0;color:var(--muted);max-width:70ch;line-height:1.7}
.hero__actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:16px}
.button{display:inline-flex;align-items:center;justify-content:center;padding:11px 16px;border-radius:999px;border:0;background:linear-gradient(135deg,var(--brand),var(--brand-2));color:#fff;font-weight:700}
.button--ghost{background:transparent;color:var(--brand);border:1px solid rgba(12,107,102,.18)}
.grid{display:grid;gap:18px}
.grid--2{grid-template-columns:repeat(2,minmax(0,1fr))}
.grid--3{grid-template-columns:repeat(3,minmax(0,1fr))}
.grid--4{grid-template-columns:repeat(4,minmax(0,1fr))}
.metric{padding:18px;border:1px solid var(--line);border-radius:20px;background:#fffdf8}
.metric__label{color:var(--muted)}
.metric__value{font-size:1.9rem;font-weight:800;margin:6px 0 8px}
.table-card,.card{padding:22px}
.table-card__header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:16px;flex-wrap:wrap}
table{width:100%;border-collapse:collapse}
th,td{padding:12px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}
th{font-size:.82rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
.status{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;background:rgba(12,107,102,.1);color:var(--brand);font-weight:700;font-size:.84rem}
.status--warning{background:rgba(163,75,18,.12);color:#98510c}
.status--success{background:rgba(17,98,61,.12);color:#11623d}
.list-reset{margin:0;padding-left:18px}
.list-reset li+li{margin-top:10px}
.note{padding:16px 18px;background:rgba(12,107,102,.06)}
.stack{display:grid;gap:12px}
.chip-list{display:flex;flex-wrap:wrap;gap:8px}
.chip{display:inline-flex;align-items:center;padding:5px 10px;border-radius:999px;background:rgba(12,107,102,.08);color:var(--brand);font-size:.84rem;font-weight:700}
@media(max-width:960px){.hero,.grid--2,.grid--3,.grid--4{grid-template-columns:1fr}.bar{align-items:flex-start;flex-direction:column}}
</style>
</head>
<body>
<main class="shell">
<header class="bar">
<div class="brand">
<strong>Kaffeeliste SaaS</strong>
<span>Tenant Rollen, Rechte und Delegation an einem Ort.</span>
</div>
<div class="toolbar">
<span class="badge"><?= tenant_roles_h((string) ($tenant['name'] ?? 'Tenant')) ?></span>
<span class="badge">Tenant-Admin Vollzugriff</span>
<span class="badge badge--solid">Vier-Augen optional</span>
</div>
</header>
<section class="hero">
<div>
<p class="hero__kicker">Tenant Rollenmodell</p>
<h1 class="hero__title">Die Plattform zeigt klar, wer was darf und wer es weitergeben kann.</h1>
<p class="hero__lead">
Tenant-Admins behalten den Gesamtzugriff, während finance_admin, support_contact und survey_manager
als Fachrollen gezielt delegiert werden. Wenn eine Funktion noch nicht direkt in der Anwendung steckt,
wird sie als Tenant-Funktion eingerichtet und sichtbar gemacht.
</p>
<div class="hero__actions">
<a class="button" href="/tenants/">Zur Tenant-Konsole</a>
<a class="button button--ghost" href="/dashboard/">Zum Tenant-Dashboard</a>
</div>
<div class="meta" style="margin-top:16px;">
<span class="badge">lokal + ADFS/OIDC</span>
<span class="badge">Rollenmatrix</span>
<span class="badge">Delegation</span>
</div>
</div>
<div class="stack">
<?php foreach (array_slice($metrics, 0, 4) as $metric): ?>
<article class="metric">
<div class="metric__label"><?= tenant_roles_h((string) ($metric['label'] ?? 'Kennzahl')) ?></div>
<div class="metric__value"><?= tenant_roles_h((string) ($metric['value'] ?? '-')) ?></div>
<div class="muted"><?= tenant_roles_h((string) ($metric['detail'] ?? '')) ?></div>
</article>
<?php endforeach; ?>
</div>
</section>
<section class="grid grid--2">
<article class="table-card">
<div class="table-card__header">
<div>
<p class="hero__kicker">Rollenmatrix</p>
<h2>Vollzugriff, Spezialrollen und Leserechte</h2>
</div>
<span class="pill">tenant scoped</span>
</div>
<table>
<thead>
<tr>
<th>Rolle</th>
<th>Rechte</th>
<th>Delegation</th>
</tr>
</thead>
<tbody>
<?php foreach ($roles as $role): ?>
<tr>
<td>
<strong><?= tenant_roles_h((string) ($role['label'] ?? $role['key'] ?? 'Rolle')) ?></strong><br>
<span class="muted"><?= tenant_roles_h((string) ($role['scope'] ?? '')) ?></span>
</td>
<td>
<div class="chip-list">
<?php foreach (($role['rights'] ?? []) as $right): ?>
<span class="chip"><?= tenant_roles_h((string) $right) ?></span>
<?php endforeach; ?>
</div>
</td>
<td>
<?= tenant_roles_h((string) ($role['delegation'] ?? '')) ?><br>
<span class="muted"><?= tenant_roles_h((string) ($role['notes'] ?? '')) ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</article>
<article class="card">
<p class="hero__kicker">Delegation</p>
<h2>Wer Rollen vergeben darf</h2>
<div class="stack">
<?php foreach ($delegationRules as $rule): ?>
<div class="note">
<strong><?= tenant_roles_h((string) ($rule['from'] ?? '')) ?> → <?= tenant_roles_h((string) ($rule['to'] ?? '')) ?></strong>
<div><?= tenant_roles_h((string) ($rule['rule'] ?? '')) ?></div>
<div class="muted" style="margin-top:4px;">
Freigabe: <?= tenant_roles_h((string) ($rule['approval'] ?? '')) ?>
</div>
<div class="muted"><?= tenant_roles_h((string) ($rule['note'] ?? '')) ?></div>
</div>
<?php endforeach; ?>
</div>
<div class="note" style="margin-top:16px;">
Das Vier-Augen-Prinzip kann tenantweit aktiviert oder deaktiviert werden. Kritische Aktionen werden
dadurch kontrolliert, ohne die Facharbeit in jedem Tenant gleich stark zu bremsen.
</div>
</article>
</section>
<section class="grid grid--2" style="margin-top:18px;">
<article class="table-card">
<div class="table-card__header">
<div>
<p class="hero__kicker">Tenant-Snapshots</p>
<h2>Wie die Rollen in den Tenants aussehen</h2>
</div>
<span class="pill"><?= tenant_roles_h((string) ($tenant['tenant_key'] ?? 'berlin')) ?></span>
</div>
<table>
<thead>
<tr>
<th>Mandant</th>
<th>Admins</th>
<th>Spezialrollen</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php foreach ($tenantSnapshots as $snapshot): ?>
<tr>
<td>
<strong><?= tenant_roles_h((string) ($snapshot['name'] ?? 'Tenant')) ?></strong><br>
<span class="muted"><?= tenant_roles_h((string) ($snapshot['tenant_key'] ?? '')) ?> · <?= tenant_roles_h((string) ($snapshot['primary_contact'] ?? '')) ?></span>
</td>
<td><?= (int) ($snapshot['admin_count'] ?? 0) ?></td>
<td><?= tenant_roles_implode((array) ($snapshot['special_roles'] ?? [])) ?></td>
<td>
<?php if (($snapshot['status'] ?? 'active') === 'active'): ?>
<span class="status status--success"><?= tenant_roles_h((string) ($snapshot['delegation_state'] ?? 'Aktiv')) ?></span>
<?php else: ?>
<span class="status status--warning"><?= tenant_roles_h((string) ($snapshot['delegation_state'] ?? 'Prüfen')) ?></span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</article>
<article class="card">
<p class="hero__kicker">Lesbare Sicht</p>
<h2>Was Tenant-Admins direkt verstehen sollen</h2>
<div class="stack">
<?php foreach ($permissionGroups as $group): ?>
<div class="note">
<strong><?= tenant_roles_h((string) ($group['title'] ?? 'Rechtegruppe')) ?></strong>
<div class="chip-list" style="margin-top:8px;">
<?php foreach (($group['items'] ?? []) as $item): ?>
<span class="chip"><?= tenant_roles_h((string) $item) ?></span>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<ul class="list-reset" style="margin-top:16px;">
<?php foreach ($notes as $note): ?>
<li><?= tenant_roles_h((string) $note) ?></li>
<?php endforeach; ?>
</ul>
</article>
</section>
</main>
</body>
</html>
+7
View File
@@ -10,6 +10,13 @@ return [
'name' => 'content.index',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/support',
'action' => 'SupportController@index',
'name' => 'support.index',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/surveys',