diff --git a/docs/legacy-implementation-roadmap.md b/docs/legacy-implementation-roadmap.md index 491b8bd..9323d80 100644 --- a/docs/legacy-implementation-roadmap.md +++ b/docs/legacy-implementation-roadmap.md @@ -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 diff --git a/saas-app/app/Modules/Content/Application/ContentService.php b/saas-app/app/Modules/Content/Application/ContentService.php index 2ea68cc..2f908a2 100644 --- a/saas-app/app/Modules/Content/Application/ContentService.php +++ b/saas-app/app/Modules/Content/Application/ContentService.php @@ -26,6 +26,81 @@ class ContentService ]; } + /** + * @return array + */ + 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 + */ + 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 */ diff --git a/saas-app/app/Modules/Content/Controllers/ContentController.php b/saas-app/app/Modules/Content/Controllers/ContentController.php new file mode 100644 index 0000000..1dd857f --- /dev/null +++ b/saas-app/app/Modules/Content/Controllers/ContentController.php @@ -0,0 +1,36 @@ + '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), + ], + ]; + } +} diff --git a/saas-app/app/Modules/Content/Domain/Announcement.php b/saas-app/app/Modules/Content/Domain/Announcement.php index 4609679..031603c 100644 --- a/saas-app/app/Modules/Content/Domain/Announcement.php +++ b/saas-app/app/Modules/Content/Domain/Announcement.php @@ -16,6 +16,21 @@ class Announcement ) { } + /** + * @return array + */ + 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; + } } diff --git a/saas-app/app/Modules/Content/Domain/FaqItem.php b/saas-app/app/Modules/Content/Domain/FaqItem.php index 3e87111..ba31ddf 100644 --- a/saas-app/app/Modules/Content/Domain/FaqItem.php +++ b/saas-app/app/Modules/Content/Domain/FaqItem.php @@ -16,6 +16,21 @@ class FaqItem ) { } + /** + * @return array + */ + 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; + } } diff --git a/saas-app/app/Modules/Identity/Controllers/LoginController.php b/saas-app/app/Modules/Identity/Controllers/LoginController.php index 1a5e7cd..237b97f 100644 --- a/saas-app/app/Modules/Identity/Controllers/LoginController.php +++ b/saas-app/app/Modules/Identity/Controllers/LoginController.php @@ -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(), ], ]; } diff --git a/saas-app/app/Modules/Identity/Controllers/OidcController.php b/saas-app/app/Modules/Identity/Controllers/OidcController.php index 1c6e50e..d843254 100644 --- a/saas-app/app/Modules/Identity/Controllers/OidcController.php +++ b/saas-app/app/Modules/Identity/Controllers/OidcController.php @@ -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, ]; } diff --git a/saas-app/app/Modules/Identity/Services/AuthService.php b/saas-app/app/Modules/Identity/Services/AuthService.php index 372ca73..b696ce5 100644 --- a/saas-app/app/Modules/Identity/Services/AuthService.php +++ b/saas-app/app/Modules/Identity/Services/AuthService.php @@ -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> + */ + 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 */ @@ -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(), ]; } } diff --git a/saas-app/app/Modules/Identity/Services/OidcProviderService.php b/saas-app/app/Modules/Identity/Services/OidcProviderService.php index b7496d2..f842f89 100644 --- a/saas-app/app/Modules/Identity/Services/OidcProviderService.php +++ b/saas-app/app/Modules/Identity/Services/OidcProviderService.php @@ -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; + } } diff --git a/saas-app/app/Modules/Identity/Support/IdentityPolicy.php b/saas-app/app/Modules/Identity/Support/IdentityPolicy.php new file mode 100644 index 0000000..4f66a15 --- /dev/null +++ b/saas-app/app/Modules/Identity/Support/IdentityPolicy.php @@ -0,0 +1,30 @@ + 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', + ], + ], + ]; + } +} diff --git a/saas-app/app/Modules/Identity/Support/RoleMatrix.php b/saas-app/app/Modules/Identity/Support/RoleMatrix.php new file mode 100644 index 0000000..65a07bd --- /dev/null +++ b/saas-app/app/Modules/Identity/Support/RoleMatrix.php @@ -0,0 +1,46 @@ + [ + [ + '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.', + ], + ]; + } +} diff --git a/saas-app/app/Modules/Ledger/Application/DashboardService.php b/saas-app/app/Modules/Ledger/Application/DashboardService.php index 6066946..7f79acb 100644 --- a/saas-app/app/Modules/Ledger/Application/DashboardService.php +++ b/saas-app/app/Modules/Ledger/Application/DashboardService.php @@ -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), ]; } } diff --git a/saas-app/app/Modules/Ledger/Application/LedgerService.php b/saas-app/app/Modules/Ledger/Application/LedgerService.php index 3a63af3..bd352df 100644 --- a/saas-app/app/Modules/Ledger/Application/LedgerService.php +++ b/saas-app/app/Modules/Ledger/Application/LedgerService.php @@ -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 + */ + 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.', + ], + ]; + } } diff --git a/saas-app/app/Modules/Ledger/Controllers/LedgerController.php b/saas-app/app/Modules/Ledger/Controllers/LedgerController.php new file mode 100644 index 0000000..3d6e34f --- /dev/null +++ b/saas-app/app/Modules/Ledger/Controllers/LedgerController.php @@ -0,0 +1,31 @@ + + */ + 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, + ], + ]; + } +} diff --git a/saas-app/app/Modules/Ledger/Domain/CoffeeEntry.php b/saas-app/app/Modules/Ledger/Domain/CoffeeEntry.php index e286acc..fa4063e 100644 --- a/saas-app/app/Modules/Ledger/Domain/CoffeeEntry.php +++ b/saas-app/app/Modules/Ledger/Domain/CoffeeEntry.php @@ -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 + */ + 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(), + ]; + } } diff --git a/saas-app/app/Modules/Ledger/Domain/LedgerEntry.php b/saas-app/app/Modules/Ledger/Domain/LedgerEntry.php index ada6ce8..c02c50f 100644 --- a/saas-app/app/Modules/Ledger/Domain/LedgerEntry.php +++ b/saas-app/app/Modules/Ledger/Domain/LedgerEntry.php @@ -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 + */ + 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(), + ]; + } } diff --git a/saas-app/app/Modules/Payments/Application/PaymentService.php b/saas-app/app/Modules/Payments/Application/PaymentService.php index 304e176..4e02c9a 100644 --- a/saas-app/app/Modules/Payments/Application/PaymentService.php +++ b/saas-app/app/Modules/Payments/Application/PaymentService.php @@ -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 + */ + 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')), + ], + ]; + } } diff --git a/saas-app/app/Modules/Payments/Controllers/PaymentsController.php b/saas-app/app/Modules/Payments/Controllers/PaymentsController.php new file mode 100644 index 0000000..ea8e8f5 --- /dev/null +++ b/saas-app/app/Modules/Payments/Controllers/PaymentsController.php @@ -0,0 +1,31 @@ + + */ + 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, + ], + ]; + } +} diff --git a/saas-app/app/Modules/Payments/Domain/Payment.php b/saas-app/app/Modules/Payments/Domain/Payment.php index ed9fb43..c11d460 100644 --- a/saas-app/app/Modules/Payments/Domain/Payment.php +++ b/saas-app/app/Modules/Payments/Domain/Payment.php @@ -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 + */ + 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(), + ]; + } } diff --git a/saas-app/app/Modules/Support/Application/SupportService.php b/saas-app/app/Modules/Support/Application/SupportService.php new file mode 100644 index 0000000..5cead2d --- /dev/null +++ b/saas-app/app/Modules/Support/Application/SupportService.php @@ -0,0 +1,626 @@ + [ + '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> + */ + public function directions(): array + { + return self::DIRECTIONS; + } + + /** + * @return array + */ + public function statuses(): array + { + return self::STATUSES; + } + + /** + * @return array + */ + public function priorities(): array + { + return self::PRIORITIES; + } + + /** + * @return array + */ + public function categories(): array + { + return self::CATEGORIES; + } + + /** + * @return array + */ + 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 + */ + 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 + */ + 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 $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 $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> + */ + private function queryAll(PDO $pdo, string $sql, array $params = []): array + { + $statement = $pdo->prepare($sql); + $statement->execute($params); + + return $statement->fetchAll() ?: []; + } + + /** + * @return array|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); + } +} diff --git a/saas-app/app/Modules/Support/Controllers/SupportController.php b/saas-app/app/Modules/Support/Controllers/SupportController.php new file mode 100644 index 0000000..039748d --- /dev/null +++ b/saas-app/app/Modules/Support/Controllers/SupportController.php @@ -0,0 +1,72 @@ + + */ + 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']) : '')); + } +} diff --git a/saas-app/app/Modules/Support/Domain/SupportRequest.php b/saas-app/app/Modules/Support/Domain/SupportRequest.php new file mode 100644 index 0000000..2c4e4af --- /dev/null +++ b/saas-app/app/Modules/Support/Domain/SupportRequest.php @@ -0,0 +1,103 @@ + $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 $row + * @param array $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 + */ + 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 + ), + ]; + } +} diff --git a/saas-app/app/Modules/Support/Domain/SupportRequestMessage.php b/saas-app/app/Modules/Support/Domain/SupportRequestMessage.php new file mode 100644 index 0000000..09c16ff --- /dev/null +++ b/saas-app/app/Modules/Support/Domain/SupportRequestMessage.php @@ -0,0 +1,63 @@ + $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 + */ + 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, + ]; + } +} diff --git a/saas-app/app/Modules/Support/bootstrap.php b/saas-app/app/Modules/Support/bootstrap.php new file mode 100644 index 0000000..254a64e --- /dev/null +++ b/saas-app/app/Modules/Support/bootstrap.php @@ -0,0 +1,8 @@ + + */ + 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 + */ + 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 + */ + 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.', + ), + ]; + } } diff --git a/saas-app/app/Modules/Surveys/Controllers/SurveyController.php b/saas-app/app/Modules/Surveys/Controllers/SurveyController.php new file mode 100644 index 0000000..339c020 --- /dev/null +++ b/saas-app/app/Modules/Surveys/Controllers/SurveyController.php @@ -0,0 +1,27 @@ + 'surveys.index', + 'data' => [ + 'title' => 'Survey Admin und Snapshot Publishing', + 'board' => $this->surveyService->managerBoard($tenantId), + 'memberBoard' => $this->surveyService->memberBoard($tenantId), + 'draftSurveys' => $this->surveyService->activeSurveys($tenantId), + ], + ]; + } +} diff --git a/saas-app/app/Modules/Surveys/Domain/Survey.php b/saas-app/app/Modules/Surveys/Domain/Survey.php index b154832..4fab355 100644 --- a/saas-app/app/Modules/Surveys/Domain/Survey.php +++ b/saas-app/app/Modules/Surveys/Domain/Survey.php @@ -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'; + } } diff --git a/saas-app/app/Modules/Surveys/Domain/SurveyPublication.php b/saas-app/app/Modules/Surveys/Domain/SurveyPublication.php new file mode 100644 index 0000000..ec5a874 --- /dev/null +++ b/saas-app/app/Modules/Surveys/Domain/SurveyPublication.php @@ -0,0 +1,66 @@ +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; + } +} diff --git a/saas-app/app/Modules/Surveys/Domain/SurveyQuestion.php b/saas-app/app/Modules/Surveys/Domain/SurveyQuestion.php index 6ec3e76..8c43017 100644 --- a/saas-app/app/Modules/Surveys/Domain/SurveyQuestion.php +++ b/saas-app/app/Modules/Surveys/Domain/SurveyQuestion.php @@ -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 + */ + public function options(): array + { + return $this->options; + } } diff --git a/saas-app/app/Modules/Tenants/Controllers/TenantConsoleController.php b/saas-app/app/Modules/Tenants/Controllers/TenantConsoleController.php index efe421d..0305301 100644 --- a/saas-app/app/Modules/Tenants/Controllers/TenantConsoleController.php +++ b/saas-app/app/Modules/Tenants/Controllers/TenantConsoleController.php @@ -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), ], ]; } diff --git a/saas-app/app/Modules/Tenants/Models/TenantUser.php b/saas-app/app/Modules/Tenants/Models/TenantUser.php index 328bdde..447f0cd 100644 --- a/saas-app/app/Modules/Tenants/Models/TenantUser.php +++ b/saas-app/app/Modules/Tenants/Models/TenantUser.php @@ -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 + */ + 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 + ); + } } diff --git a/saas-app/app/Modules/Tenants/Services/TenantMembershipService.php b/saas-app/app/Modules/Tenants/Services/TenantMembershipService.php index e3c529f..1ccff95 100644 --- a/saas-app/app/Modules/Tenants/Services/TenantMembershipService.php +++ b/saas-app/app/Modules/Tenants/Services/TenantMembershipService.php @@ -17,4 +17,24 @@ class TenantMembershipService roles: ['tenant_admin'] ); } + + /** + * @return array + */ + public function specialRoles(): array + { + return [ + 'finance_admin', + 'support_contact', + 'survey_manager', + ]; + } + + /** + * @return array + */ + public function delegableRoles(): array + { + return $this->specialRoles(); + } } diff --git a/saas-app/app/Modules/Tenants/Services/TenantRoleService.php b/saas-app/app/Modules/Tenants/Services/TenantRoleService.php new file mode 100644 index 0000000..434c38b --- /dev/null +++ b/saas-app/app/Modules/Tenants/Services/TenantRoleService.php @@ -0,0 +1,261 @@ +> + */ + 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> + */ + 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 + */ + 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> + */ + 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 + */ + 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]; + } +} diff --git a/saas-app/app/Modules/Tenants/Services/TenantService.php b/saas-app/app/Modules/Tenants/Services/TenantService.php index ba35d98..1e6d810 100644 --- a/saas-app/app/Modules/Tenants/Services/TenantService.php +++ b/saas-app/app/Modules/Tenants/Services/TenantService.php @@ -45,6 +45,34 @@ class TenantService ); } + /** + * @return array|null + */ + public function findTenantById(string $tenantId): ?array + { + foreach ($this->tenantPortfolio() as $tenant) { + if (($tenant['id'] ?? '') === $tenantId) { + return $tenant; + } + } + + return null; + } + + /** + * @return array|null + */ + public function findTenantByKey(string $tenantKey): ?array + { + foreach ($this->tenantPortfolio() as $tenant) { + if (($tenant['tenant_key'] ?? '') === $tenantKey) { + return $tenant; + } + } + + return null; + } + /** * @return array */ @@ -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', ], ]; diff --git a/saas-app/database/migrations/2026_03_30_000001_create_support_requests_table.php b/saas-app/database/migrations/2026_03_30_000001_create_support_requests_table.php new file mode 100644 index 0000000..1aba8d6 --- /dev/null +++ b/saas-app/database/migrations/2026_03_30_000001_create_support_requests_table.php @@ -0,0 +1,37 @@ + '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); Dashboard Hinweise + Umfragen + Support Mitglieder Buchungen @@ -436,6 +448,27 @@ $canManageTenant = app_can_manage_tenant($auth); +
+
+
Content
+

Hinweise und FAQ

+

Tenant-Admins pflegen Meldungen, Standardvorlagen und FAQ direkt im Mandantenkontext.

+ +
+
+
Support
+

Vorgänge mit Status

+

Benutzer und Verantwortliche sehen Supportanfragen mit Routing, Verlauf und Bearbeitungsstand.

+ +
+
+
Surveys
+

Snapshots und Freigaben

+

Umfragen werden live bearbeitet, Mitglieder sehen freigegebene Snapshots statt Entwürfe.

+ +
+
+
diff --git a/saas-app/public/logout/index.php b/saas-app/public/logout/index.php index 79d03ec..91bc2d7 100644 --- a/saas-app/public/logout/index.php +++ b/saas-app/public/logout/index.php @@ -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(); + +?> + + + + + Kaffeeliste SaaS - Abmelden + + + +
+
+
Abmeldung
+

+

+ +

+ +
+ Lokale Anmeldung bleibt Pflicht + ADFS/OIDC als Zusatzoption + Email-Mapping und Klaerfall bleiben aktiv +
+ +
+ Wenn du in mehreren Tenants arbeitest oder ein externer Provider nicht erreichbar ist, kannst du dich weiterhin mit dem lokalen Konto anmelden. +
+ + +
+
+ + diff --git a/saas-app/public/support/index.php b/saas-app/public/support/index.php new file mode 100644 index 0000000..7df758a --- /dev/null +++ b/saas-app/public/support/index.php @@ -0,0 +1,107 @@ +' . support_h($label) . ''; +} + +$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; diff --git a/saas-app/public/surveys/index.php b/saas-app/public/surveys/index.php new file mode 100644 index 0000000..1c94914 --- /dev/null +++ b/saas-app/public/surveys/index.php @@ -0,0 +1,293 @@ +index($tenantId); +$data = $payload['data']; +$board = $data['board']; +$memberBoard = $data['memberBoard']; + +?> + + + + + Kaffeeliste SaaS - Surveys + + + +
+
+
+ Kaffeeliste SaaS + Surveys als tenantfaehiger Verwaltungsbereich mit Snapshot-Publishing. +
+
+ + Tenant-Admin / Survey-Manager + Snapshot-Modus +
+
+ +
+
+

Survey Admin

+

Umfragen werden fachlich gepflegt und als veroeffentlichte Version ausgeliefert.

+

+ 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. +

+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+

Verwaltung

+

Entwuerfe und Freigaben

+
+ Draft -> Review -> Snapshot +
+ + + + + + + + + + + + + + + + + + + +
UmfrageFragenStatusKommentar
+ title()) ?> +
ID id()) ?>
+
questionCount() ?> + isDraft()): ?> + Entwurf + status() === 'in_review'): ?> + Pruefung + + Freigegeben + + + isDraft() ? 'Live bearbeitbar, noch nicht freigegeben.' : 'Bereit fuer Freigabe oder Snapshot.') ?> +
+
+ Die Fachseite pflegt Entwuerfe live. Mitglieder sehen spaeter nur den veroeffentlichten Snapshot. +
+
+ +
+

Freigabe-Workflow

+
+ +
+

+

+
+ +
+
+ Freigaben koennen tenantweit konfigurierbar bleiben. Snapshot und Entwurf sind sauber getrennt. +
+
+
+ +
+
+
+
+

Veroeffentlicht

+

Snapshots fuer Mitglieder

+
+ Read only +
+ + + + + + + + + + + + + + + + + + + +
SnapshotStandAntwortenFreigabe
+ surveyTitle()) ?> +
versionLabel()) ?>
+
+
publishedAt())) ?>
+
von publishedBy()) ?>
+
responseCount() ?> + memberVisible()): ?> + Sichtbar + + Gesperrt + +
+
+ +
+

Mitglieder-Sicht

+
+ +
+
S
+
+

+
+
+ +
+
+ Mitglieder sehen nur freigegebene Snapshots. Entwuerfe bleiben fuer Tenant-Admins und Survey-Manager editierbar. +
+
+
+ +
+
+

Rollen Im Modul

+
+ +
+
+
+

+

+
+
+ +
+
+ +
+

Publishing-Regeln

+
    + +
  • Regel
  • + +
+
+ Tenant-Verantwortliche koennen Freigaben steuern und bei Bedarf den Vier-Augen-Mechanismus tenantweit deaktivieren. +
+
+
+
+ + diff --git a/saas-app/public/tenants/roles/index.php b/saas-app/public/tenants/roles/index.php new file mode 100644 index 0000000..30df17f --- /dev/null +++ b/saas-app/public/tenants/roles/index.php @@ -0,0 +1,70 @@ + $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; diff --git a/saas-app/resources/views/auth/login.blade.php b/saas-app/resources/views/auth/login.blade.php index 4f328ab..35961d2 100644 --- a/saas-app/resources/views/auth/login.blade.php +++ b/saas-app/resources/views/auth/login.blade.php @@ -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 @@
E-Mail first Tenant-Erkennung + Lokaler Login Pflicht + ADFS/OIDC zusaetzlich Mehrfachzuordnung unterstuetzt
@@ -111,6 +166,29 @@

Statt Sackgasse gibt es einen klaren Rueckweg zu Tenant-Admin, Einladung oder Support.

+
+ @foreach ($providers as $providerKey => $provider) +
+
{{ strtoupper(substr((string) $providerKey, 0, 1)) }}
+
+

{{ $provider['label'] ?? $providerKey }}

+

{{ $provider['description'] ?? '' }}

+
+ + {{ !empty($provider['required']) ? 'Pflicht' : 'Optional' }} + +
+ @endforeach +
+
+ {{ $identityPolicy['admin_clarification']['title'] ?? 'Admin-Klaerfall' }} +

{{ $identityPolicy['admin_clarification']['description'] ?? $identityPolicy['fallback'] ?? '' }}

+
    + @foreach (($identityPolicy['admin_clarification']['steps'] ?? []) as $step) +
  • - {{ $step }}
  • + @endforeach +
+
@@ -159,4 +237,85 @@ + +
+
+

Identity-Policy

+

Lokale Anmeldung plus ADFS/OIDC

+
+
+

Pflicht

+

{{ $identityPolicy['local_login_required'] ? 'Lokale Anmeldung bleibt immer verfuegbar.' : 'Lokale Anmeldung ist optional.' }}

+
+
+

Zusaetzliche Option

+

{{ $identityPolicy['external_login_mode'] ?? 'ADFS/OIDC' }}

+
+
+

Abgleich

+

{{ $identityPolicy['email_mapping'] ?? '' }}

+
+
+

Klärfall

+

{{ $identityPolicy['fallback'] ?? '' }}

+
+
+

Admin-Klaerfall

+

{{ $identityPolicy['admin_clarification']['description'] ?? '' }}

+
+
+
+ +
+

Rollenmodell

+

Vorgeschlagene Rechtebasis

+
+ @foreach ($roleMatrix['roles'] ?? [] as $role) +
+
{{ strtoupper(substr((string) ($role['key'] ?? ''), 0, 1)) }}
+
+

{{ $role['key'] ?? '' }}

+

{{ $role['summary'] ?? '' }}

+
+
+ @endforeach +
+
+
+ +
+
+

Provider-Details

+

Konfigurierte ADFS/OIDC-Verbindungen

+
+ @foreach ($oidcProviders as $provider) +
+

{{ $provider['provider_key'] ?? 'provider' }}

+

Driver: {{ $provider['driver'] ?? 'oidc' }} | Client-ID: {{ $provider['client_id'] ?? '-' }}

+

Redirect: {{ $provider['redirect_uri'] ?? '-' }}

+

Scopes: {{ implode(', ', $provider['scopes'] ?? []) }}

+
+ @endforeach +
+
+ +
+

Mindestregel

+

Lokale Anmeldung bleibt immer verfuegbar.

+
+ Selbst wenn ein externer Provider ausfaellt, bleibt der lokale Weg aktiv. So kann der Tenant weiterarbeiten und der Admin-Klaerfall bleibt handhabbar. +
+
+
+ +
+

Regeln fuer Rollen und Abgrenzung

+
    + @foreach ($roleMatrix['rules'] ?? [] as $rule) +
  • + Regel {{ $rule }} +
  • + @endforeach +
+
@endsection diff --git a/saas-app/resources/views/content/editor.blade.php b/saas-app/resources/views/content/editor.blade.php new file mode 100644 index 0000000..05e4086 --- /dev/null +++ b/saas-app/resources/views/content/editor.blade.php @@ -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') +
+
+
+

Tenant-Admin-Perspektive

+

Content-Redaktion mit Standardvorlage und Tenant-Override.

+

+ Diese Sicht ist fuer Tenant-Admins gedacht: Hinweise, FAQ und spaetere Textbausteine + folgen einer klaren Redaktionslogik mit Freigabe, Sichtbarkeit und tenantbezogener Pflege. +

+
+
+ Redaktion + Freigabe + Tenant Override +
+
+ + +
+ +
+
+

