feat(billing): Nutzerhinweise bei leerem Budget + Mandats-Mail (402/SSE)
Gateway - InsufficientBalanceException: billingModel, userAction (TOP_UP_SELF / CONTACT_MANDATE_ADMIN), DE/EN-Texte, toClientDict(), fromBalanceCheck() - HTTP 402 + JSON detail für globale API-Fehlerbehandlung - AI/Chatbot: vor Raise ggf. E-Mail an BillingSettings.notifyEmails (PREPAY_MANDATE, Throttle 1h/Mandat) via billingExhaustedNotify - Agent-Loop & Workspace-Route: SSE-ERROR mit strukturiertem Billing-Payload - datamodelBilling: notifyEmails-Doku für Pool-Alerts frontend_nyla - useWorkspace: SSE onError für INSUFFICIENT_BALANCE mit messageDe/En und Hinweis auf Billing-Pfad bei TOP_UP_SELF
This commit is contained in:
parent
735a6f3d3b
commit
9b99020686
36 changed files with 1522 additions and 400 deletions
227
docs/MONETARISIERUNG_FEATURES_INTERAKTIV.md
Normal file
227
docs/MONETARISIERUNG_FEATURES_INTERAKTIV.md
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
# Monetarisierung der Features — interaktives Klärungs-Playbook
|
||||||
|
|
||||||
|
Dieses Dokument ist für **Live-Workshops** gedacht (Product, Sales, Tech). Du kannst es in Cursor/VS Code oder GitHub öffnen: **Checkboxen** (`- [ ]`) und **leere Tabellenzellen** werden direkt im Editor abgehakt bzw. ausgefüllt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ausgangslage aus `frontend_nyla` (gemeinsames Bild)
|
||||||
|
|
||||||
|
Die App denkt in **Mandanten → Features → Instanzen → Rechte (Views)**. Nutzer haben keinen direkten „Mandanten-Login“, sondern **Zugriff auf Feature-Instanzen** (`featureStore`).
|
||||||
|
|
||||||
|
| Baustein | Bedeutung für Pricing |
|
||||||
|
|----------|------------------------|
|
||||||
|
| **Feature-Code** (z. B. `chatbot`, `workspace`) | logische Produktlinie / Modul |
|
||||||
|
| **Instanz** | oft Kunde, Abteilung, Bot, “Organisation” — **horizontale Skalierung** (mehr Instanzen = mehr Wert oder mehr Kosten) |
|
||||||
|
| **Views / Berechtigungen** | micro-Segmente im Produkt (**Add-ons**, Rollenpakete, „Light vs Pro“) |
|
||||||
|
| **Feature Store** (`Store.tsx`) | Self-Service-Aktivierung (z. B. `automation`, `teamsbot`) — Kandidaten für **Freemium / Trial / Upsell** |
|
||||||
|
| **Billing im Frontend** (`billingApi.ts`) | Modelle `PREPAY_MANDATE`, `PREPAY_USER`, `UNLIMITED`; Transaktionen mit u. a. `featureCode`, `featureInstanceId`, Provider/Modell |
|
||||||
|
|
||||||
|
**Leitidee:** Preis = *was* (Feature/View) × *wie viel* (Instanz, Nutzer, Volumen) × *wie abgerechnet* (Pauschale, Credits, Nachzahlung).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Schnell-Check: Welches Geschäftsmodell passt grob?
|
||||||
|
|
||||||
|
Arbeite die Fragen der Reihe nach durch und hake ab.
|
||||||
|
|
||||||
|
- [ ] **Primärer Käufer:** zahlt der **Mandant** (Firma), der **Endnutzer**, oder ein **Partner**?
|
||||||
|
- [ ] **Value Metric:** was korreliert am ehesten mit Kundennutzen? (z. B. Mandanten, Instanzen, aktive Nutzer, gespeicherte Dokumente, API-Calls, AI-Tokens/CHF-Verbrauch)
|
||||||
|
- [ ] **Margen-Risiko:** wo sind **variable Kosten** (LLM, Storage, Third-Party) — müssen die **durchgereicht** oder **gepuffert** werden?
|
||||||
|
- [ ] **Go-to-Market:** brauchst du **Selbstbedienung** (Store) oder nur **Sales / Onboarding** (Admin legt Instanzen an)?
|
||||||
|
- [ ] **Fairness:** soll ein Power-User **pro Nutzer** limitiert sein oder **Kontingent pro Mandant**?
|
||||||
|
|
||||||
|
### Entscheidungsbaum (Diskussionsvorlage)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Nutzerwert skaliert primär mit Volumen?] -->|Ja| B[Usage / Credits / Nachzahlung]
|
||||||
|
A -->|Nein| C[Festpreis / Seat / Instanz]
|
||||||
|
B --> D[Pro Feature oder globaler Credit-Pool?]
|
||||||
|
C --> E[Wer zählt als Seat: Login oder aktive Nutzung?]
|
||||||
|
D --> F[Sichtbar im Billing-Dashboard pro FeatureCode?]
|
||||||
|
E --> G[Wie viele Instanzen sind inkludiert?]
|
||||||
|
```
|
||||||
|
|
||||||
|
Trage Ergebnis hier ein (ein Satz):
|
||||||
|
|
||||||
|
> **Entscheidung Grobmodell:** …
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Billing-Modelle (Anknüpfung an `billingApi.ts`)
|
||||||
|
|
||||||
|
Ordnet euer **Angebot** den technisch vorhandenen **BillingModel**-Werten zu (Namen aus dem Frontend):
|
||||||
|
|
||||||
|
| `BillingModel` | Typische Kundenstory | Wann sinnvoll? |
|
||||||
|
|----------------|---------------------|----------------|
|
||||||
|
| `PREPAY_MANDATE` | „Wir laden ein Konto auf, alle ziehen daraus.“ | Gemeinsamer Pool, ein Rechnungsempfänger |
|
||||||
|
| `PREPAY_USER` | „Jeder Nutzer hat ein Kontingent.“ | faire Verteilung bei heterogenem Nutzungsverhalten |
|
||||||
|
| `UNLIMITED` | „Flat rate / Enterprise-Vertrag.“ | Paketpreis deckt erwarteten Mix |
|
||||||
|
|
||||||
|
**Workshop-Aufgabe**
|
||||||
|
|
||||||
|
- [ ] Standard für **SMB**:
|
||||||
|
- [ ] Standard für **Enterprise**:
|
||||||
|
- [ ] Ausnahme / Piloten:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Feature-Inventar (aus `FEATURE_REGISTRY`)
|
||||||
|
|
||||||
|
Die folgende Tabelle ist die **Checkliste pro Modul**. Pro Zeile: **was** verkaufen wir, **woran** messen wir, **was** ist Inhalt eines Pakets?
|
||||||
|
|
||||||
|
| `featureCode` | Produktname (DE) | Hauptnutzen (1 Satz) | Typische „Einheit“ für Preis (*Seat / Instanz / Request / GB / CHF-Verbrauch*) | Im **Feature Store** relevant? | Variable Kosten? (ja/nein/klein) | Notizen |
|
||||||
|
|---------------|------------------|----------------------|----------------------------------------------------------------------------------|-------------------------------|-----------------------------------|---------|
|
||||||
|
| `trustee` | Treuhand | | | ☐ ja ☐ nein | | |
|
||||||
|
| `realestate` | Immobilien | | | ☐ ja ☐ nein | | |
|
||||||
|
| `chatbot` | Chatbot | | | ☐ ja ☐ nein | | |
|
||||||
|
| `chatworkflow` | Workflow | | | ☐ ja ☐ nein | | |
|
||||||
|
| `automation` | Automatisierung | | | ☐ ja ☐ nein | | |
|
||||||
|
| `teamsbot` | Teams Bot | | | ☐ ja ☐ nein | | |
|
||||||
|
| `neutralization` | Neutralisierung | | | ☐ ja ☐ nein | | |
|
||||||
|
| `commcoach` | Kommunikations-Coach | | | ☐ ja ☐ nein | | |
|
||||||
|
| `workspace` | AI Workspace | | | ☐ ja ☐ nein | | |
|
||||||
|
|
||||||
|
> **Hinweis Store:** In `Store.tsx` sind derzeit u. a. `automation` und `teamsbot` als Self-Service beschrieben — weitere Features können folgen, sobald Backend/Policy das erlaubt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Views als Upsell-Stufen (fein granulare Monetarisierung)
|
||||||
|
|
||||||
|
Viele **Views** sind Kandidaten für „Basic / Pro“ oder Add-ons (technisch: Rechte pro View).
|
||||||
|
|
||||||
|
**Vorgehen pro Feature:** Liste die Views und markiere: **Base** (muss rein), **Upsell**, **Admin-only** (meist kein direkter Upsell).
|
||||||
|
|
||||||
|
### `trustee`
|
||||||
|
|
||||||
|
- [ ] `dashboard` — Base / Upsell / Admin-only
|
||||||
|
- [ ] `positions` — …
|
||||||
|
- [ ] `documents` — …
|
||||||
|
- [ ] `position-documents` — …
|
||||||
|
- [ ] `expense-import` — …
|
||||||
|
- [ ] `scan-upload` — …
|
||||||
|
- [ ] `instance-roles` (adminOnly) — …
|
||||||
|
- [ ] `settings` — …
|
||||||
|
|
||||||
|
### `chatbot`
|
||||||
|
|
||||||
|
- [ ] `conversations` — …
|
||||||
|
- [ ] `settings` — …
|
||||||
|
|
||||||
|
### `automation`
|
||||||
|
|
||||||
|
- [ ] `definitions` — …
|
||||||
|
- [ ] `templates` — …
|
||||||
|
- [ ] `logs` — …
|
||||||
|
|
||||||
|
### `teamsbot`
|
||||||
|
|
||||||
|
- [ ] `dashboard` — …
|
||||||
|
- [ ] `sessions` — …
|
||||||
|
- [ ] `settings` — …
|
||||||
|
|
||||||
|
### `neutralization`
|
||||||
|
|
||||||
|
- [ ] `playground` / `dashboard` — …
|
||||||
|
- [ ] `config` — …
|
||||||
|
- [ ] `attributes` — …
|
||||||
|
|
||||||
|
### `commcoach`
|
||||||
|
|
||||||
|
- [ ] `dashboard` — …
|
||||||
|
- [ ] `coaching` — …
|
||||||
|
- [ ] `dossier` — …
|
||||||
|
- [ ] `settings` — …
|
||||||
|
|
||||||
|
### `workspace`
|
||||||
|
|
||||||
|
- [ ] `dashboard` — …
|
||||||
|
- [ ] `editor` — …
|
||||||
|
- [ ] `settings` — …
|
||||||
|
|
||||||
|
### `realestate`
|
||||||
|
|
||||||
|
- [ ] `dashboard` — …
|
||||||
|
- [ ] `instance-roles` (adminOnly) — …
|
||||||
|
|
||||||
|
### `chatworkflow`
|
||||||
|
|
||||||
|
- [ ] `dashboard` — …
|
||||||
|
- [ ] `runs` — …
|
||||||
|
- [ ] `files` — …
|
||||||
|
|
||||||
|
**Paket-Entscheid (freies Feld):**
|
||||||
|
|
||||||
|
| Paketname | Enthaltene `featureCode`s | Enthaltene Views / Ausnahmen | Limits (Instanzen, Nutzer, Speicher, Credits) |
|
||||||
|
|-----------|---------------------------|------------------------------|-----------------------------------------------|
|
||||||
|
| Starter | | | |
|
||||||
|
| Business | | | |
|
||||||
|
| Enterprise | | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Nutzungsmessung vs. Produktversprechen
|
||||||
|
|
||||||
|
Transaktionen im Billing können unter anderem **`featureCode`**, **`featureInstanceId`**, **`aicoreProvider`**, **`aicoreModel`** tragen — gut für **trans-parente** oder **Feature-spezifische** Auswertung.
|
||||||
|
|
||||||
|
**Abgleich (interaktiv):**
|
||||||
|
|
||||||
|
- [ ] Welche Features sollen im Kunden-UI **getrennt** sichtbar sein (`costByFeature` im Usage-Report)?
|
||||||
|
- [ ] Welche Kosten werden **in einen Topf** gelegt (einfacheres Pricing, weniger Erklärungsbedarf)?
|
||||||
|
- [ ] Gibt es **überproportionale** Kostenfaktoren (z. B. Web-Recherche beim Chatbot), die ein **Aufsatz** oder **Limit** brauchen?
|
||||||
|
|
||||||
|
**Policy-Sätze (ausfüllen):**
|
||||||
|
|
||||||
|
1. Wenn Guthaben **`warningThreshold`** unterschreitet, dann: …
|
||||||
|
2. Wenn **`blockOnZeroBalance`** aktiv ist, dann welche Features blocken wir zuerst: …
|
||||||
|
3. **Trial:** welche Features sind zeitlich / volumenmäßig limitiert: …
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Angebots-Builder (1 Seite für Sales)
|
||||||
|
|
||||||
|
_Kopiere diesen Block pro Lead._
|
||||||
|
|
||||||
|
- **Kunde / Segment:** …
|
||||||
|
- **Mandanten-Setup:** # Instanzen geplant pro Feature: …
|
||||||
|
- **Nutzer / Rollen:** …
|
||||||
|
- **Inkludierte Module (`featureCode`):** …
|
||||||
|
- **Add-ons (Views):** …
|
||||||
|
- **BillingModel:** `PREPAY_MANDATE` / `PREPAY_USER` / `UNLIMITED`
|
||||||
|
- **Kontingente:** CHF/Monat, Tokens, API-Calls, Speicher: …
|
||||||
|
- **Preisgestaltung:** Listenpreis, Rabatt %, Laufzeit: …
|
||||||
|
- **Risiken / Sonderkosten (LLM, Integrationen):** …
|
||||||
|
|
||||||
|
**Einwand-Notizen:**
|
||||||
|
|
||||||
|
| Einwand | Antwort / Kompromiss |
|
||||||
|
|---------|----------------------|
|
||||||
|
| „Wir wollen unbegrenzt.“ | |
|
||||||
|
| „Wir haben viele Abteilungen (Instanzen).“ | |
|
||||||
|
| „Wir brauchen nur eine View.“ | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Definition of Done (Pricing ist „fertig“ besprochen)
|
||||||
|
|
||||||
|
- [ ] Jedes `featureCode` hat **Paket** + **Messgröße** + **Ausnahmen**
|
||||||
|
- [ ] Jede revenue-relevante View ist **Base/Upsell** zugeordnet
|
||||||
|
- [ ] Store-Features haben **Activation Policy** (wer darf, was passiert nach Trial)
|
||||||
|
- [ ] Billing-Model pro Segment ist festgelegt inkl. **Warn-/Block-Verhalten**
|
||||||
|
- [ ] Sales-Template (Abschnitt 7) ist befüllt für **Pilotkunde 1**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Referenz im Repo (für Technik-Abgleich)
|
||||||
|
|
||||||
|
| Thema | Datei |
|
||||||
|
|-------|--------|
|
||||||
|
| Feature-Definitionen (Codes, Views) | `src/types/mandate.ts` (`FEATURE_REGISTRY`) |
|
||||||
|
| Mandant → Instanzen → Rechte | `src/stores/featureStore.tsx` |
|
||||||
|
| Self-Service Store | `src/pages/Store.tsx`, `src/api/storeApi.ts` |
|
||||||
|
| Billing-Typen & Reports | `src/api/billingApi.ts` |
|
||||||
|
| UI-Komponenten / Routing-Helfer | `src/config/pageRegistry.tsx` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Version: 2026-03-20 — ausgerichtet auf den Stand von `frontend_nyla`; bei neuen Features die Tabellen in Abschnitt 4–5 ergänzen.*
|
||||||
68
docs/MONETARISIERUNG_KURZ_PRAESENTATION.md
Normal file
68
docs/MONETARISIERUNG_KURZ_PRAESENTATION.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Monetarisierung — Kurzfassung (Präsentation)
|
||||||
|
|
||||||
|
*Technische Einordnung: Plattform `frontend_nyla` — Mandat → Feature → **Instanz** → **Views** (Rechte). Billing-Typen aus `billingApi.ts`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Folie 1 · Kernthese
|
||||||
|
|
||||||
|
**Wir verkaufen Module (`featureCode`) und deren Ausprägung: wie viele Instanzen, welche Views, wie viel Verbrauch.**
|
||||||
|
|
||||||
|
Preislogik: **Was** × **Wie viel** × **Abrechnungsmodell** (Pauschale, Credits, Nachzahlung, Flat).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Folie 2 · Drei Hebel für das Angebot
|
||||||
|
|
||||||
|
| Hebel | Bedeutung |
|
||||||
|
|--------|-----------|
|
||||||
|
| **Modul** | z. B. `chatbot`, `workspace`, `trustee`, `automation`, … |
|
||||||
|
| **Instanz** | Skalierung pro Mandat (Bots, Organisationen, Teams, …) |
|
||||||
|
| **View / Rolle** | „Light vs Pro“, Add-ons (fein granular über Berechtigungen) |
|
||||||
|
|
||||||
|
**Self-Service:** Feature Store (`automation`, `teamsbot`, …) → Trial, Freemium, Upsell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Folie 3 · Abrechnung (Systemkonzept)
|
||||||
|
|
||||||
|
| Modell | Kunde versteht es so |
|
||||||
|
|--------|----------------------|
|
||||||
|
| **PREPAY_MANDATE** | Ein Guthaben-Topf für die ganze Organisation |
|
||||||
|
| **PREPAY_USER** | Kontingent pro Nutzer |
|
||||||
|
| **UNLIMITED** | Paket / Enterprise-Flat |
|
||||||
|
|
||||||
|
Transparenz: Verbrauch lässt sich nach **Feature**, **Instanz**, **Provider/Modell** auswerten (technische Basis im Billing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Folie 4 · Produktportfolio (Überblick)
|
||||||
|
|
||||||
|
| Modul | Fokus |
|
||||||
|
|--------|--------|
|
||||||
|
| Treuhand (`trustee`) | Dokumente, Positionen, Import/Scan, Buchhaltung |
|
||||||
|
| Immobilien (`realestate`) | Karte / Mandantenfähigkeit |
|
||||||
|
| Chatbot (`chatbot`) | Konversationen, Konfiguration |
|
||||||
|
| Workflow (`chatworkflow`) | Überblicke, Runs, Dateien |
|
||||||
|
| Automatisierung (`automation`) | Definitionen, Vorlagen, Logs |
|
||||||
|
| Teams Bot (`teamsbot`) | Dashboard, Sessions, Settings |
|
||||||
|
| Neutralisierung (`neutralization`) | Playground, Config, Attribute |
|
||||||
|
| CommCoach (`commcoach`) | Dashboard, Coaching, Dossier |
|
||||||
|
| AI Workspace (`workspace`) | Dashboard, Editor, Settings |
|
||||||
|
|
||||||
|
*Details & Workshop-Checklisten: siehe `MONETARISIERUNG_FEATURES_INTERAKTIV.md`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Folie 5 · Entscheidungen (noch zu füllen)
|
||||||
|
|
||||||
|
1. **Segment:** SMB-Paket vs. Enterprise — Standard-Billingmodell?
|
||||||
|
2. **Messgröße:** Seats, Instanzen, CHF-Verbrauch, hybride Limits?
|
||||||
|
3. **Transparenz:** getrennte Kosten pro Feature oder ein Gesamtpool?
|
||||||
|
4. **Store:** welche Module dürfen selbst aktiviert werden — mit welchem Trial?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Folie 6 · Nächster Schritt
|
||||||
|
|
||||||
|
Paketraster **Starter / Business / Enterprise** mit festen Inklusiv-Features + klaren **Überlaufregeln** (Warnung, Sperre, Nachkauf).
|
||||||
|
|
@ -41,7 +41,7 @@ import { FeatureViewPage } from './pages/FeatureView';
|
||||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin';
|
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin';
|
||||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView } from './pages/billing';
|
||||||
function App() {
|
function App() {
|
||||||
// Load saved theme preference and set app name on app mount
|
// Load saved theme preference and set app name on app mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -185,7 +185,10 @@ function App() {
|
||||||
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
|
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
|
||||||
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
||||||
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
|
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
|
||||||
<Route path="billing" element={<BillingAdmin />} />
|
<Route path="billing">
|
||||||
|
<Route index element={<BillingAdmin />} />
|
||||||
|
<Route path="mandates" element={<BillingMandateView />} />
|
||||||
|
</Route>
|
||||||
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,8 @@ api.interceptors.response.use(
|
||||||
// Don't redirect to login if the request was to a login endpoint
|
// Don't redirect to login if the request was to a login endpoint
|
||||||
const isLoginEndpoint = error.config?.url?.includes('/login') ||
|
const isLoginEndpoint = error.config?.url?.includes('/login') ||
|
||||||
error.config?.url?.includes('/api/local/login') ||
|
error.config?.url?.includes('/api/local/login') ||
|
||||||
error.config?.url?.includes('/api/msft/login');
|
error.config?.url?.includes('/api/msft/auth/login') ||
|
||||||
|
error.config?.url?.includes('/api/google/auth/login');
|
||||||
|
|
||||||
// Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work)
|
// Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work)
|
||||||
const pathname = window.location.pathname;
|
const pathname = window.location.pathname;
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,10 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
// TYPES & INTERFACES
|
// TYPES & INTERFACES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER' | 'CREDIT_POSTPAY' | 'UNLIMITED';
|
export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER';
|
||||||
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
|
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
|
||||||
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
|
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
|
||||||
|
|
||||||
export interface BillingAddress {
|
|
||||||
company: string;
|
|
||||||
street: string;
|
|
||||||
zip: string;
|
|
||||||
city: string;
|
|
||||||
country: string;
|
|
||||||
vatNumber?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BillingBalance {
|
export interface BillingBalance {
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
mandateName: string;
|
mandateName: string;
|
||||||
|
|
@ -25,7 +16,6 @@ export interface BillingBalance {
|
||||||
currency: string;
|
currency: string;
|
||||||
warningThreshold: number;
|
warningThreshold: number;
|
||||||
isWarning: boolean;
|
isWarning: boolean;
|
||||||
creditLimit?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BillingTransaction {
|
export interface BillingTransaction {
|
||||||
|
|
@ -54,20 +44,16 @@ export interface BillingSettings {
|
||||||
billingModel: BillingModel;
|
billingModel: BillingModel;
|
||||||
defaultUserCredit: number;
|
defaultUserCredit: number;
|
||||||
warningThresholdPercent: number;
|
warningThresholdPercent: number;
|
||||||
blockOnZeroBalance: boolean;
|
|
||||||
notifyOnWarning: boolean;
|
notifyOnWarning: boolean;
|
||||||
notifyEmails: string[];
|
notifyEmails: string[];
|
||||||
billingAddress?: BillingAddress;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BillingSettingsUpdate {
|
export interface BillingSettingsUpdate {
|
||||||
billingModel?: BillingModel;
|
billingModel?: BillingModel;
|
||||||
defaultUserCredit?: number;
|
defaultUserCredit?: number;
|
||||||
warningThresholdPercent?: number;
|
warningThresholdPercent?: number;
|
||||||
blockOnZeroBalance?: boolean;
|
|
||||||
notifyOnWarning?: boolean;
|
notifyOnWarning?: boolean;
|
||||||
notifyEmails?: string[];
|
notifyEmails?: string[];
|
||||||
billingAddress?: BillingAddress;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageReport {
|
export interface UsageReport {
|
||||||
|
|
@ -85,7 +71,6 @@ export interface AccountSummary {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
accountType: string;
|
accountType: string;
|
||||||
balance: number;
|
balance: number;
|
||||||
creditLimit?: number;
|
|
||||||
warningThreshold: number;
|
warningThreshold: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -325,7 +310,6 @@ export interface MandateBalance {
|
||||||
userCount: number;
|
userCount: number;
|
||||||
defaultUserCredit: number;
|
defaultUserCredit: number;
|
||||||
warningThresholdPercent: number;
|
warningThresholdPercent: number;
|
||||||
blockOnZeroBalance: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ export async function fetchCurrentUser(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout current user
|
* Logout current user
|
||||||
* Endpoint: POST /api/local/logout | /api/msft/logout
|
* Endpoint: POST /api/local/logout | /api/msft/logout | /api/google/logout
|
||||||
*/
|
*/
|
||||||
export async function logoutUser(
|
export async function logoutUser(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
|
|
@ -112,6 +112,8 @@ export async function logoutUser(
|
||||||
|
|
||||||
if (authAuthority === 'msft') {
|
if (authAuthority === 'msft') {
|
||||||
endpoint = '/api/msft/logout';
|
endpoint = '/api/msft/logout';
|
||||||
|
} else if (authAuthority === 'google') {
|
||||||
|
endpoint = '/api/google/logout';
|
||||||
}
|
}
|
||||||
|
|
||||||
await request({
|
await request({
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,18 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Number/float fields: avoid empty inputs — use attribute default or type default (e.g. 0)
|
||||||
|
filteredAttrs.forEach(attr => {
|
||||||
|
if (isNumberType(attr.type as AttributeType)) {
|
||||||
|
const v = processedData[attr.name];
|
||||||
|
if (v === undefined || v === null || v === '') {
|
||||||
|
processedData[attr.name] =
|
||||||
|
attr.default !== undefined
|
||||||
|
? attr.default
|
||||||
|
: getDefaultValueForType(attr.type as AttributeType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
setFormData(processedData as T);
|
setFormData(processedData as T);
|
||||||
} else {
|
} else {
|
||||||
const filteredAttrs = getFilteredAttributes();
|
const filteredAttrs = getFilteredAttributes();
|
||||||
|
|
@ -967,9 +979,15 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
const inputType = attributeTypeToInputType(attr.type);
|
const inputType = attributeTypeToInputType(attr.type);
|
||||||
|
|
||||||
// For timestamp fields, convert Unix timestamp (float) to datetime-local format for display
|
// For timestamp fields, convert Unix timestamp (float) to datetime-local format for display
|
||||||
const displayValue = attr.type === 'timestamp'
|
// Number: must not use (value || '') — 0 is valid and would show empty
|
||||||
|
const displayValue =
|
||||||
|
attr.type === 'timestamp'
|
||||||
? timestampToDatetimeLocal(value)
|
? timestampToDatetimeLocal(value)
|
||||||
: (value || '');
|
: isNumberType(attr.type as AttributeType)
|
||||||
|
? value === '' || value === undefined || value === null || (typeof value === 'number' && Number.isNaN(value))
|
||||||
|
? ''
|
||||||
|
: String(value)
|
||||||
|
: value || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.floatingLabelInput} key={attr.name}>
|
<div className={styles.floatingLabelInput} key={attr.name}>
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,11 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useCurrentUser } from '../../hooks/useUsers';
|
import { useCurrentUser } from '../../hooks/useUsers';
|
||||||
import { useMsal } from '@azure/msal-react';
|
|
||||||
import { NotificationBell } from '../NotificationBell';
|
import { NotificationBell } from '../NotificationBell';
|
||||||
import styles from './UserSection.module.css';
|
import styles from './UserSection.module.css';
|
||||||
|
|
||||||
export const UserSection: React.FC = () => {
|
export const UserSection: React.FC = () => {
|
||||||
const { user, logout } = useCurrentUser();
|
const { user, logout } = useCurrentUser();
|
||||||
const { instance: msalInstance } = useMsal();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
|
@ -22,7 +20,7 @@ export const UserSection: React.FC = () => {
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
try {
|
try {
|
||||||
await logout(msalInstance);
|
await logout();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout failed:', error);
|
console.error('Logout failed:', error);
|
||||||
setIsLoggingOut(false);
|
setIsLoggingOut(false);
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ export function useMsalAuth() {
|
||||||
|
|
||||||
// Open popup window
|
// Open popup window
|
||||||
const popup = window.open(
|
const popup = window.open(
|
||||||
`${backendUrl}/api/msft/login?state=login`,
|
`${backendUrl}/api/msft/auth/login`,
|
||||||
'microsoft-login',
|
'microsoft-login',
|
||||||
'width=600,height=700,left=100,top=100'
|
'width=600,height=700,left=100,top=100'
|
||||||
);
|
);
|
||||||
|
|
@ -301,7 +301,7 @@ export function useGoogleAuth() {
|
||||||
|
|
||||||
// Open popup window
|
// Open popup window
|
||||||
const popup = window.open(
|
const popup = window.open(
|
||||||
`${backendUrl}/api/google/login?state=login`,
|
`${backendUrl}/api/google/auth/login`,
|
||||||
'google-login',
|
'google-login',
|
||||||
'width=600,height=700,left=100,top=100'
|
'width=600,height=700,left=100,top=100'
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -169,59 +169,6 @@ export function useBillingAdmin(mandateId?: string) {
|
||||||
}
|
}
|
||||||
}, [request, mandateId]);
|
}, [request, mandateId]);
|
||||||
|
|
||||||
// Update settings
|
|
||||||
const saveSettings = useCallback(async (
|
|
||||||
settingsUpdate: BillingSettingsUpdate,
|
|
||||||
targetMandateId?: string
|
|
||||||
) => {
|
|
||||||
const mId = targetMandateId || mandateId;
|
|
||||||
if (!mId) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await updateSettingsAdmin(request, mId, settingsUpdate);
|
|
||||||
setSettings(data);
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error saving billing settings:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, [request, mandateId]);
|
|
||||||
|
|
||||||
// Add credit (manual, admin)
|
|
||||||
const addCredit = useCallback(async (
|
|
||||||
creditRequest: CreditAddRequest,
|
|
||||||
targetMandateId?: string
|
|
||||||
) => {
|
|
||||||
const mId = targetMandateId || mandateId;
|
|
||||||
if (!mId) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await addCreditAdmin(request, mId, creditRequest);
|
|
||||||
// Reload accounts after adding credit
|
|
||||||
await loadAccounts(mId);
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error adding credit:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, [request, mandateId]);
|
|
||||||
|
|
||||||
// Create Stripe Checkout Session (returns redirect URL)
|
|
||||||
const createCheckout = useCallback(async (
|
|
||||||
checkoutRequest: CheckoutCreateRequest,
|
|
||||||
targetMandateId?: string
|
|
||||||
) => {
|
|
||||||
const mId = targetMandateId || mandateId;
|
|
||||||
if (!mId) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await createCheckoutSessionApi(request, mId, checkoutRequest);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error creating checkout session:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, [request, mandateId]);
|
|
||||||
|
|
||||||
// Fetch accounts for a mandate
|
// Fetch accounts for a mandate
|
||||||
const loadAccounts = useCallback(async (targetMandateId?: string) => {
|
const loadAccounts = useCallback(async (targetMandateId?: string) => {
|
||||||
const mId = targetMandateId || mandateId;
|
const mId = targetMandateId || mandateId;
|
||||||
|
|
@ -270,6 +217,70 @@ export function useBillingAdmin(mandateId?: string) {
|
||||||
}
|
}
|
||||||
}, [request, mandateId]);
|
}, [request, mandateId]);
|
||||||
|
|
||||||
|
// Update settings — after billing model change, reload dependent data (accounts / users / tx)
|
||||||
|
const saveSettings = useCallback(
|
||||||
|
async (settingsUpdate: BillingSettingsUpdate, targetMandateId?: string) => {
|
||||||
|
const mId = targetMandateId || mandateId;
|
||||||
|
if (!mId) return null;
|
||||||
|
|
||||||
|
const previousModel = settings?.billingModel;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await updateSettingsAdmin(request, mId, settingsUpdate);
|
||||||
|
setSettings(data);
|
||||||
|
const newModel = settingsUpdate.billingModel;
|
||||||
|
const modelChanged =
|
||||||
|
newModel !== undefined && newModel !== null && newModel !== previousModel;
|
||||||
|
if (modelChanged) {
|
||||||
|
await Promise.all([
|
||||||
|
loadAccounts(mId),
|
||||||
|
loadTransactions(mId, 100),
|
||||||
|
loadUsers(mId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving billing settings:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[request, mandateId, settings?.billingModel, loadAccounts, loadTransactions, loadUsers]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add credit (manual, admin)
|
||||||
|
const addCredit = useCallback(
|
||||||
|
async (creditRequest: CreditAddRequest, targetMandateId?: string) => {
|
||||||
|
const mId = targetMandateId || mandateId;
|
||||||
|
if (!mId) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await addCreditAdmin(request, mId, creditRequest);
|
||||||
|
await loadAccounts(mId);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding credit:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[request, mandateId, loadAccounts]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create Stripe Checkout Session (returns redirect URL)
|
||||||
|
const createCheckout = useCallback(
|
||||||
|
async (checkoutRequest: CheckoutCreateRequest, targetMandateId?: string) => {
|
||||||
|
const mId = targetMandateId || mandateId;
|
||||||
|
if (!mId) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await createCheckoutSessionApi(request, mId, checkoutRequest);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating checkout session:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[request, mandateId]
|
||||||
|
);
|
||||||
|
|
||||||
// Load data when mandateId changes
|
// Load data when mandateId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mandateId) {
|
if (mandateId) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Folgt dem gleichen Pattern wie useOrgUsers.
|
* Folgt dem gleichen Pattern wie useOrgUsers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useApiRequest } from './useApi';
|
import { useApiRequest } from './useApi';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { usePermissions, type UserPermissions } from './usePermissions';
|
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||||
|
|
@ -19,6 +19,8 @@ import {
|
||||||
type MandateUpdateData,
|
type MandateUpdateData,
|
||||||
type PaginationParams
|
type PaginationParams
|
||||||
} from '../api/mandateApi';
|
} from '../api/mandateApi';
|
||||||
|
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
|
||||||
|
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type { Mandate, MandateUpdateData, PaginationParams };
|
export type { Mandate, MandateUpdateData, PaginationParams };
|
||||||
|
|
@ -164,14 +166,14 @@ export function useAdminMandates() {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create mandate
|
// Create mandate
|
||||||
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<boolean> => {
|
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => {
|
||||||
try {
|
try {
|
||||||
await createMandateApi(request, mandateData);
|
const created = await createMandateApi(request, mandateData);
|
||||||
await fetchMandates();
|
await fetchMandates();
|
||||||
return true;
|
return created ?? null;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error creating mandate:', error);
|
console.error('Error creating mandate:', error);
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}, [request, fetchMandates]);
|
}, [request, fetchMandates]);
|
||||||
|
|
||||||
|
|
@ -235,3 +237,80 @@ export function useAdminMandates() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useAdminMandates;
|
export default useAdminMandates;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mandate model attributes for FormGenerator (create/edit) — shared by Admin page and wizard.
|
||||||
|
*/
|
||||||
|
export function useMandateFormAttributes() {
|
||||||
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/attributes/Mandate');
|
||||||
|
let attrs: AttributeDefinition[] = [];
|
||||||
|
const data = response.data;
|
||||||
|
if (data?.attributes && Array.isArray(data.attributes)) {
|
||||||
|
attrs = data.attributes;
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
attrs = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
for (const key of Object.keys(data)) {
|
||||||
|
if (Array.isArray((data as Record<string, unknown>)[key])) {
|
||||||
|
attrs = (data as Record<string, AttributeDefinition[]>)[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAttributes(attrs);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Attribute-Laden fehlgeschlagen';
|
||||||
|
setError(msg);
|
||||||
|
setAttributes([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const formAttributes: FormGenAttr[] = useMemo(() => {
|
||||||
|
return attributes
|
||||||
|
.filter(attr => attr.name !== 'id')
|
||||||
|
.map(attr => ({ ...attr, type: attr.type })) as FormGenAttr[];
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
const createFormAttributes: FormGenAttr[] = useMemo(
|
||||||
|
() => formAttributes.filter(attr => attr.name !== 'isSystem'),
|
||||||
|
[formAttributes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const billingFormAttributes: FormGenAttr[] = useMemo(() => getMandateBillingFormAttributes(), []);
|
||||||
|
|
||||||
|
/** Mandate attributes + billing (Abrechnung) for SysAdmin create flows */
|
||||||
|
const createFormAttributesWithBilling: FormGenAttr[] = useMemo(
|
||||||
|
() => [...createFormAttributes, ...billingFormAttributes],
|
||||||
|
[createFormAttributes, billingFormAttributes]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Mandate attributes + billing for SysAdmin edit flows */
|
||||||
|
const formAttributesWithBilling: FormGenAttr[] = useMemo(
|
||||||
|
() => [...formAttributes, ...billingFormAttributes],
|
||||||
|
[formAttributes, billingFormAttributes]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
formAttributes,
|
||||||
|
createFormAttributes,
|
||||||
|
formAttributesWithBilling,
|
||||||
|
createFormAttributesWithBilling,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: load,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
|
const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']);
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface NotificationAction {
|
export interface NotificationAction {
|
||||||
actionId: string;
|
actionId: string;
|
||||||
|
|
@ -51,6 +53,7 @@ export function useNotifications() {
|
||||||
|
|
||||||
// Polling interval ref
|
// Polling interval ref
|
||||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const prevUnreadCountRef = useRef<number | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all notifications for the current user
|
* Fetch all notifications for the current user
|
||||||
|
|
@ -90,6 +93,26 @@ export function useNotifications() {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/api/notifications/unread-count');
|
const response = await api.get('/api/notifications/unread-count');
|
||||||
const count = response.data.count;
|
const count = response.data.count;
|
||||||
|
const prev = prevUnreadCountRef.current;
|
||||||
|
prevUnreadCountRef.current = count;
|
||||||
|
|
||||||
|
if (prev !== null && count > prev) {
|
||||||
|
try {
|
||||||
|
const listRes = await api.get('/api/notifications', {
|
||||||
|
params: { status: 'unread', limit: 25 },
|
||||||
|
});
|
||||||
|
const list = listRes.data as UserNotification[];
|
||||||
|
if (
|
||||||
|
Array.isArray(list) &&
|
||||||
|
list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType))
|
||||||
|
) {
|
||||||
|
window.dispatchEvent(new Event('features-changed'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setUnreadCount(count);
|
setUnreadCount(count);
|
||||||
return count;
|
return count;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ export function useCurrentUser() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = async (msalInstance?: any) => {
|
const logout = async () => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('No user to logout');
|
throw new Error('No user to logout');
|
||||||
}
|
}
|
||||||
|
|
@ -160,8 +160,7 @@ export function useCurrentUser() {
|
||||||
// Clear user state after successful logout
|
// Clear user state after successful logout
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
|
||||||
// CRITICAL: Clear all authentication data BEFORE any redirects
|
// Clear client-side auth hints; gateway session ended via API (cookies cleared by backend).
|
||||||
// This ensures cleanup happens even if MSAL redirect interrupts the process
|
|
||||||
console.log('🧹 Starting comprehensive cleanup...');
|
console.log('🧹 Starting comprehensive cleanup...');
|
||||||
|
|
||||||
// Clear user data cache from sessionStorage
|
// Clear user data cache from sessionStorage
|
||||||
|
|
@ -170,42 +169,14 @@ export function useCurrentUser() {
|
||||||
// Clear auth authority from sessionStorage
|
// Clear auth authority from sessionStorage
|
||||||
sessionStorage.removeItem('auth_authority');
|
sessionStorage.removeItem('auth_authority');
|
||||||
|
|
||||||
// Clear MSAL cache tokens from localStorage
|
// Optional: clear MSAL browser cache only (PowerOn JWT lives in httpOnly cookies + backend).
|
||||||
// MSAL stores tokens with keys starting with 'msal.'
|
// Do not call msal.logoutRedirect — that signs the user out of Microsoft globally.
|
||||||
const keysToRemove = [];
|
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (key && (
|
|
||||||
key.startsWith('msal.') ||
|
|
||||||
key === 'auth_token' ||
|
|
||||||
key === 'refresh_token' ||
|
|
||||||
key.includes('token') ||
|
|
||||||
key.includes('auth') ||
|
|
||||||
key.includes('msal')
|
|
||||||
)) {
|
|
||||||
keysToRemove.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keysToRemove.forEach(key => {
|
|
||||||
console.log('🗑️ Removing token:', key);
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear ALL MSAL cache data (including account keys, token keys, version)
|
|
||||||
const msalKeysToRemove = [];
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
const key = localStorage.key(i);
|
||||||
if (key && key.startsWith('msal.')) {
|
if (key && key.startsWith('msal.')) {
|
||||||
msalKeysToRemove.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msalKeysToRemove.forEach(key => {
|
|
||||||
console.log('🗑️ Removing MSAL cache:', key);
|
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
// Clear sessionStorage as well (CSRF tokens, etc.)
|
|
||||||
sessionStorage.clear();
|
|
||||||
|
|
||||||
// Clear cookies as backup (in case backend doesn't clear them properly)
|
// Clear cookies as backup (in case backend doesn't clear them properly)
|
||||||
// Note: This only works for cookies that are accessible to JavaScript
|
// Note: This only works for cookies that are accessible to JavaScript
|
||||||
|
|
@ -229,23 +200,6 @@ export function useCurrentUser() {
|
||||||
|
|
||||||
console.log('✅ Cleanup completed');
|
console.log('✅ Cleanup completed');
|
||||||
|
|
||||||
// Handle MSAL logout for Microsoft authentication
|
|
||||||
if (user.authenticationAuthority === 'msft' && msalInstance) {
|
|
||||||
try {
|
|
||||||
console.log('🔄 Starting MSAL logout redirect...');
|
|
||||||
await msalInstance.logoutRedirect({
|
|
||||||
onRedirectNavigate: () => {
|
|
||||||
console.log('🔄 MSAL redirect initiated - cleanup already completed');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return; // MSAL will handle the redirect
|
|
||||||
} catch (msalError) {
|
|
||||||
console.error('MSAL logout failed:', msalError);
|
|
||||||
// Continue with regular redirect if MSAL logout fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to login or home page
|
// Redirect to login or home page
|
||||||
console.log('🔄 Redirecting to login page...');
|
console.log('🔄 Redirecting to login page...');
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
|
|
|
||||||
|
|
@ -94,11 +94,24 @@
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
--mobile-topbar-height: 0px;
|
--mobile-topbar-height: 0px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
/* Let child components handle their own scrolling for sticky headers */
|
/* Let child components handle their own scrolling for sticky headers */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-primary, #ffffff);
|
background: var(--bg-primary, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fills .content flex column so admin pages get a bounded height for inner scroll */
|
||||||
|
.outletShell {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mobileTopBar {
|
.mobileTopBar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,10 @@ const MainLayoutInner: React.FC = () => {
|
||||||
|
|
||||||
<WorkspaceKeepAlive isVisible={_WORKSPACE_ROUTE_RE.test(location.pathname)} />
|
<WorkspaceKeepAlive isVisible={_WORKSPACE_ROUTE_RE.test(location.pathname)} />
|
||||||
|
|
||||||
<div style={{ display: _WORKSPACE_ROUTE_RE.test(location.pathname) ? 'none' : 'contents' }}>
|
<div
|
||||||
|
className={styles.outletShell}
|
||||||
|
style={{ display: _WORKSPACE_ROUTE_RE.test(location.pathname) ? 'none' : undefined }}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, Navigate } from 'react-router-dom';
|
||||||
import useNavigation from '../hooks/useNavigation';
|
import useNavigation from '../hooks/useNavigation';
|
||||||
import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation';
|
import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation';
|
||||||
import { getPageIcon } from '../config/pageRegistry';
|
import { getPageIcon } from '../config/pageRegistry';
|
||||||
|
|
@ -47,19 +47,6 @@ const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// EMPTY STATE
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const EmptyState: React.FC = () => (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<div className={styles.emptyIcon}>📋</div>
|
|
||||||
<h2>Willkommen bei PowerOn</h2>
|
|
||||||
<p>Du hast aktuell Zugriff auf keine Feature-Instanzen.</p>
|
|
||||||
<p>Kontaktiere einen Administrator, um Zugriff zu erhalten.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// DASHBOARD PAGE
|
// DASHBOARD PAGE
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -89,7 +76,7 @@ export const DashboardPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalInstances === 0) {
|
if (totalInstances === 0) {
|
||||||
return <EmptyState />;
|
return <Navigate to="/store" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FaCogs, FaHeadset } from 'react-icons/fa';
|
import { FaCogs, FaComments, FaHeadset } from 'react-icons/fa';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useStore } from '../hooks/useStore';
|
import { useStore } from '../hooks/useStore';
|
||||||
import type { StoreFeature } from '../api/storeApi';
|
import type { StoreFeature } from '../api/storeApi';
|
||||||
|
|
@ -16,6 +16,8 @@ import styles from './Store.module.css';
|
||||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||||
automation: <FaCogs />,
|
automation: <FaCogs />,
|
||||||
teamsbot: <FaHeadset />,
|
teamsbot: <FaHeadset />,
|
||||||
|
workspace: <FaComments />,
|
||||||
|
commcoach: <FaComments />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = {
|
const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = {
|
||||||
|
|
@ -29,6 +31,16 @@ const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = {
|
||||||
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
|
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
|
||||||
fr: 'Integrez un bot IA dans vos reunions et canaux Microsoft Teams.',
|
fr: 'Integrez un bot IA dans vos reunions et canaux Microsoft Teams.',
|
||||||
},
|
},
|
||||||
|
workspace: {
|
||||||
|
de: 'Nutze den gemeinsamen AI Workspace: Chats, Tools und Kontext pro Instanz.',
|
||||||
|
en: 'Use the shared AI workspace: chats, tools, and context per instance.',
|
||||||
|
fr: 'Utilisez l\'espace de travail IA partage: chats, outils et contexte par instance.',
|
||||||
|
},
|
||||||
|
commcoach: {
|
||||||
|
de: 'CommCoach: Kommunikation trainieren mit KI-gestütztem Coaching und Feedback.',
|
||||||
|
en: 'CommCoach: practice communication with AI-assisted coaching and feedback.',
|
||||||
|
fr: 'CommCoach: entrainer la communication avec un coaching assiste par IA.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function _getLabel(labels: Record<string, string>, lang: string): string {
|
function _getLabel(labels: Record<string, string>, lang: string): string {
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,12 @@ export const AccessManagementHub: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFeatures();
|
fetchFeatures();
|
||||||
fetchMandates().then(setMandates);
|
fetchMandates().then(data => {
|
||||||
|
setMandates(data);
|
||||||
|
if (data.length > 0 && !selectedMandateId) {
|
||||||
|
setSelectedMandateId(data[0].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
}, [fetchFeatures, fetchMandates]);
|
}, [fetchFeatures, fetchMandates]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,22 @@
|
||||||
|
|
||||||
.adminPage {
|
.adminPage {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
/* Fill parent height and enable flex layout for sticky table headers */
|
/* Default: grow with content → scroll on MainLayout .outletShell (expandable panels, long pages). */
|
||||||
height: 100%;
|
flex: 0 0 auto;
|
||||||
max-height: 100%;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FormGeneratorTable expects a bounded height chain (height:100% / flex:1).
|
||||||
|
* With default .adminPage (flex:0 0 auto), .tableContainer flex:1 collapses → empty table.
|
||||||
|
* Use together: className={`${styles.adminPage} ${styles.adminPageFill}`}
|
||||||
|
*/
|
||||||
|
.adminPage.adminPageFill {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,6 +31,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ export const AdminAutomationEventsPage: React.FC = () => {
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Automation Events</h1>
|
<h1 className={styles.pageTitle}>Automation Events</h1>
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,12 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
// Load features, mandates, and attributes on mount
|
// Load features, mandates, and attributes on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFeatures();
|
fetchFeatures();
|
||||||
fetchMandates().then(setMandates);
|
fetchMandates().then(data => {
|
||||||
|
setMandates(data);
|
||||||
|
if (data.length > 0 && !selectedMandateId) {
|
||||||
|
setSelectedMandateId(data[0].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
// Fetch FeatureInstance attributes from backend
|
// Fetch FeatureInstance attributes from backend
|
||||||
api.get('/api/attributes/FeatureInstance').then(response => {
|
api.get('/api/attributes/FeatureInstance').then(response => {
|
||||||
const attrs = response.data?.attributes || response.data || [];
|
const attrs = response.data?.attributes || response.data || [];
|
||||||
|
|
@ -327,7 +332,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||||
|
|
@ -340,7 +345,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Feature-Instanzen</h1>
|
<h1 className={styles.pageTitle}>Feature-Instanzen</h1>
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,9 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
setCombinedOptions(allOptions);
|
setCombinedOptions(allOptions);
|
||||||
|
if (allOptions.length > 0 && !selectedCombinedKey) {
|
||||||
|
setSelectedCombinedKey(allOptions[0].combinedKey);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadCombinedOptions();
|
loadCombinedOptions();
|
||||||
|
|
@ -387,7 +390,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
|
|
||||||
if (error && !selectedCombinedKey) {
|
if (error && !selectedCombinedKey) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||||
|
|
@ -400,7 +403,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Feature Instanz Benutzer</h1>
|
<h1 className={styles.pageTitle}>Feature Instanz Benutzer</h1>
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
|
|
||||||
if (error && !selectedFeatureCode) {
|
if (error && !selectedFeatureCode) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>{error}</p>
|
<p className={styles.errorMessage}>{error}</p>
|
||||||
|
|
@ -258,7 +258,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Feature Rollen & Rechte</h1>
|
<h1 className={styles.pageTitle}>Feature Rollen & Rechte</h1>
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||||
|
|
@ -255,7 +255,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Einladungen</h1>
|
<h1 className={styles.pageTitle}>Einladungen</h1>
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||||
|
|
@ -307,7 +307,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Rollen</h1>
|
<h1 className={styles.pageTitle}>Rollen</h1>
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,27 @@
|
||||||
* Admin page for managing Mandates (tenants) using FormGeneratorTable.
|
* Admin page for managing Mandates (tenants) using FormGeneratorTable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAdminMandates, type Mandate } from '../../hooks/useMandates';
|
import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates';
|
||||||
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
|
import { fetchSettingsAdmin, updateSettingsAdmin } from '../../api/billingApi';
|
||||||
|
import {
|
||||||
|
mergeBillingIntoMandateFormData,
|
||||||
|
splitMandateAndBillingFromForm,
|
||||||
|
} from '../../utils/mandateBillingFormMerge';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
export const AdminMandatesPage: React.FC = () => {
|
export const AdminMandatesPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const { showWarning, showSuccess } = useToast();
|
||||||
const {
|
const {
|
||||||
mandates,
|
mandates,
|
||||||
attributes,
|
|
||||||
columns,
|
columns,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
|
|
@ -31,53 +39,76 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
} = useAdminMandates();
|
} = useAdminMandates();
|
||||||
|
|
||||||
// Form attributes from backend - filter for create/edit forms
|
const {
|
||||||
const formAttributes: AttributeDefinition[] = useMemo(() => {
|
formAttributes,
|
||||||
const excludedFields = ['id'];
|
createFormAttributes,
|
||||||
return attributes
|
formAttributesWithBilling,
|
||||||
.filter(attr => !excludedFields.includes(attr.name))
|
createFormAttributesWithBilling,
|
||||||
.map(attr => ({
|
loading: mandateAttrsLoading,
|
||||||
...attr,
|
} = useMandateFormAttributes();
|
||||||
type: attr.type,
|
|
||||||
})) as AttributeDefinition[];
|
|
||||||
}, [attributes]);
|
|
||||||
|
|
||||||
// Create form attributes - exclude isSystem (only set by system, not user)
|
|
||||||
const createFormAttributes: AttributeDefinition[] = useMemo(() => {
|
|
||||||
return formAttributes.filter(attr => attr.name !== 'isSystem');
|
|
||||||
}, [formAttributes]);
|
|
||||||
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [editingMandate, setEditingMandate] = useState<Mandate | null>(null);
|
/** Mandate row merged with billing fields for FormGenerator */
|
||||||
|
const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [editingBillingWarning, setEditingBillingWarning] = useState<string | null>(null);
|
||||||
|
|
||||||
// Check if user can create
|
// Check if user can create
|
||||||
const canCreate = permissions?.create !== 'n';
|
const canCreate = permissions?.create !== 'n';
|
||||||
const canUpdate = permissions?.update !== 'n';
|
const canUpdate = permissions?.update !== 'n';
|
||||||
const canDelete = permissions?.delete !== 'n';
|
const canDelete = permissions?.delete !== 'n';
|
||||||
|
|
||||||
// Handle edit click
|
// Handle edit click — load mandate + billing settings (separate persistence)
|
||||||
const handleEditClick = async (mandate: Mandate) => {
|
const handleEditClick = async (mandate: Mandate) => {
|
||||||
|
setEditingBillingWarning(null);
|
||||||
const fullMandate = await fetchMandateById(mandate.id);
|
const fullMandate = await fetchMandateById(mandate.id);
|
||||||
if (fullMandate) {
|
if (!fullMandate) return;
|
||||||
setEditingMandate(fullMandate);
|
try {
|
||||||
|
const settings = await fetchSettingsAdmin(request, fullMandate.id);
|
||||||
|
setEditingFormData(
|
||||||
|
mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, settings)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setEditingFormData(mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, null));
|
||||||
|
setEditingBillingWarning(
|
||||||
|
'Abrechnungseinstellungen konnten nicht geladen werden. Nur Mandantendaten sind sicher bearbeitbar.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle create submit
|
// Handle create submit — POST mandate, then billing settings
|
||||||
const handleCreateSubmit = async (data: Partial<Mandate>) => {
|
const handleCreateSubmit = async (data: Record<string, unknown>) => {
|
||||||
const success = await handleCreate(data);
|
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
||||||
if (success) {
|
const created = await handleCreate(mandatePayload as Partial<Mandate>);
|
||||||
|
if (!created?.id) return;
|
||||||
|
try {
|
||||||
|
await updateSettingsAdmin(request, created.id, billingUpdate);
|
||||||
|
showSuccess('Erstellt', 'Mandant inkl. Abrechnungseinstellungen gespeichert.');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error(e);
|
||||||
|
showWarning(
|
||||||
|
'Mandant erstellt',
|
||||||
|
'Abrechnungseinstellungen konnten nicht gespeichert werden. Bitte unter Administration → Abrechnung nachpflegen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle edit submit
|
// Handle edit submit — PUT mandate + POST billing settings
|
||||||
const handleEditSubmit = async (data: Partial<Mandate>) => {
|
const handleEditSubmit = async (data: Record<string, unknown>) => {
|
||||||
if (!editingMandate) return;
|
if (!editingFormData?.id) return;
|
||||||
const success = await handleUpdate(editingMandate.id, data);
|
const mandateId = String(editingFormData.id);
|
||||||
if (success) {
|
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
||||||
setEditingMandate(null);
|
const mandateOk = await handleUpdate(mandateId, mandatePayload as Partial<Mandate>);
|
||||||
|
if (!mandateOk) return;
|
||||||
|
try {
|
||||||
|
await updateSettingsAdmin(request, mandateId, billingUpdate);
|
||||||
|
showSuccess('Gespeichert', 'Mandant und Abrechnung aktualisiert.');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error(e);
|
||||||
|
showWarning('Teilweise gespeichert', 'Mandant gespeichert, Abrechnung konnte nicht aktualisiert werden.');
|
||||||
}
|
}
|
||||||
|
setEditingFormData(null);
|
||||||
|
setEditingBillingWarning(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle delete (confirmation handled by DeleteActionButton)
|
// Handle delete (confirmation handled by DeleteActionButton)
|
||||||
|
|
@ -91,7 +122,7 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>Fehler beim Laden der Mandanten: {error}</p>
|
<p className={styles.errorMessage}>Fehler beim Laden der Mandanten: {error}</p>
|
||||||
|
|
@ -104,7 +135,7 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Mandanten</h1>
|
<h1 className={styles.pageTitle}>Mandanten</h1>
|
||||||
|
|
@ -212,14 +243,18 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
{createFormAttributes.length === 0 ? (
|
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginTop: 0, marginBottom: '12px' }}>
|
||||||
|
Stammdaten kommen aus dem Modell <code>Mandate</code> (API). Abrechnung wird in{' '}
|
||||||
|
<code>BillingSettings</code> pro Mandant gespeichert.
|
||||||
|
</p>
|
||||||
|
{mandateAttrsLoading || createFormAttributes.length === 0 ? (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner} />
|
<div className={styles.spinner} />
|
||||||
<span>Lade Formular...</span>
|
<span>Lade Formular...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FormGeneratorForm
|
<FormGeneratorForm
|
||||||
attributes={createFormAttributes}
|
attributes={createFormAttributesWithBilling}
|
||||||
mode="create"
|
mode="create"
|
||||||
onSubmit={handleCreateSubmit}
|
onSubmit={handleCreateSubmit}
|
||||||
onCancel={() => setShowCreateModal(false)}
|
onCancel={() => setShowCreateModal(false)}
|
||||||
|
|
@ -233,20 +268,29 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editingMandate && (
|
{editingFormData && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setEditingMandate(null)}>
|
<div
|
||||||
|
className={styles.modalOverlay}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingFormData(null);
|
||||||
|
setEditingBillingWarning(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>Mandant bearbeiten</h2>
|
<h2 className={styles.modalTitle}>Mandant bearbeiten</h2>
|
||||||
<button
|
<button
|
||||||
className={styles.modalClose}
|
className={styles.modalClose}
|
||||||
onClick={() => setEditingMandate(null)}
|
onClick={() => {
|
||||||
|
setEditingFormData(null);
|
||||||
|
setEditingBillingWarning(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
{editingMandate.isSystem && (
|
{Boolean(editingFormData.isSystem) && (
|
||||||
<div className={styles.infoBox} style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
<div className={styles.infoBox} style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
||||||
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -254,6 +298,14 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{editingBillingWarning && (
|
||||||
|
<div
|
||||||
|
className={styles.infoBox}
|
||||||
|
style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}
|
||||||
|
>
|
||||||
|
{editingBillingWarning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{formAttributes.length === 0 ? (
|
{formAttributes.length === 0 ? (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner} />
|
<div className={styles.spinner} />
|
||||||
|
|
@ -261,11 +313,14 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FormGeneratorForm
|
<FormGeneratorForm
|
||||||
attributes={formAttributes}
|
attributes={formAttributesWithBilling}
|
||||||
data={editingMandate}
|
data={editingFormData}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
onCancel={() => setEditingMandate(null)}
|
onCancel={() => {
|
||||||
|
setEditingFormData(null);
|
||||||
|
setEditingBillingWarning(null);
|
||||||
|
}}
|
||||||
submitButtonText="Speichern"
|
submitButtonText="Speichern"
|
||||||
cancelButtonText="Abbrechen"
|
cancelButtonText="Abbrechen"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -515,7 +515,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
||||||
|
|
||||||
if (error && !overview) {
|
if (error && !overview) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||||
|
|
@ -531,7 +531,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Benutzer-Zugriffsübersicht</h1>
|
<h1 className={styles.pageTitle}>Benutzer-Zugriffsübersicht</h1>
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,7 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||||
|
|
@ -270,7 +270,7 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Mandanten-Mitglieder</h1>
|
<h1 className={styles.pageTitle}>Mandanten-Mitglieder</h1>
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>Fehler beim Laden der Benutzer: {error}</p>
|
<p className={styles.errorMessage}>Fehler beim Laden der Benutzer: {error}</p>
|
||||||
|
|
@ -138,7 +138,7 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Benutzer</h1>
|
<h1 className={styles.pageTitle}>Benutzer</h1>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,12 @@ import {
|
||||||
type Feature,
|
type Feature,
|
||||||
} from '../../../hooks/useFeatureAccess';
|
} from '../../../hooks/useFeatureAccess';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import api from '../../../api';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import { useMandateFormAttributes } from '../../../hooks/useMandates';
|
||||||
|
import { createMandate } from '../../../api/mandateApi';
|
||||||
|
import { updateSettingsAdmin } from '../../../api/billingApi';
|
||||||
|
import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFormMerge';
|
||||||
|
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||||
import styles from '../Admin.module.css';
|
import styles from '../Admin.module.css';
|
||||||
|
|
||||||
const TOTAL_STEPS = 4;
|
const TOTAL_STEPS = 4;
|
||||||
|
|
@ -25,12 +30,19 @@ interface RoleOption {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AdminMandateWizardPage: React.FC = () => {
|
export const AdminMandateWizardPage: React.FC = () => {
|
||||||
const { showSuccess } = useToast();
|
const { showSuccess, showWarning, showError } = useToast();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const {
|
||||||
|
createFormAttributes,
|
||||||
|
createFormAttributesWithBilling,
|
||||||
|
loading: mandateAttrLoading,
|
||||||
|
} = useMandateFormAttributes();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fetchMandateUsers,
|
fetchMandateUsers,
|
||||||
addUserToMandate,
|
addUserToMandate,
|
||||||
removeUserFromMandate,
|
removeUserFromMandate,
|
||||||
|
updateUserRoles,
|
||||||
fetchMandates: fetchMandatesList,
|
fetchMandates: fetchMandatesList,
|
||||||
fetchRoles: fetchMandateRolesList,
|
fetchRoles: fetchMandateRolesList,
|
||||||
fetchAllUsers,
|
fetchAllUsers,
|
||||||
|
|
@ -44,6 +56,7 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
fetchInstanceUsers,
|
fetchInstanceUsers,
|
||||||
addUserToInstance,
|
addUserToInstance,
|
||||||
removeUserFromInstance,
|
removeUserFromInstance,
|
||||||
|
updateInstanceUserRoles,
|
||||||
fetchInstanceRoles: fetchInstanceRolesList,
|
fetchInstanceRoles: fetchInstanceRolesList,
|
||||||
} = useFeatureAccess();
|
} = useFeatureAccess();
|
||||||
|
|
||||||
|
|
@ -56,14 +69,13 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||||
const [selectedMandate, setSelectedMandate] = useState<Record<string, any> | null>(null);
|
const [selectedMandate, setSelectedMandate] = useState<Record<string, any> | null>(null);
|
||||||
const [isCreatingMandate, setIsCreatingMandate] = useState(false);
|
const [isCreatingMandate, setIsCreatingMandate] = useState(false);
|
||||||
const [mandateForm, setMandateForm] = useState({ name: '' });
|
|
||||||
|
|
||||||
// Step 2: Mandate Users
|
// Step 2: Mandate Users
|
||||||
const [mandateUsers, setMandateUsers] = useState<MandateUser[]>([]);
|
const [mandateUsers, setMandateUsers] = useState<MandateUser[]>([]);
|
||||||
const [allSystemUsers, setAllSystemUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]);
|
const [allSystemUsers, setAllSystemUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]);
|
||||||
const [mandateRoles, setMandateRoles] = useState<RoleOption[]>([]);
|
const [mandateRoles, setMandateRoles] = useState<RoleOption[]>([]);
|
||||||
const [isAddingMandateUser, setIsAddingMandateUser] = useState(false);
|
const [isAddingMandateUser, setIsAddingMandateUser] = useState(false);
|
||||||
const [addMandateUserForm, setAddMandateUserForm] = useState({ userId: '', roleIds: [] as string[] });
|
const [addMandateUserForm, setAddMandateUserForm] = useState({ userIds: [] as string[], roleIds: [] as string[] });
|
||||||
|
|
||||||
// Step 3: Instances
|
// Step 3: Instances
|
||||||
const [features, setFeatures] = useState<Feature[]>([]);
|
const [features, setFeatures] = useState<Feature[]>([]);
|
||||||
|
|
@ -77,7 +89,12 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
const [instanceUsers, setInstanceUsers] = useState<FeatureAccessUser[]>([]);
|
const [instanceUsers, setInstanceUsers] = useState<FeatureAccessUser[]>([]);
|
||||||
const [instanceRoles, setInstanceRoles] = useState<RoleOption[]>([]);
|
const [instanceRoles, setInstanceRoles] = useState<RoleOption[]>([]);
|
||||||
const [isAddingInstanceUser, setIsAddingInstanceUser] = useState(false);
|
const [isAddingInstanceUser, setIsAddingInstanceUser] = useState(false);
|
||||||
const [addInstanceUserForm, setAddInstanceUserForm] = useState({ userId: '', roleIds: [] as string[] });
|
const [addInstanceUserForm, setAddInstanceUserForm] = useState({ userIds: [] as string[], roleIds: [] as string[] });
|
||||||
|
|
||||||
|
const [roleEditContext, setRoleEditContext] = useState<
|
||||||
|
null | { scope: 'mandate' | 'instance'; userId: string }
|
||||||
|
>(null);
|
||||||
|
const [roleEditDraft, setRoleEditDraft] = useState<string[]>([]);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// HELPERS
|
// HELPERS
|
||||||
|
|
@ -126,6 +143,11 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
fetchFeatures().then(setFeatures);
|
fetchFeatures().then(setFeatures);
|
||||||
}, [fetchFeatures]);
|
}, [fetchFeatures]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRoleEditContext(null);
|
||||||
|
setRoleEditDraft([]);
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
// Step 2
|
// Step 2
|
||||||
const loadMandateUsers = useCallback(async () => {
|
const loadMandateUsers = useCallback(async () => {
|
||||||
if (!selectedMandate) return;
|
if (!selectedMandate) return;
|
||||||
|
|
@ -188,42 +210,69 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
// HANDLERS
|
// HANDLERS
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleCreateMandate = async () => {
|
const handleCreateMandate = async (data: Record<string, unknown>) => {
|
||||||
if (!mandateForm.name.trim()) { setError('Name ist erforderlich'); return; }
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/api/mandates/', {
|
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
||||||
name: mandateForm.name,
|
const body = {
|
||||||
enabled: true,
|
...mandatePayload,
|
||||||
});
|
enabled: mandatePayload.enabled !== undefined ? mandatePayload.enabled : true,
|
||||||
setSelectedMandate(response.data);
|
};
|
||||||
|
const created = await createMandate(request, body);
|
||||||
|
let billingSaved = false;
|
||||||
|
try {
|
||||||
|
await updateSettingsAdmin(request, String(created.id), billingUpdate);
|
||||||
|
billingSaved = true;
|
||||||
|
} catch (billingErr: unknown) {
|
||||||
|
console.error(billingErr);
|
||||||
|
showWarning(
|
||||||
|
'Mandant erstellt',
|
||||||
|
'Abrechnungseinstellungen konnten nicht gespeichert werden. Bitte unter Administration → Abrechnung nachpflegen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setSelectedMandate(created as Record<string, unknown>);
|
||||||
setIsCreatingMandate(false);
|
setIsCreatingMandate(false);
|
||||||
showSuccess('Erstellt', 'Mandant erstellt');
|
if (billingSaved) {
|
||||||
|
showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert');
|
||||||
|
}
|
||||||
await loadMandates();
|
await loadMandates();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err?.response?.data?.detail || err?.message || 'Fehler beim Erstellen');
|
const e = err as { response?: { data?: { detail?: string } }; message?: string };
|
||||||
|
setError(e?.response?.data?.detail || e?.message || 'Fehler beim Erstellen');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddMandateUser = async () => {
|
const handleAddMandateUser = async () => {
|
||||||
if (!selectedMandate || !addMandateUserForm.userId) return;
|
if (!selectedMandate || addMandateUserForm.userIds.length === 0) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const failures: string[] = [];
|
||||||
|
let ok = 0;
|
||||||
try {
|
try {
|
||||||
|
for (const uid of addMandateUserForm.userIds) {
|
||||||
const result = await addUserToMandate(selectedMandate.id, {
|
const result = await addUserToMandate(selectedMandate.id, {
|
||||||
targetUserId: addMandateUserForm.userId,
|
targetUserId: uid,
|
||||||
roleIds: addMandateUserForm.roleIds,
|
roleIds: addMandateUserForm.roleIds,
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) ok += 1;
|
||||||
|
else failures.push(`${uid}: ${result.error || 'Fehler'}`);
|
||||||
|
}
|
||||||
|
if (ok > 0) {
|
||||||
setIsAddingMandateUser(false);
|
setIsAddingMandateUser(false);
|
||||||
setAddMandateUserForm({ userId: '', roleIds: [] });
|
setAddMandateUserForm({ userIds: [], roleIds: [] });
|
||||||
showSuccess('Hinzugefügt', 'Benutzer zum Mandanten hinzugefügt');
|
showSuccess('Hinzugefügt', `${ok} Benutzer zum Mandanten hinzugefügt`);
|
||||||
await loadMandateUsers();
|
await loadMandateUsers();
|
||||||
} else {
|
}
|
||||||
setError(result.error || 'Fehler beim Hinzufügen');
|
if (failures.length > 0) {
|
||||||
|
showWarning(
|
||||||
|
'Teilweise fehlgeschlagen',
|
||||||
|
failures.slice(0, 5).join('; ') + (failures.length > 5 ? '…' : ''),
|
||||||
|
);
|
||||||
|
if (ok === 0) setError(failures.join('; '));
|
||||||
|
else await loadMandateUsers();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
@ -276,27 +325,76 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddInstanceUser = async () => {
|
const handleAddInstanceUser = async () => {
|
||||||
if (!selectedInstance || !selectedMandate || !addInstanceUserForm.userId) return;
|
if (!selectedInstance || !selectedMandate || addInstanceUserForm.userIds.length === 0) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const failures: string[] = [];
|
||||||
|
let ok = 0;
|
||||||
try {
|
try {
|
||||||
|
for (const uid of addInstanceUserForm.userIds) {
|
||||||
const result = await addUserToInstance(selectedMandate.id, selectedInstance.id, {
|
const result = await addUserToInstance(selectedMandate.id, selectedInstance.id, {
|
||||||
userId: addInstanceUserForm.userId,
|
userId: uid,
|
||||||
roleIds: addInstanceUserForm.roleIds,
|
roleIds: addInstanceUserForm.roleIds,
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) ok += 1;
|
||||||
|
else failures.push(`${uid}: ${result.error || 'Fehler'}`);
|
||||||
|
}
|
||||||
|
if (ok > 0) {
|
||||||
setIsAddingInstanceUser(false);
|
setIsAddingInstanceUser(false);
|
||||||
setAddInstanceUserForm({ userId: '', roleIds: [] });
|
setAddInstanceUserForm({ userIds: [], roleIds: [] });
|
||||||
showSuccess('Hinzugefügt', 'Benutzer zur Feature-Instanz hinzugefügt');
|
showSuccess('Hinzugefügt', `${ok} Benutzer zur Feature-Instanz hinzugefügt`);
|
||||||
await loadInstanceUsers();
|
await loadInstanceUsers();
|
||||||
} else {
|
}
|
||||||
setError(result.error || 'Fehler beim Hinzufügen');
|
if (failures.length > 0) {
|
||||||
|
showWarning(
|
||||||
|
'Teilweise fehlgeschlagen',
|
||||||
|
failures.slice(0, 5).join('; ') + (failures.length > 5 ? '…' : ''),
|
||||||
|
);
|
||||||
|
if (ok === 0) setError(failures.join('; '));
|
||||||
|
else await loadInstanceUsers();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _saveMandateRoleEdit = async () => {
|
||||||
|
if (!selectedMandate || roleEditContext?.scope !== 'mandate') return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const r = await updateUserRoles(selectedMandate.id, roleEditContext.userId, roleEditDraft);
|
||||||
|
if (r.success) {
|
||||||
|
showSuccess('Gespeichert', 'Rollen aktualisiert');
|
||||||
|
setRoleEditContext(null);
|
||||||
|
setRoleEditDraft([]);
|
||||||
|
await loadMandateUsers();
|
||||||
|
} else {
|
||||||
|
showError('Fehler', r.error || 'Rollen konnten nicht gespeichert werden');
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _saveInstanceRoleEdit = async () => {
|
||||||
|
if (!selectedMandate || !selectedInstance || roleEditContext?.scope !== 'instance') return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const r = await updateInstanceUserRoles(
|
||||||
|
selectedMandate.id,
|
||||||
|
selectedInstance.id,
|
||||||
|
roleEditContext.userId,
|
||||||
|
{ roleIds: roleEditDraft },
|
||||||
|
);
|
||||||
|
if (r.success) {
|
||||||
|
showSuccess('Gespeichert', 'Rollen aktualisiert');
|
||||||
|
setRoleEditContext(null);
|
||||||
|
setRoleEditDraft([]);
|
||||||
|
await loadInstanceUsers();
|
||||||
|
} else {
|
||||||
|
showError('Fehler', r.error || 'Rollen konnten nicht gespeichert werden');
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveInstanceUser = async (userId: string) => {
|
const handleRemoveInstanceUser = async (userId: string) => {
|
||||||
if (!selectedInstance || !selectedMandate) return;
|
if (!selectedInstance || !selectedMandate) return;
|
||||||
const result = await removeUserFromInstance(selectedMandate.id, selectedInstance.id, userId);
|
const result = await removeUserFromInstance(selectedMandate.id, selectedInstance.id, userId);
|
||||||
|
|
@ -324,9 +422,34 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
// SHARED UI
|
// SHARED UI
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type WizardUserRow = {
|
||||||
|
userId?: string;
|
||||||
|
id?: string;
|
||||||
|
username: string;
|
||||||
|
email?: string | null;
|
||||||
|
fullName?: string;
|
||||||
|
firstname?: string | null;
|
||||||
|
lastname?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
roleIds?: string[];
|
||||||
|
roleLabels?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const _roleTextForRow = (u: WizardUserRow, roleLookup: RoleOption[]) => {
|
||||||
|
if (u.roleLabels && u.roleLabels.length > 0) return u.roleLabels.join(', ');
|
||||||
|
if (u.roleIds && u.roleIds.length > 0) {
|
||||||
|
return u.roleIds
|
||||||
|
.map(rid => roleLookup.find(r => r.id === rid)?.roleLabel || rid)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
};
|
||||||
|
|
||||||
const renderUserTable = (
|
const renderUserTable = (
|
||||||
users: Array<{ userId?: string; id?: string; username: string; email?: string | null; fullName?: string; firstname?: string | null; lastname?: string | null; enabled?: boolean; roleLabels?: string[] }>,
|
users: WizardUserRow[],
|
||||||
|
roleLookup: RoleOption[],
|
||||||
onRemove: (userId: string) => void,
|
onRemove: (userId: string) => void,
|
||||||
|
options?: { onEditRoles?: (userId: string, currentRoleIds: string[]) => void },
|
||||||
) => (
|
) => (
|
||||||
users.length > 0 ? (
|
users.length > 0 ? (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
|
@ -342,12 +465,30 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map(u => {
|
{users.map(u => {
|
||||||
const uid = u.userId || u.id || '';
|
const uid = u.userId || u.id || '';
|
||||||
|
const ids = u.roleIds || [];
|
||||||
return (
|
return (
|
||||||
<tr key={uid} style={{ borderBottom: '1px solid var(--border-color, #f1f5f9)' }}>
|
<tr key={uid} style={{ borderBottom: '1px solid var(--border-color, #f1f5f9)' }}>
|
||||||
<td style={{ padding: '8px 12px', fontSize: '13px' }}>{getUserDisplayName(u as any)}</td>
|
<td style={{ padding: '8px 12px', fontSize: '13px' }}>{getUserDisplayName(u as any)}</td>
|
||||||
<td style={{ padding: '8px 12px', fontSize: '13px', color: 'var(--text-secondary)' }}>{u.email || '-'}</td>
|
<td style={{ padding: '8px 12px', fontSize: '13px', color: 'var(--text-secondary)' }}>{u.email || '-'}</td>
|
||||||
<td style={{ padding: '8px 12px', fontSize: '12px', color: 'var(--text-secondary)' }}>
|
<td style={{ padding: '8px 12px', fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||||
{u.roleLabels?.join(', ') || '-'}
|
<span>{_roleTextForRow(u, roleLookup)}</span>
|
||||||
|
{options?.onEditRoles && roleLookup.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
border: '1px solid var(--border-color, #e5e7eb)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: 'var(--surface-color, #fff)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => options.onEditRoles!(uid, ids)}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px 12px', textAlign: 'center' }}>
|
<td style={{ padding: '8px 12px', textAlign: 'center' }}>
|
||||||
<span className={styles.badge} style={{
|
<span className={styles.badge} style={{
|
||||||
|
|
@ -383,35 +524,55 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
const renderAddUserForm = (
|
const renderAddUserForm = (
|
||||||
availableUsers: Array<{ id?: string; userId?: string; username: string; email?: string | null; fullName?: string; firstname?: string | null; lastname?: string | null }>,
|
availableUsers: Array<{ id?: string; userId?: string; username: string; email?: string | null; fullName?: string; firstname?: string | null; lastname?: string | null }>,
|
||||||
roles: RoleOption[],
|
roles: RoleOption[],
|
||||||
formValue: { userId: string; roleIds: string[] },
|
formValue: { userIds: string[]; roleIds: string[] },
|
||||||
setFormValue: (fn: (prev: { userId: string; roleIds: string[] }) => { userId: string; roleIds: string[] }) => void,
|
setFormValue: (fn: (prev: { userIds: string[]; roleIds: string[] }) => { userIds: string[]; roleIds: string[] }) => void,
|
||||||
onSubmit: () => void,
|
onSubmit: () => void,
|
||||||
onCancel: () => void,
|
onCancel: () => void,
|
||||||
) => (
|
) => (
|
||||||
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
|
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
|
||||||
<div>
|
<div>
|
||||||
<label className={styles.formLabel}>Benutzer *</label>
|
<label className={styles.formLabel}>Benutzer * (mehrfach möglich)</label>
|
||||||
<select
|
<div
|
||||||
className={styles.filterSelect}
|
style={{
|
||||||
style={{ width: '100%' }}
|
maxHeight: '220px',
|
||||||
value={formValue.userId}
|
overflowY: 'auto',
|
||||||
onChange={e => setFormValue(p => ({ ...p, userId: e.target.value }))}
|
border: '1px solid var(--border-color, #e5e7eb)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '6px',
|
||||||
|
background: 'var(--surface-color, #fff)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="">-- Benutzer wählen --</option>
|
|
||||||
{availableUsers.map(u => {
|
{availableUsers.map(u => {
|
||||||
const uid = u.userId || u.id || '';
|
const uid = u.userId || u.id || '';
|
||||||
const name = getUserDisplayName(u as any);
|
const name = getUserDisplayName(u as any);
|
||||||
return (
|
return (
|
||||||
<option key={uid} value={uid}>
|
<label key={uid} className={styles.checkboxLabel} style={{ alignItems: 'flex-start' }}>
|
||||||
{u.username} {u.email ? `(${u.email})` : ''} {name !== u.username ? `- ${name}` : ''}
|
<input
|
||||||
</option>
|
type="checkbox"
|
||||||
|
checked={formValue.userIds.includes(uid)}
|
||||||
|
onChange={e => {
|
||||||
|
setFormValue(p => ({
|
||||||
|
...p,
|
||||||
|
userIds: e.target.checked
|
||||||
|
? [...p.userIds, uid]
|
||||||
|
: p.userIds.filter(id => id !== uid),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{u.username} {u.email ? <span style={{ color: 'var(--text-secondary)' }}>({u.email})</span> : null}
|
||||||
|
{name !== u.username ? <span style={{ color: 'var(--text-secondary)' }}> — {name}</span> : null}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{roles.length > 0 && (
|
{roles.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className={styles.formLabel}>Rollen</label>
|
<label className={styles.formLabel}>Rollen (für alle ausgewählten Benutzer)</label>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
{roles.map(r => (
|
{roles.map(r => (
|
||||||
<label key={r.id} className={styles.checkboxLabel}>
|
<label key={r.id} className={styles.checkboxLabel}>
|
||||||
|
|
@ -434,7 +595,11 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<button className={styles.primaryButton} onClick={onSubmit} disabled={isLoading || !formValue.userId}>
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={isLoading || formValue.userIds.length === 0}
|
||||||
|
>
|
||||||
{isLoading ? 'Hinzufügen...' : 'Hinzufügen'}
|
{isLoading ? 'Hinzufügen...' : 'Hinzufügen'}
|
||||||
</button>
|
</button>
|
||||||
<button className={styles.secondaryButton} onClick={onCancel}>
|
<button className={styles.secondaryButton} onClick={onCancel}>
|
||||||
|
|
@ -554,21 +719,21 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: '12px' }}>
|
<div style={{ display: 'grid', gap: '12px' }}>
|
||||||
<div className={styles.formGroup}>
|
{mandateAttrLoading || createFormAttributes.length === 0 ? (
|
||||||
<label className={`${styles.formLabel} ${styles.required}`}>Name</label>
|
<div className={styles.loadingContainer} style={{ padding: '24px' }}>
|
||||||
<input
|
<div className={styles.spinner} />
|
||||||
className={styles.formInput}
|
<span>Formular wird geladen...</span>
|
||||||
value={mandateForm.name}
|
</div>
|
||||||
onChange={e => setMandateForm(p => ({ ...p, name: e.target.value }))}
|
) : (
|
||||||
placeholder="z.B. Swiss Trust AG"
|
<FormGeneratorForm
|
||||||
|
attributes={createFormAttributesWithBilling}
|
||||||
|
mode="create"
|
||||||
|
onSubmit={handleCreateMandate}
|
||||||
|
onCancel={() => setIsCreatingMandate(false)}
|
||||||
|
submitButtonText={isLoading ? 'Erstellen...' : 'Mandant erstellen'}
|
||||||
|
cancelButtonText="Abbrechen"
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<button className={styles.primaryButton} onClick={handleCreateMandate} disabled={isLoading}>
|
|
||||||
{isLoading ? 'Erstellen...' : 'Mandant erstellen'}
|
|
||||||
</button>
|
|
||||||
<button className={styles.secondaryButton} onClick={() => setIsCreatingMandate(false)}>Abbrechen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -606,11 +771,64 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
addMandateUserForm,
|
addMandateUserForm,
|
||||||
setAddMandateUserForm,
|
setAddMandateUserForm,
|
||||||
handleAddMandateUser,
|
handleAddMandateUser,
|
||||||
() => { setIsAddingMandateUser(false); setAddMandateUserForm({ userId: '', roleIds: [] }); },
|
() => { setIsAddingMandateUser(false); setAddMandateUserForm({ userIds: [], roleIds: [] }); },
|
||||||
|
)}
|
||||||
|
|
||||||
|
{roleEditContext?.scope === 'mandate' && (
|
||||||
|
<div style={{
|
||||||
|
padding: '16px',
|
||||||
|
background: 'var(--bg-secondary, #f8fafc)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '12px',
|
||||||
|
border: '1px solid var(--border-color, #e5e7eb)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '14px' }}>Rollen bearbeiten</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{mandateRoles.map(r => (
|
||||||
|
<label key={r.id} className={styles.checkboxLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={roleEditDraft.includes(r.id)}
|
||||||
|
onChange={e => {
|
||||||
|
setRoleEditDraft(prev =>
|
||||||
|
e.target.checked ? [...prev, r.id] : prev.filter(id => id !== r.id),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{r.roleLabel}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={() => _saveMandateRoleEdit()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => { setRoleEditContext(null); setRoleEditDraft([]); }}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
{renderUserTable(mandateUsers as any[], handleRemoveMandateUser)}
|
{renderUserTable(mandateUsers as WizardUserRow[], mandateRoles, handleRemoveMandateUser, {
|
||||||
|
onEditRoles: (userId, ids) => {
|
||||||
|
setError(null);
|
||||||
|
setRoleEditContext({ scope: 'mandate', userId });
|
||||||
|
setRoleEditDraft([...ids]);
|
||||||
|
},
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
|
@ -770,13 +988,68 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
addInstanceUserForm,
|
addInstanceUserForm,
|
||||||
setAddInstanceUserForm,
|
setAddInstanceUserForm,
|
||||||
handleAddInstanceUser,
|
handleAddInstanceUser,
|
||||||
() => { setIsAddingInstanceUser(false); setAddInstanceUserForm({ userId: '', roleIds: [] }); },
|
() => { setIsAddingInstanceUser(false); setAddInstanceUserForm({ userIds: [], roleIds: [] }); },
|
||||||
|
)}
|
||||||
|
|
||||||
|
{roleEditContext?.scope === 'instance' && (
|
||||||
|
<div style={{
|
||||||
|
padding: '16px',
|
||||||
|
background: 'var(--bg-secondary, #f8fafc)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '12px',
|
||||||
|
border: '1px solid var(--border-color, #e5e7eb)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '14px' }}>Rollen bearbeiten (Feature-Instanz)</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{instanceRoles.map(r => (
|
||||||
|
<label key={r.id} className={styles.checkboxLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={roleEditDraft.includes(r.id)}
|
||||||
|
onChange={e => {
|
||||||
|
setRoleEditDraft(prev =>
|
||||||
|
e.target.checked ? [...prev, r.id] : prev.filter(id => id !== r.id),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{r.roleLabel}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={() => _saveInstanceRoleEdit()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => { setRoleEditContext(null); setRoleEditDraft([]); }}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
{renderUserTable(
|
{renderUserTable(
|
||||||
instanceUsers.map(u => ({ ...u, userId: u.userId || u.id })),
|
instanceUsers.map(u => ({ ...u, userId: u.userId || u.id })) as WizardUserRow[],
|
||||||
|
instanceRoles,
|
||||||
handleRemoveInstanceUser,
|
handleRemoveInstanceUser,
|
||||||
|
{
|
||||||
|
onEditRoles: (userId, ids) => {
|
||||||
|
setError(null);
|
||||||
|
setRoleEditContext({ scope: 'instance', userId });
|
||||||
|
setRoleEditDraft([...ids]);
|
||||||
|
},
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
|
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
|
||||||
import { useAdminMandates } from '../../hooks/useMandates';
|
import type { CheckoutCreateRequest } from '../../api/billingApi';
|
||||||
|
import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates';
|
||||||
|
import { useCurrentUser } from '../../hooks/useUsers';
|
||||||
|
import api from '../../api';
|
||||||
|
import { getUserDataCache } from '../../utils/userCache';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
const _formatCurrency = (amount: number) => {
|
const _formatCurrency = (amount: number) => {
|
||||||
|
|
@ -19,37 +24,49 @@ const _formatCurrency = (amount: number) => {
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _mandateDisplayLabel = (m: UserMandateRow): string => {
|
||||||
|
if (m.label) return m.label;
|
||||||
|
if (typeof m.name === 'object' && m.name) {
|
||||||
|
const n = m.name as Record<string, string>;
|
||||||
|
return n.de || n.en || Object.values(n)[0] || m.id;
|
||||||
|
}
|
||||||
|
return (m.name as string) || m.id;
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MANDATE SELECTOR
|
// MANDATE SELECTOR
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
interface MandateSelectorProps {
|
interface MandateSelectorProps {
|
||||||
|
mandates: UserMandateRow[];
|
||||||
|
loading: boolean;
|
||||||
selectedMandateId: string | null;
|
selectedMandateId: string | null;
|
||||||
onSelect: (mandateId: string) => void;
|
onSelect: (mandateId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MandateSelector: React.FC<MandateSelectorProps> = ({ selectedMandateId, onSelect }) => {
|
const MandateSelector: React.FC<MandateSelectorProps> = ({
|
||||||
const { mandates, loading } = useAdminMandates();
|
mandates,
|
||||||
|
loading,
|
||||||
return (
|
selectedMandateId,
|
||||||
|
onSelect,
|
||||||
|
}) => (
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>Mandant auswählen</label>
|
<label>Mandant auswählen</label>
|
||||||
<select
|
<select
|
||||||
className={styles.select}
|
className={styles.select}
|
||||||
value={selectedMandateId || ''}
|
value={selectedMandateId || ''}
|
||||||
onChange={(e) => onSelect(e.target.value)}
|
onChange={e => onSelect(e.target.value)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<option value="">-- Mandant wählen --</option>
|
<option value="">-- Mandant wählen --</option>
|
||||||
{mandates.map((mandate) => (
|
{mandates.map(mandate => (
|
||||||
<option key={mandate.id} value={mandate.id}>
|
<option key={mandate.id} value={mandate.id}>
|
||||||
{mandate.label || mandate.name || mandate.id}
|
{_mandateDisplayLabel(mandate)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SETTINGS EDITOR
|
// SETTINGS EDITOR
|
||||||
|
|
@ -63,10 +80,9 @@ interface SettingsEditorProps {
|
||||||
|
|
||||||
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
|
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
billingModel: settings?.billingModel || 'UNLIMITED',
|
billingModel: (settings?.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE') as BillingSettings['billingModel'],
|
||||||
defaultUserCredit: settings?.defaultUserCredit || 10,
|
defaultUserCredit: Number(settings?.defaultUserCredit ?? 0),
|
||||||
warningThresholdPercent: settings?.warningThresholdPercent || 10,
|
warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
|
||||||
blockOnZeroBalance: settings?.blockOnZeroBalance ?? true,
|
|
||||||
notifyOnWarning: settings?.notifyOnWarning ?? true,
|
notifyOnWarning: settings?.notifyOnWarning ?? true,
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
@ -75,11 +91,10 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
setFormData({
|
setFormData({
|
||||||
billingModel: settings.billingModel,
|
billingModel: settings.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE',
|
||||||
defaultUserCredit: settings.defaultUserCredit,
|
defaultUserCredit: Number(settings.defaultUserCredit ?? 0),
|
||||||
warningThresholdPercent: settings.warningThresholdPercent,
|
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
|
||||||
blockOnZeroBalance: settings.blockOnZeroBalance,
|
notifyOnWarning: settings.notifyOnWarning ?? true,
|
||||||
notifyOnWarning: settings.notifyOnWarning,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
@ -116,12 +131,10 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
<select
|
<select
|
||||||
className={styles.select}
|
className={styles.select}
|
||||||
value={formData.billingModel}
|
value={formData.billingModel}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, billingModel: e.target.value as any }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, billingModel: e.target.value as BillingSettings['billingModel'] }))}
|
||||||
>
|
>
|
||||||
<option value="UNLIMITED">Unlimited</option>
|
|
||||||
<option value="PREPAY_MANDATE">Prepaid (Mandant)</option>
|
<option value="PREPAY_MANDATE">Prepaid (Mandant)</option>
|
||||||
<option value="PREPAY_USER">Prepaid (Benutzer)</option>
|
<option value="PREPAY_USER">Prepaid (Benutzer)</option>
|
||||||
<option value="CREDIT_POSTPAY">Kredit (Postpay)</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -151,18 +164,6 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
step="1"
|
step="1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label> </label>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.blockOnZeroBalance}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, blockOnZeroBalance: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
Bei Guthaben 0 blockieren
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.formRow}>
|
<div className={styles.formRow}>
|
||||||
|
|
@ -363,7 +364,6 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
|
||||||
<div className={styles.accountInfo}>
|
<div className={styles.accountInfo}>
|
||||||
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
|
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
|
||||||
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
||||||
{account.creditLimit && <span>Limit: {formatCurrency(account.creditLimit)}</span>}
|
|
||||||
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
|
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -373,13 +373,113 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MANDATE ADMIN — STRIPE TOP-UP (same URL as SysAdmin billing admin)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface MandateStripeTopUpProps {
|
||||||
|
mandateId: string;
|
||||||
|
createCheckout: (
|
||||||
|
checkoutRequest: CheckoutCreateRequest,
|
||||||
|
targetMandateId?: string
|
||||||
|
) => Promise<{ redirectUrl?: string } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, createCheckout }) => {
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [localMsg, setLocalMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const _handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const n = parseFloat(amount);
|
||||||
|
if (!n || n <= 0) {
|
||||||
|
setLocalMsg('Betrag muss positiv sein');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setLocalMsg(null);
|
||||||
|
try {
|
||||||
|
const currentUser = getUserDataCache();
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
currentUrl.searchParams.delete('success');
|
||||||
|
currentUrl.searchParams.delete('canceled');
|
||||||
|
currentUrl.searchParams.delete('session_id');
|
||||||
|
currentUrl.hash = '';
|
||||||
|
const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`;
|
||||||
|
const result = await createCheckout(
|
||||||
|
{ userId: currentUser?.id, amount: n, returnUrl },
|
||||||
|
mandateId
|
||||||
|
);
|
||||||
|
if (result?.redirectUrl) {
|
||||||
|
window.location.href = result.redirectUrl;
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Checkout fehlgeschlagen';
|
||||||
|
setLocalMsg(msg);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.adminSection}>
|
||||||
|
<h3>Guthaben via Stripe aufladen</h3>
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text-secondary, #64748b)', marginTop: 0 }}>
|
||||||
|
Sie werden zu Stripe weitergeleitet. Nach erfolgreicher Zahlung kehren Sie hierher zurück.
|
||||||
|
</p>
|
||||||
|
{localMsg && <div className={styles.errorMessage}>{localMsg}</div>}
|
||||||
|
<form onSubmit={_handleSubmit}>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>Betrag (CHF)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.input}
|
||||||
|
value={amount}
|
||||||
|
onChange={e => setAmount(e.target.value)}
|
||||||
|
placeholder="z.B. 50"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
disabled={busy || !amount}
|
||||||
|
>
|
||||||
|
{busy ? 'Weiterleitung...' : 'Mit Stripe bezahlen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MAIN COMPONENT
|
// MAIN COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const BillingAdmin: React.FC = () => {
|
export const BillingAdmin: React.FC = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { user: currentUser } = useCurrentUser();
|
||||||
|
const isSysAdmin = currentUser?.isSysAdmin === true;
|
||||||
|
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
||||||
const { settings, accounts, users, loading, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined);
|
const [mandateList, setMandateList] = useState<UserMandateRow[]>([]);
|
||||||
|
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||||
|
|
||||||
|
const { fetchMandates } = useUserMandates();
|
||||||
|
const {
|
||||||
|
settings,
|
||||||
|
accounts,
|
||||||
|
users,
|
||||||
|
loading,
|
||||||
|
saveSettings,
|
||||||
|
addCredit,
|
||||||
|
loadAccounts,
|
||||||
|
createCheckout,
|
||||||
|
} = useBillingAdmin(selectedMandateId || undefined);
|
||||||
|
|
||||||
const handleMandateSelect = (mandateId: string) => {
|
const handleMandateSelect = (mandateId: string) => {
|
||||||
setSelectedMandateId(mandateId || null);
|
setSelectedMandateId(mandateId || null);
|
||||||
|
|
@ -398,15 +498,138 @@ export const BillingAdmin: React.FC = () => {
|
||||||
return result;
|
return result;
|
||||||
}, [selectedMandateId, addCredit, loadAccounts]);
|
}, [selectedMandateId, addCredit, loadAccounts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setMandatesLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchMandates();
|
||||||
|
if (!cancelled) {
|
||||||
|
setMandateList(Array.isArray(data) ? data : []);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setMandatesLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [fetchMandates]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSysAdmin && mandateList.length === 1 && selectedMandateId === null) {
|
||||||
|
setSelectedMandateId(mandateList[0].id);
|
||||||
|
}
|
||||||
|
}, [isSysAdmin, mandateList, selectedMandateId]);
|
||||||
|
|
||||||
|
const [stripeReturnMessage, setStripeReturnMessage] = useState<{
|
||||||
|
type: 'success' | 'error';
|
||||||
|
text: string;
|
||||||
|
} | null>(null);
|
||||||
|
const successParam = searchParams.get('success');
|
||||||
|
const canceledParam = searchParams.get('canceled');
|
||||||
|
const sessionIdParam = searchParams.get('session_id');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const _confirmCheckoutIfNeeded = async () => {
|
||||||
|
if (successParam !== 'true') {
|
||||||
|
if (canceledParam === 'true' && !cancelled) {
|
||||||
|
setStripeReturnMessage({ type: 'error', text: 'Zahlung abgebrochen.' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionIdParam) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setStripeReturnMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (selectedMandateId) await loadAccounts(selectedMandateId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/api/billing/checkout/confirm', { sessionId: sessionIdParam });
|
||||||
|
if (!cancelled) {
|
||||||
|
setStripeReturnMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: 'Zahlung erfolgreich. Guthaben wurde verbucht.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||||
|
if (!cancelled) {
|
||||||
|
setStripeReturnMessage({
|
||||||
|
type: 'error',
|
||||||
|
text:
|
||||||
|
detail ||
|
||||||
|
'Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (selectedMandateId) await loadAccounts(selectedMandateId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_confirmCheckoutIfNeeded();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]);
|
||||||
|
|
||||||
|
const _clearStripeParams = useCallback(() => {
|
||||||
|
searchParams.delete('success');
|
||||||
|
searchParams.delete('canceled');
|
||||||
|
searchParams.delete('session_id');
|
||||||
|
setSearchParams(searchParams, { replace: true });
|
||||||
|
setStripeReturnMessage(null);
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
const showStripeForMandateAdmin = !isSysAdmin && !!selectedMandateId && !!settings;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.billingDashboard}>
|
<div className={styles.billingDashboard}>
|
||||||
<header className={styles.pageHeader}>
|
<header className={styles.pageHeader}>
|
||||||
<h1>Billing Administration</h1>
|
<h1>Billing Administration</h1>
|
||||||
<p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p>
|
<p className={styles.subtitle}>
|
||||||
|
{isSysAdmin
|
||||||
|
? 'Verwaltung von Abrechnungseinstellungen und Guthaben'
|
||||||
|
: 'Guthaben und Konten für Ihre Mandanten'}
|
||||||
|
</p>
|
||||||
|
{isSysAdmin && (
|
||||||
|
<p style={{ marginTop: '8px' }}>
|
||||||
|
<Link to="/admin/billing/mandates" style={{ color: 'var(--color-primary)' }}>
|
||||||
|
Mandanten-Übersicht (Balances & Transaktionen)
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{stripeReturnMessage && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
stripeReturnMessage.type === 'success' ? styles.successMessage : styles.errorMessage
|
||||||
|
}
|
||||||
|
style={{ marginBottom: '1rem' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '12px', alignItems: 'center' }}>
|
||||||
|
<span>{stripeReturnMessage.text}</span>
|
||||||
|
<button type="button" className={styles.button} onClick={_clearStripeParams}>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<MandateSelector
|
<MandateSelector
|
||||||
|
mandates={mandateList}
|
||||||
|
loading={mandatesLoading}
|
||||||
selectedMandateId={selectedMandateId}
|
selectedMandateId={selectedMandateId}
|
||||||
onSelect={handleMandateSelect}
|
onSelect={handleMandateSelect}
|
||||||
/>
|
/>
|
||||||
|
|
@ -414,31 +637,33 @@ export const BillingAdmin: React.FC = () => {
|
||||||
|
|
||||||
{selectedMandateId && (
|
{selectedMandateId && (
|
||||||
<>
|
<>
|
||||||
|
{isSysAdmin && (
|
||||||
<SettingsEditor
|
<SettingsEditor
|
||||||
settings={settings}
|
settings={settings}
|
||||||
onSave={handleSaveSettings}
|
onSave={handleSaveSettings}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSysAdmin && (
|
||||||
<CreditAdder
|
<CreditAdder
|
||||||
settings={settings}
|
settings={settings}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
users={users}
|
users={users}
|
||||||
onAddCredit={_handleAddCredit}
|
onAddCredit={_handleAddCredit}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AccountsOverview
|
{showStripeForMandateAdmin && (
|
||||||
accounts={accounts}
|
<MandateStripeTopUp mandateId={selectedMandateId} createCheckout={createCheckout} />
|
||||||
users={users}
|
)}
|
||||||
loading={loading}
|
|
||||||
/>
|
<AccountsOverview accounts={accounts} users={users} loading={loading} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!selectedMandateId && (
|
{!selectedMandateId && (
|
||||||
<div className={styles.noData}>
|
<div className={styles.noData}>Bitte wählen Sie einen Mandanten aus.</div>
|
||||||
Bitte wählen Sie einen Mandanten aus.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,8 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBillingModelLabel = (model: string) => {
|
const getBillingModelLabel = (model: string) => {
|
||||||
switch (model) {
|
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
||||||
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
|
return 'Prepaid (Mandant)';
|
||||||
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
|
|
||||||
case 'CREDIT_POSTPAY': return 'Kredit';
|
|
||||||
case 'UNLIMITED': return 'Unlimited';
|
|
||||||
default: return model;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -61,18 +61,12 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
|
||||||
const [showCheckout, setShowCheckout] = useState(false);
|
const [showCheckout, setShowCheckout] = useState(false);
|
||||||
|
|
||||||
const _getBillingModelLabel = (model: string) => {
|
const _getBillingModelLabel = (model: string) => {
|
||||||
switch (model) {
|
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
||||||
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
|
return 'Prepaid (Mandant)';
|
||||||
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
|
|
||||||
case 'CREDIT_POSTPAY': return 'Kredit';
|
|
||||||
case 'UNLIMITED': return 'Unlimited';
|
|
||||||
default: return model;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const canTopUp = balance.billingModel === 'PREPAY_USER'
|
const canTopUp = balance.billingModel === 'PREPAY_USER'
|
||||||
|| balance.billingModel === 'PREPAY_MANDATE'
|
|| balance.billingModel === 'PREPAY_MANDATE';
|
||||||
|| balance.billingModel === 'CREDIT_POSTPAY';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
||||||
|
|
|
||||||
|
|
@ -39,13 +39,8 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBillingModelLabel = (model: string) => {
|
const getBillingModelLabel = (model: string) => {
|
||||||
switch (model) {
|
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
||||||
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
|
return 'Prepaid (Mandant)';
|
||||||
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
|
|
||||||
case 'CREDIT_POSTPAY': return 'Kredit';
|
|
||||||
case 'UNLIMITED': return 'Unlimited';
|
|
||||||
default: return model;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -351,13 +351,27 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
onStopped: () => setIsProcessing(false),
|
onStopped: () => setIsProcessing(false),
|
||||||
onError: (event) => {
|
onError: (event) => {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
const item = event.item as Record<string, unknown> | undefined;
|
||||||
|
let msg = event.content || 'Unknown error';
|
||||||
|
if (item && item.error === 'INSUFFICIENT_BALANCE') {
|
||||||
|
const preferDe =
|
||||||
|
typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de');
|
||||||
|
const de = typeof item.messageDe === 'string' ? item.messageDe : '';
|
||||||
|
const en = typeof item.messageEn === 'string' ? item.messageEn : '';
|
||||||
|
msg = preferDe ? de || en || msg : en || de || msg;
|
||||||
|
if (item.userAction === 'TOP_UP_SELF' && typeof item.billingUiPath === 'string') {
|
||||||
|
msg += `\n\n→ ${item.billingUiPath}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
msg = `Error: ${msg}`;
|
||||||
|
}
|
||||||
setMessages(prev => [
|
setMessages(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: `error-${Date.now()}`,
|
id: `error-${Date.now()}`,
|
||||||
workflowId: '',
|
workflowId: '',
|
||||||
role: 'system',
|
role: 'system',
|
||||||
message: `Error: ${event.content || 'Unknown error'}`,
|
message: msg,
|
||||||
publishedAt: Date.now() / 1000,
|
publishedAt: Date.now() / 1000,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
163
src/utils/mandateBillingFormMerge.ts
Normal file
163
src/utils/mandateBillingFormMerge.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
/**
|
||||||
|
* Merges mandate core fields with billing settings for SysAdmin mandate forms.
|
||||||
|
* Billing is stored separately (BillingSettings); attributes API only exposes Mandate model fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm';
|
||||||
|
import type { BillingSettings, BillingSettingsUpdate } from '../api/billingApi';
|
||||||
|
|
||||||
|
export const mandateBillingFieldNames = [
|
||||||
|
'billingModel',
|
||||||
|
'defaultUserCredit',
|
||||||
|
'warningThresholdPercent',
|
||||||
|
'notifyOnWarning',
|
||||||
|
'notifyEmails',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type MandateBillingFieldName = (typeof mandateBillingFieldNames)[number];
|
||||||
|
|
||||||
|
/** FormGenerator attribute definitions for mandate billing (appended after /api/attributes/Mandate). */
|
||||||
|
export function getMandateBillingFormAttributes(): AttributeDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'billingModel',
|
||||||
|
type: 'select',
|
||||||
|
label: 'Abrechnungsmodell',
|
||||||
|
description: 'Vorauszahlung auf Mandanten- oder Benutzerkonten.',
|
||||||
|
required: false,
|
||||||
|
default: 'PREPAY_MANDATE',
|
||||||
|
editable: true,
|
||||||
|
order: 100,
|
||||||
|
options: [
|
||||||
|
{ value: 'PREPAY_MANDATE', label: 'Vorauszahlung (Mandanten-Guthaben)' },
|
||||||
|
{ value: 'PREPAY_USER', label: 'Vorauszahlung pro Benutzer' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'defaultUserCredit',
|
||||||
|
type: 'float',
|
||||||
|
label: 'Startguthaben neuem Benutzer (CHF)',
|
||||||
|
description:
|
||||||
|
'Nur relevant bei PREPAY_USER (u. a. Root-Mandant). Sonst meist 0.',
|
||||||
|
required: false,
|
||||||
|
default: 0,
|
||||||
|
editable: true,
|
||||||
|
order: 101,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'warningThresholdPercent',
|
||||||
|
type: 'float',
|
||||||
|
label: 'Warnschwelle (%)',
|
||||||
|
description: 'Benachrichtigung, wenn das Guthaben unter diesem Prozentsatz fällt.',
|
||||||
|
required: false,
|
||||||
|
default: 10,
|
||||||
|
editable: true,
|
||||||
|
order: 102,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'notifyOnWarning',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: 'Bei Warnung benachrichtigen',
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
editable: true,
|
||||||
|
order: 103,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'notifyEmails',
|
||||||
|
type: 'textarea',
|
||||||
|
label: 'Benachrichtigungs-E-Mails',
|
||||||
|
description: 'Eine Adresse pro Zeile oder durch Komma/Semikolon getrennt.',
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
editable: true,
|
||||||
|
order: 104,
|
||||||
|
minRows: 2,
|
||||||
|
maxRows: 6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseNotifyEmailsInput(val: unknown): string[] {
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
return val.map(String).map(s => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof val !== 'string') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
.split(/[\n,;]+/)
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build initial form values: mandate row + billing settings (notifyEmails as multi-line string). */
|
||||||
|
function _normalizeBillingModelUi(raw: string | undefined): BillingSettings['billingModel'] {
|
||||||
|
if (raw === 'PREPAY_USER') return 'PREPAY_USER';
|
||||||
|
return 'PREPAY_MANDATE';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeBillingIntoMandateFormData(
|
||||||
|
mandate: Record<string, unknown>,
|
||||||
|
settings: BillingSettings | null
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!settings) {
|
||||||
|
return {
|
||||||
|
...mandate,
|
||||||
|
billingModel: 'PREPAY_MANDATE',
|
||||||
|
defaultUserCredit: 0,
|
||||||
|
warningThresholdPercent: 10,
|
||||||
|
notifyOnWarning: true,
|
||||||
|
notifyEmails: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...mandate,
|
||||||
|
billingModel: _normalizeBillingModelUi(settings.billingModel),
|
||||||
|
defaultUserCredit: Number(settings.defaultUserCredit ?? 0),
|
||||||
|
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
|
||||||
|
notifyOnWarning: settings.notifyOnWarning ?? true,
|
||||||
|
notifyEmails: (settings.notifyEmails || []).join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split form submit payload into mandate PUT body and billing POST body. */
|
||||||
|
export function splitMandateAndBillingFromForm(
|
||||||
|
formData: Record<string, unknown>
|
||||||
|
): { mandatePayload: Record<string, unknown>; billingUpdate: BillingSettingsUpdate } {
|
||||||
|
const mandatePayload: Record<string, unknown> = {};
|
||||||
|
if ('name' in formData) mandatePayload.name = formData.name;
|
||||||
|
if ('label' in formData) mandatePayload.label = formData.label;
|
||||||
|
if ('enabled' in formData) mandatePayload.enabled = formData.enabled;
|
||||||
|
|
||||||
|
const billingUpdate: BillingSettingsUpdate = {};
|
||||||
|
if ('billingModel' in formData && formData.billingModel !== undefined && formData.billingModel !== '') {
|
||||||
|
billingUpdate.billingModel = formData.billingModel as BillingSettingsUpdate['billingModel'];
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const raw = formData.defaultUserCredit;
|
||||||
|
const n =
|
||||||
|
raw === undefined || raw === null || raw === ''
|
||||||
|
? 0
|
||||||
|
: Number(raw);
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
billingUpdate.defaultUserCredit = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
'warningThresholdPercent' in formData &&
|
||||||
|
formData.warningThresholdPercent !== undefined &&
|
||||||
|
formData.warningThresholdPercent !== ''
|
||||||
|
) {
|
||||||
|
const n = Number(formData.warningThresholdPercent);
|
||||||
|
if (!Number.isNaN(n)) billingUpdate.warningThresholdPercent = n;
|
||||||
|
}
|
||||||
|
if ('notifyOnWarning' in formData) {
|
||||||
|
billingUpdate.notifyOnWarning = Boolean(formData.notifyOnWarning);
|
||||||
|
}
|
||||||
|
if ('notifyEmails' in formData) {
|
||||||
|
billingUpdate.notifyEmails = _parseNotifyEmailsInput(formData.notifyEmails);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mandatePayload, billingUpdate };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue