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