Workflow

+

Wie Inhalte durch den Tenant laufen

+
+ @foreach (($editorial['sections'] ?? []) as $section) +
+

{{ $section['title'] ?? '-' }}

+

{{ $section['description'] ?? '-' }}

+
+ @endforeach +
+
+ +
+

Bearbeitbar

+

Was Tenant-Admins erfassen koennen

+
    + @foreach (($editorial['editable_items']['announcement'] ?? []) as $announcement) +
  • + Hinweis: {{ $announcement['title'] ?? '-' }} + {{ $announcement['visible_until'] ?? '-' }} +
  • + @endforeach + @foreach (($editorial['editable_items']['faq'] ?? []) as $faqItem) +
  • + FAQ: {{ $faqItem['question'] ?? '-' }} + Sortierung {{ $faqItem['sort_order'] ?? '-' }} +
  • + @endforeach +
+
+
+ +
+

Empfehlung

+

Standardvorlage plus Tenant-Override

+

+ 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. +

+
+@endsection diff --git a/saas-app/resources/views/content/index.blade.php b/saas-app/resources/views/content/index.blade.php index af03502..88e0842 100644 --- a/saas-app/resources/views/content/index.blade.php +++ b/saas-app/resources/views/content/index.blade.php @@ -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') -
-
-

Content

-

Hinweise, Banner und FAQ werden tenantbezogene Inhalte.

-

- 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. -

-
-
- Announcements - FAQ - Tenant scoped +
+
+
+

Content-MVP

+

Hinweise und FAQ werden zu tenantfaehigen Redaktionsobjekten.

+

+ 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. +

+
+
+ Tenant scoped + Standardvorlage + Override +
+
+ {{ (int) ($summary['announcement_count'] ?? 0) }} Hinweise + {{ (int) ($summary['faq_count'] ?? 0) }} FAQ-Eintraege + Freigabe steuert Sichtbarkeit +
+ +
-
+
-

Aktiv

-

Hinweise im Umlauf

+

Hinweise

+

Aktive Meldungen im Umlauf

- Header + Dashboard + Tenant-Admin View
@@ -35,44 +74,121 @@ - - - - - - - - - - - - - + @forelse ($announcements as $announcement) + + + + + + @empty + + + + @endforelse
Titel Sichtbar bisKanal Status
Monatsabschluss am Freitag31.03.2026DashboardAktiv
Neuer Preis pro Strich15.04.2026HeaderGeplant
+ {{ $announcement['title'] ?? '-' }}
+ {{ $announcement['message'] ?? '-' }} +
{{ $announcement['visible_until'] ?? '-' }} + @if (array_key_exists('active', $announcement) ? (bool) $announcement['active'] : true) + Aktiv + @else + Inaktiv + @endif +
+ Noch keine aktiven Hinweise fuer diesen Tenant hinterlegt. +
-

Redaktioneller Nutzen

-
+

Redaktionsregeln

+

Standardvorlage plus Tenant-Override

+
-

Hinweise ohne Code-Aenderung

-

Tenant-Admins koennen sichtbare Meldungen direkt verwalten.

+

Vorlage zuerst

+

{{ $policy['template_policy'] ?? 'Standardvorlage plus Tenant-Override' }}

-

FAQ je Organisation

-

Hilfeinhalte koennen pro Mandant und Prozess gepflegt werden.

+

Sichtbarkeit steuern

+

{{ $policy['visibility_policy'] ?? 'Freigegebene Inhalte werden sichtbar' }}

-

Saubere Sichtbarkeit

-

Dashboard, Header und spaetere Mailtexte greifen auf denselben Content-Pool zu.

+

Tenant-Admin Verantwortung

+

{{ $policy['admin_scope'] ?? 'Tenant-Admins pflegen Inhalte im eigenen Mandanten' }}

+
+ @foreach ($workflow as $step) + {{ $step }} + @endforeach +
+
+
+ +
+
+
+

FAQ

+

Antworten, die tenantbezogen gepflegt werden

+
+ Standard + Override +
+
+ + + + + + + + + + + @forelse ($faqItems as $item) + + + + + + + @empty + + + + @endforelse + +
FrageAntwortReihenfolgeStatus
{{ $item['question'] ?? '-' }}{{ $item['answer'] ?? '-' }}{{ $item['sort_order'] ?? '-' }} + @if (array_key_exists('active', $item) ? (bool) $item['active'] : true) + Aktiv + @else + Entwurf + @endif +
+ FAQ-Inhalte werden tenantbezogen gepflegt und sind noch leer. +
+
+
+ +
+
+

Tenant-Admin

+

Redaktion im Mandantenkontext

+

Tenant-Admins sollen Hinweise, FAQ und spaetere Mailtexte ohne Code-Aenderung pflegen koennen.

+
+
+

Freigabe

+

Publikation mit klarer Sichtbarkeit

+

Freigaben steuern, was Mitglieder sehen und wann Inhalte automatisch auslaufen.

+
+
+

Erweiterung

+

Spaetere Kanaele ohne neue Struktur

+

Die gleiche Content-Basis kann spaeter auch fuer Benachrichtigungen und FAQ-Sync genutzt werden.

@endsection diff --git a/saas-app/resources/views/ledger/index.blade.php b/saas-app/resources/views/ledger/index.blade.php index fe189a7..d7ea43f 100644 --- a/saas-app/resources/views/ledger/index.blade.php +++ b/saas-app/resources/views/ledger/index.blade.php @@ -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') -
-
-

Accounting flow

-

Ledger fuer Buchungen, Verbrauch und Korrekturen.

-

- Die fachliche Kernlogik der alten Kaffeeliste bleibt erhalten: Einzahlungen, Striche und Saldo - werden in einer nachvollziehbaren Buchungsspur zusammengefuehrt. -

-
-
- Saldo: 7,50 EUR - Korrekturen: 2 - Buchungsspur geordnet +
+
+
+

Accounting flow

+

Ledger fuer Buchungen, Verbrauch, Storno und loeschbare Striche.

+

+ Das Ledger bildet die fachliche Buchungsspur der Kaffeeliste ab. Einzahlungen sind als Storno + sichtbar, Striche bleiben als verantwortbare Loeschung erkennbar und Korrekturen bleiben auditierbar. +

+
+
+ Audit trail + Storno nur durch Verantwortliche + Striche loeschbar +
+
+ {{ $entryCount }} Buchungen + {{ number_format($balance, 2, ',', '.') }} EUR Saldo + Letzte Buchung: {{ $latestEntry ?? '-' }} +
+ +

Offener Saldo

-
7,50 EUR
-

Positiv, daher kein Handlungsdruck.

+
{{ number_format($balance, 2, ',', '.') }} EUR
+

Die Buchungsspur bleibt sofort nachvollziehbar.

-

Verbrauchsumfang

-
5 Striche
-

Aktueller Buchungsumfang im laufenden Zeitraum.

+

Korrekturen

+
{{ $correctionCount }}
+

Stornos und manuelle Anpassungen sind im Audit sichtbar.

-

Letzte Korrektur

-
Heute
-

Sichtbar fuer Admins und Audit.

+

Buchungstypen

+
4 Pfade
+

Einzahlung, Verbrauch, Anpassung und Reversal bleiben getrennt erkennbar.

-
-
-
-

Buchungen

-

Ledger-Eintraege

+
+
+
+
+

Buchungen

+

Ledger-Eintraege

+
+ Tenant scoped
- Audit trail -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
DatumBeschreibungTypBetrag
21.03.2026Einzahlung Demo-WorkspacePayment+10,00 EUR
21.03.20262 Striche KaffeeConsumption-2,50 EUR
20.03.2026Manuelle KorrekturAdjustment+0,50 EUR
-
+
+ + + + + + + + + + + + + @forelse ($journal as $entry) + @php + $entryType = (string) ($entry['entry_type'] ?? ''); + $typeMeta = $entryTypes[$entryType] ?? ['label' => ucfirst($entryType ?: 'Unbekannt'), 'tone' => 'neutral']; + @endphp + + + + + + + + + @empty + + + + @endforelse + +
DatumMitgliedTypReferenzBetragAktion
{{ $entry['booked_at'] ?? '-' }}{{ $entry['member_name'] ?? '-' }}{{ $typeMeta['label'] }}{{ $entry['reference_type'] ?? '-' }}{{ number_format((float) ($entry['amount'] ?? 0), 2, ',', '.') }} EUR + @if ($entryType === 'payment') + stornierbar + @elseif ($entryType === 'consumption') + loeschbar + @elseif (in_array($entryType, ['adjustment', 'reversal'], true)) + auditpflichtig + @else + gebucht + @endif +
Noch keine Ledger-Eintraege vorhanden.
+
+ + +
+

Korrekturregeln

+

Was im MVP sichtbar sein muss

+
+
+

Einzahlungen

+

Storno ist fachlich vorgesehen und nur durch Verantwortliche ausloesbar.

+
+
+

Stricheintraege

+

Striche bleiben loeschbar, wenn der Verantwortliche das explizit veranlasst.

+
+
+

Audit

+

Jede Korrektur bleibt mit Ursprungseintrag und Zeitstempel nachvollziehbar.

+
+
+

Reconciliation

+

Zahlungsabgleich wird vorbereitet, aber noch nicht als automatischer MVP-Import ausgerollt.

+
+
+
+ Das Ledger bleibt damit fachlich vollständig genug fuer das MVP, ohne die spaetere Zahlungsabstimmung vorwegzunehmen. +
+
    + @foreach ($policyPoints as $policyPoint) +
  • {{ $policyPoint }}
  • + @endforeach +
+
+
+ +
+ @foreach ($entryTypes as $key => $type) +
+

{{ $type['label'] }}

+

{{ ucfirst($key) }}

+

+ @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 +

+
+ @endforeach
@endsection diff --git a/saas-app/resources/views/payments/index.blade.php b/saas-app/resources/views/payments/index.blade.php index f39181c..17a0180 100644 --- a/saas-app/resources/views/payments/index.blade.php +++ b/saas-app/resources/views/payments/index.blade.php @@ -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') -
-
-

Payments

-

Einzahlungen und Zahlungswege werden zu einem eigenen SaaS-Modul.

-

- 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. -

-
-
- PayPal optional - Finance workflow - Saldo-relevant +
+
+
+

Payments

+

Einzahlungen werden tenantfaehig verwaltet und fuer spaetere Abgleiche vorbereitet.

+

+ 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. +

+
+
+ Finance workflow + Tenant scoped + Reconciliation vorbereitet + Storno nur durch Verantwortliche +
+
+ {{ $paymentCount }} Einzahlungen + {{ number_format($paymentTotal, 2, ',', '.') }} EUR + Letzte Buchung: {{ $latestPayment ?? '-' }} + {{ $cancelableCount }} stornierbar +
+ +
-

Offene Zahlungen

-
6
-

Noch zu bestaetigende oder abzugleichende Vorgaege.

+

Gebuchte Einzahlungen

+
{{ $paymentCount }}
+

Manuelle Zahlungen und importierte Eintraege werden hier zusammengefuehrt.

-

Monatssumme

-
245 EUR
-

Gebuchte Einzahlungen im aktuellen Abrechnungszeitraum.

+

Saldo-Effekt

+
{{ number_format($paymentTotal, 2, ',', '.') }} EUR
+

Der Betrag wirkt unmittelbar auf den Mitgliedssaldo im Ledger.

-
-

Schnellwege

-
3
-

Bar, PayPal und spaetere Referenzimporte aus Drittsystemen.

-
-
+
+

Reconciliation-Status

+
{{ $reconciliationPendingCount }}
+

Eintraege sind bereits mit vorbereiteten Statusfeldern versehen.

+
+
-
+
@@ -55,42 +94,78 @@ Methode Betrag Status + Buchung - - Max Beispiel - PayPal - 10,00 EUR - Gebucht - - - Julia Betrieb - Bar - 5,00 EUR - Bestaetigt - - - Rene Muster - SEPA Import - 15,00 EUR - Pruefen - + @forelse ($journal as $entry) + + {{ $entry['member_name'] ?? '-' }} + {{ ucfirst((string) ($entry['payment_method'] ?? 'manual')) }} + {{ number_format((float) ($entry['amount'] ?? 0), 2, ',', '.') }} EUR + + @if (($entry['reconciliation_state'] ?? 'neu') === 'gematcht') + Gematcht + @elseif (($entry['reconciliation_state'] ?? 'neu') === 'pruefen') + Pruefen + @else + Stornierbar + @endif + + + @if (!empty($entry['cancellable'])) + Nur Verantwortliche + @else + Archiviert + @endif + + + @empty + + Noch keine Zahlungen vorhanden. + + @endforelse
-

Was aus dem Legacy-System bleibt

-
    -
  • Sammelerfassung Admins buchen mehrere Einzahlungen in einem Schritt.
  • -
  • Dashboard-Link Direkte Einzahlung oder Schuldenausgleich bleibt moeglich.
  • -
  • Ledger Sync Jede Einzahlung wirkt unmittelbar auf den Kontostand.
  • -
+

Reconciliation

+

Vorbereitung fuer spaetere Abgleiche

+
+
+

Statusmodell

+

{{ implode(', ', $reconciliation['states'] ?? $reconciliationStates) }}

+
+
+

Storno-Regel

+

{{ $reconciliation['responsible_rule'] ?? 'Einzahlungen koennen nur durch Verantwortliche storniert werden.' }}

+
+
+

MVP

+

{{ $reconciliation['message'] ?? 'Nur Konfiguration und manuelle Buchung, kein automatischer Mailbox-Import.' }}

+
+
+

Spaeter

+

Matching, externe Referenz und Klaerungsfaelle werden als Folgepaket ergaenzt.

+
+
- 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.
+ +
+ @foreach ($paymentMethods as $method) +
+

{{ $method['label'] }}

+

{{ $method['state'] }}

+

+ {{ $method['label'] }} bleibt tenantbezogen konfigurierbar und kann mit sichtbaren Pflichtfeldern versehen werden. +

+
+ @endforeach +
@endsection diff --git a/saas-app/resources/views/support/index.blade.php b/saas-app/resources/views/support/index.blade.php new file mode 100644 index 0000000..68f4723 --- /dev/null +++ b/saas-app/resources/views/support/index.blade.php @@ -0,0 +1,377 @@ + $pageData */ +?> + + + + + + Kaffeeliste SaaS - Support + + + +
+
+
+ Kaffeeliste Support + Vollstaendiges Vorgangssystem fuer Tenant-Anfragen und zentrale Rueckmeldungen. +
+ +
+ +
+

Support Requests

+

Anfragen sichtbar machen, bearbeiten und nachvollziehbar abschließen.

+

+ 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. +

+
+ + + +
+
+ + +
+ +
+ + + +
+ +
+ 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. +
+ + + +
+

Alle Vorgänge

Support-Anfragen im aktuellen Tenant.

+

Neu

Neue Vorgänge ohne Bearbeitung.

+

Warten auf Antwort

Vorgänge mit Rueckfrage an Mitglieder.

+

Erledigt

Abgeschlossene oder gelöste Anfragen.

+
+ +
+
+

+

Support-Vorgang erstellen

+
+ + + + + + + + + + +
+ +
+
+
+ +
+

Statusmodell

+

Bearbeitungslogik im Vorgang

+
+
+

Neu bis in Bearbeitung

+

Verantwortliche setzen Status, Routing und bei Bedarf eine Zuordnung.

+
+
+

Warten auf Rückmeldung

+

Mitglieder können auf offene Rückfragen antworten, ohne den Vorgang zu verlieren.

+
+
+

Lösen und schließen

+

Jede Statusänderung landet im Verlauf und bleibt nachvollziehbar.

+
+
+
+
+ +
+

+

+
+ + + + + + + + + + + + + + + 'is-new', + 'waiting_on_user' => 'is-waiting', + 'resolved', 'closed' => 'is-closed', + default => '', + }; + ?> + + + + + + + + + + + + + + + + +
NummerBetreffStatusRichtungRoutingLetzte AktivitätDialog
+ + , + Einträge
Noch keine Support-Vorgänge vorhanden.
+
+
+ +
+
+

Detailansicht

+

Ausgewählter Vorgang

+ +
+ + + +
+
+ + + +
+

Betreff:

+

Absender: ()

+

Richtung: , Kategorie:

+

Letzte Aktivität:

+
+ +
+ +
+

+ + · · +

+

+
+ + + + +
+
+ + +
+

Noch kein Verlauf

+

Sobald geantwortet oder aktualisiert wird, erscheint hier die Historie.

+
+ +
+ +

Wähle einen Vorgang aus der Liste, um Details, Verlauf und Bearbeitungsmöglichkeiten zu sehen.

+ +
+ +
+

Bearbeiten

+

Antworten und Statuswechsel

+ + + +
+ + + + + + +
+ +
+
+ +
+
+ + + + + +
+ +
+
+ +
+ + + + + +
+ +
+
+ +
+ 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. +
+ + +
+ Wähle zuerst einen Vorgang aus der Liste. Dann kannst du ihn im Detail bearbeiten oder beantworten. +
+ +
+
+ + + +
+ + diff --git a/saas-app/resources/views/surveys/index.blade.php b/saas-app/resources/views/surveys/index.blade.php index 2d95bef..4403b22 100644 --- a/saas-app/resources/views/surveys/index.blade.php +++ b/saas-app/resources/views/surveys/index.blade.php @@ -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') -
-
-

Optionales Modul

-

Umfragen bleiben moeglich, sind aber nicht mehr Teil des Pflichtkerns.

-

- 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. -

-
-
- Feature flag - Tenant scoped - Optional +
+
+
+

Tenant Survey Admin

+

Umfragen werden tenantfaehig verwaltet und als Snapshot veroeffentlicht.

+

+ 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. +

+
+ +
+ Tenant scoped + Snapshot publishing + Freigabe gesteuert +
+ +
-
-
-

Typische Einsatzfaelle

-
    -
  • Feedback Kurze Stimmungsbilder zu Kaffee, Preisen oder Ausstattung.
  • -
  • Organisation Abstimmungen zu Office-Regeln und Betriebsroutinen.
  • -
+
+
+
+
+

Administration

+

Aktive Entwuerfe und Freigabestufen

+
+ Draft -> Review -> Snapshot +
+
+ + + + + + + + + + + @forelse ($board['draft_surveys'] ?? [] as $survey) + + + + + + + @empty + + + + @endforelse + +
UmfrageFragenStatusHinweis
+ {{ $survey->title() }} +
ID {{ $survey->id() }}
+
{{ $survey->questionCount() }} + @if ($survey->isDraft()) + Entwurf + @elseif ($survey->status() === 'in_review') + Pruefung + @else + Freigegeben + @endif + + {{ $survey->isDraft() ? 'Kann live bearbeitet werden.' : 'Bereit fuer Snapshot und Freigabe.' }} +
Keine Umfragen vorhanden.
+
+
-

Produktentscheidung

-

- Das Modul ist vorbereitet, blockiert aber weder Migration noch Go-live. Aktiviert wird es nur, - wenn ein Mandant den Bedarf wirklich hat. -

+

Freigabe-Workflow

+
+ @foreach ($board['workflow'] ?? [] as $step) +
+

{{ $step['step'] ?? 'Schritt' }}

+

{{ $step['detail'] ?? '' }}

+
+ @endforeach +
- 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.
+ +
+
+
+
+

Veroeffentlicht

+

Snapshots fuer Mitglieder

+
+ Read only +
+
+ + + + + + + + + + + @forelse ($board['published_snapshots'] ?? [] as $snapshot) + + + + + + + @empty + + + + @endforelse + +
SnapshotStandAntwortenFreigabe
+ {{ $snapshot->surveyTitle() }} +
{{ $snapshot->versionLabel() }}
+
+
{{ $snapshot->publishedAt() }}
+
von {{ $snapshot->publishedBy() }}
+
{{ $snapshot->responseCount() }} + @if ($snapshot->memberVisible()) + Sichtbar + @else + Gesperrt + @endif +
Noch kein Snapshot veroeffentlicht.
+
+
+ +
+

Mitglieder-Sicht

+
+ @foreach ($memberBoard['participation'] ?? [] as $item) +
+
S
+
+

{{ $item }}

+
+
+ @endforeach +
+
+ Mitglieder sehen nur freigegebene Snapshots. Entwuerfe bleiben fuer die Fachseite bearbeitbar. +
+
+
+ +
+
+

Rollen Im Modul

+
+ @foreach ($board['roles'] ?? [] as $role) +
+
{{ strtoupper(substr((string) ($role['role'] ?? ''), 0, 1)) }}
+
+

{{ $role['role'] ?? '' }}

+

{{ $role['capability'] ?? '' }}

+
+
+ @endforeach +
+
+ +
+

Regeln Fuer Das Publishing

+
    + @foreach ($board['publishing_rules'] ?? [] as $rule) +
  • + Regel {{ $rule }} +
  • + @endforeach +
+
+
@endsection diff --git a/saas-app/resources/views/tenants/index.blade.php b/saas-app/resources/views/tenants/index.blade.php index 33cfd81..aa74455 100644 --- a/saas-app/resources/views/tenants/index.blade.php +++ b/saas-app/resources/views/tenants/index.blade.php @@ -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 @@
+ +
+
+
+

Rollenmodell

+

Tenant-Admin Vollzugriff plus Spezialrollen mit klarer Delegation.

+
+ Rollenansicht öffnen +
+
+ @foreach ($roleOverview['metrics'] as $metric) +
+

{{ $metric['label'] }}

+
{{ $metric['value'] }}
+

{{ $metric['detail'] }}

+
+ @endforeach +
+
+ + + + + + + + + + + @foreach ($roleOverview['roles'] as $role) + + + + + + + @endforeach + +
RolleBereichRechteDelegation
+ {{ $role['label'] }}
+ {{ $role['key'] }} +
{{ $role['scope'] }}{{ implode(', ', $role['rights']) }} + {{ $role['delegation'] }}
+ {{ $role['notes'] }} +
+
+
@endsection diff --git a/saas-app/resources/views/tenants/roles.blade.php b/saas-app/resources/views/tenants/roles.blade.php new file mode 100644 index 0000000..5cff44b --- /dev/null +++ b/saas-app/resources/views/tenants/roles.blade.php @@ -0,0 +1,248 @@ + $pageData */ +?> + + + + + + Kaffeeliste SaaS - Tenant Rollen + + + +
+
+
+ Kaffeeliste SaaS + Tenant Rollen, Rechte und Delegation an einem Ort. +
+
+ + Tenant-Admin Vollzugriff + Vier-Augen optional +
+
+ +
+
+

Tenant Rollenmodell

+

Die Plattform zeigt klar, wer was darf und wer es weitergeben kann.

+

+ 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. +

+ +
+ lokal + ADFS/OIDC + Rollenmatrix + Delegation +
+
+ +
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+

Rollenmatrix

+

Vollzugriff, Spezialrollen und Leserechte

+
+ tenant scoped +
+ + + + + + + + + + + + + + + + + +
RolleRechteDelegation
+
+ +
+
+ + + +
+
+
+ +
+
+ +
+

Delegation

+

Wer Rollen vergeben darf

+
+ +
+ +
+
+ Freigabe: +
+
+
+ +
+
+ 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. +
+
+
+ +
+
+
+
+

Tenant-Snapshots

+

Wie die Rollen in den Tenants aussehen

+
+ +
+ + + + + + + + + + + + + + + + + + + +
MandantAdminsSpezialrollenStatus
+
+ · +
+ + + + + +
+
+ +
+

Lesbare Sicht

+

Was Tenant-Admins direkt verstehen sollen

+
+ +
+ +
+ + + +
+
+ +
+
    + +
  • + +
+
+
+
+ + diff --git a/saas-app/routes/operations.php b/saas-app/routes/operations.php index fb0838a..e53e144 100644 --- a/saas-app/routes/operations.php +++ b/saas-app/routes/operations.php @@ -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',