commit
708687a5e4
44 changed files with 1694 additions and 416 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 { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||
import { BillingDataView, BillingAdmin } from './pages/billing';
|
||||
import { BillingDataView, BillingAdmin, BillingMandateView } from './pages/billing';
|
||||
function App() {
|
||||
// Load saved theme preference and set app name on app mount
|
||||
useEffect(() => {
|
||||
|
|
@ -185,7 +185,10 @@ function App() {
|
|||
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
|
||||
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
||||
<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="logs" element={<AdminLogsPage />} />
|
||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||
|
|
|
|||
|
|
@ -126,9 +126,10 @@ api.interceptors.response.use(
|
|||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// 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/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)
|
||||
const pathname = window.location.pathname;
|
||||
|
|
|
|||
|
|
@ -4,19 +4,10 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
|||
// 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 ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
|
||||
|
||||
export interface BillingAddress {
|
||||
company: string;
|
||||
street: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
vatNumber?: string;
|
||||
}
|
||||
|
||||
export interface BillingBalance {
|
||||
mandateId: string;
|
||||
mandateName: string;
|
||||
|
|
@ -25,7 +16,6 @@ export interface BillingBalance {
|
|||
currency: string;
|
||||
warningThreshold: number;
|
||||
isWarning: boolean;
|
||||
creditLimit?: number;
|
||||
}
|
||||
|
||||
export interface BillingTransaction {
|
||||
|
|
@ -54,20 +44,16 @@ export interface BillingSettings {
|
|||
billingModel: BillingModel;
|
||||
defaultUserCredit: number;
|
||||
warningThresholdPercent: number;
|
||||
blockOnZeroBalance: boolean;
|
||||
notifyOnWarning: boolean;
|
||||
notifyEmails: string[];
|
||||
billingAddress?: BillingAddress;
|
||||
}
|
||||
|
||||
export interface BillingSettingsUpdate {
|
||||
billingModel?: BillingModel;
|
||||
defaultUserCredit?: number;
|
||||
warningThresholdPercent?: number;
|
||||
blockOnZeroBalance?: boolean;
|
||||
notifyOnWarning?: boolean;
|
||||
notifyEmails?: string[];
|
||||
billingAddress?: BillingAddress;
|
||||
}
|
||||
|
||||
export interface UsageReport {
|
||||
|
|
@ -85,7 +71,6 @@ export interface AccountSummary {
|
|||
userId?: string;
|
||||
accountType: string;
|
||||
balance: number;
|
||||
creditLimit?: number;
|
||||
warningThreshold: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
|
@ -325,7 +310,6 @@ export interface MandateBalance {
|
|||
userCount: number;
|
||||
defaultUserCredit: number;
|
||||
warningThresholdPercent: number;
|
||||
blockOnZeroBalance: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -102,18 +102,20 @@ export async function fetchCurrentUser(
|
|||
|
||||
/**
|
||||
* 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(
|
||||
request: ApiRequestFunction,
|
||||
authAuthority: string = 'local'
|
||||
): Promise<void> {
|
||||
let endpoint = '/api/local/logout';
|
||||
|
||||
|
||||
if (authAuthority === 'msft') {
|
||||
endpoint = '/api/msft/logout';
|
||||
} else if (authAuthority === 'google') {
|
||||
endpoint = '/api/google/logout';
|
||||
}
|
||||
|
||||
|
||||
await request({
|
||||
url: endpoint,
|
||||
method: 'post'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
} else {
|
||||
const filteredAttrs = getFilteredAttributes();
|
||||
|
|
@ -967,9 +979,15 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
const inputType = attributeTypeToInputType(attr.type);
|
||||
|
||||
// For timestamp fields, convert Unix timestamp (float) to datetime-local format for display
|
||||
const displayValue = attr.type === 'timestamp'
|
||||
? timestampToDatetimeLocal(value)
|
||||
: (value || '');
|
||||
// Number: must not use (value || '') — 0 is valid and would show empty
|
||||
const displayValue =
|
||||
attr.type === 'timestamp'
|
||||
? timestampToDatetimeLocal(value)
|
||||
: isNumberType(attr.type as AttributeType)
|
||||
? value === '' || value === undefined || value === null || (typeof value === 'number' && Number.isNaN(value))
|
||||
? ''
|
||||
: String(value)
|
||||
: value || '';
|
||||
|
||||
return (
|
||||
<div className={styles.floatingLabelInput} key={attr.name}>
|
||||
|
|
|
|||
|
|
@ -103,6 +103,14 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Label-only row under a parent that shows an icon: indent so text lines up with the
|
||||
* parent's title, not with the icon column (see .nodeIcon width + .treeNode gap).
|
||||
*/
|
||||
.treeNodeAlignWithParentTitle {
|
||||
padding-left: calc(0.5rem + 0.875rem + 0.375rem);
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* DEPTH-SPECIFIC STYLES (via data-depth) */
|
||||
/* ============================================ */
|
||||
|
|
|
|||
|
|
@ -125,6 +125,8 @@ function isTreeSeparator(item: TreeItem): item is TreeSeparatorItem {
|
|||
interface TreeNodeProps {
|
||||
node: TreeNodeItem;
|
||||
level: number;
|
||||
/** True when the parent row shows an icon — used to align label-only children with the parent's title text. */
|
||||
parentHasIcon?: boolean;
|
||||
autoExpandActive: boolean;
|
||||
currentPath: string;
|
||||
onNodeClick?: (node: TreeNodeItem) => void;
|
||||
|
|
@ -134,6 +136,7 @@ interface TreeNodeProps {
|
|||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
level,
|
||||
parentHasIcon = false,
|
||||
autoExpandActive,
|
||||
currentPath,
|
||||
onNodeClick,
|
||||
|
|
@ -219,8 +222,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
|||
</>
|
||||
);
|
||||
|
||||
// Determine if we should render as NavLink or button
|
||||
const nodeClasses = `${styles.treeNode} ${isLeafActive ? styles.active : ''} ${isGroupActive ? styles.activeGroup : ''} ${node.disabled ? styles.disabled : ''} ${node.className || ''}`;
|
||||
// Unterknoten ohne Icon unter einem Knoten mit Icon: Text mit Eltern-Titel ausrichten (nicht mit Icon-Spalte)
|
||||
const alignLabelWithParentTitle = parentHasIcon && !node.icon;
|
||||
const nodeClasses = `${styles.treeNode} ${isLeafActive ? styles.active : ''} ${isGroupActive ? styles.activeGroup : ''} ${node.disabled ? styles.disabled : ''} ${alignLabelWithParentTitle ? styles.treeNodeAlignWithParentTitle : ''} ${node.className || ''}`;
|
||||
|
||||
const nodeElement = node.path ? (
|
||||
<NavLink
|
||||
|
|
@ -258,6 +262,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
|||
key={child.id || `${node.id}-child-${index}`}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
parentHasIcon={!!node.icon}
|
||||
autoExpandActive={autoExpandActive}
|
||||
currentPath={currentPath}
|
||||
onNodeClick={onNodeClick}
|
||||
|
|
@ -304,6 +309,7 @@ const TreeSection: React.FC<TreeSectionProps> = ({
|
|||
key={node.id || `section-${section.title}-${index}`}
|
||||
node={node}
|
||||
level={0}
|
||||
parentHasIcon={false}
|
||||
autoExpandActive={autoExpandActive}
|
||||
currentPath={currentPath}
|
||||
onNodeClick={onNodeClick}
|
||||
|
|
@ -355,6 +361,7 @@ export const TreeNavigation: React.FC<TreeNavigationProps> = ({
|
|||
key={item.id || `node-${index}`}
|
||||
node={item}
|
||||
level={0}
|
||||
parentHasIcon={false}
|
||||
autoExpandActive={autoExpandActive}
|
||||
currentPath={currentPath}
|
||||
onNodeClick={onNodeClick}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,11 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCurrentUser } from '../../hooks/useUsers';
|
||||
import { useMsal } from '@azure/msal-react';
|
||||
import { NotificationBell } from '../NotificationBell';
|
||||
import styles from './UserSection.module.css';
|
||||
|
||||
export const UserSection: React.FC = () => {
|
||||
const { user, logout } = useCurrentUser();
|
||||
const { instance: msalInstance } = useMsal();
|
||||
const navigate = useNavigate();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
|
@ -22,7 +20,7 @@ export const UserSection: React.FC = () => {
|
|||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
await logout(msalInstance);
|
||||
await logout();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
setIsLoggingOut(false);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export interface Message {
|
|||
role?: string;
|
||||
status?: string;
|
||||
sequenceNr?: number;
|
||||
/** ISO or number from API; workspace may use publishedAt only */
|
||||
createdAt?: number;
|
||||
publishedAt?: number;
|
||||
success?: boolean;
|
||||
actionId?: string;
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export function useMsalAuth() {
|
|||
|
||||
// Open popup window
|
||||
const popup = window.open(
|
||||
`${backendUrl}/api/msft/login?state=login`,
|
||||
`${backendUrl}/api/msft/auth/login`,
|
||||
'microsoft-login',
|
||||
'width=600,height=700,left=100,top=100'
|
||||
);
|
||||
|
|
@ -301,7 +301,7 @@ export function useGoogleAuth() {
|
|||
|
||||
// Open popup window
|
||||
const popup = window.open(
|
||||
`${backendUrl}/api/google/login?state=login`,
|
||||
`${backendUrl}/api/google/auth/login`,
|
||||
'google-login',
|
||||
'width=600,height=700,left=100,top=100'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -169,59 +169,6 @@ export function useBillingAdmin(mandateId?: string) {
|
|||
}
|
||||
}, [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
|
||||
const loadAccounts = useCallback(async (targetMandateId?: string) => {
|
||||
const mId = targetMandateId || mandateId;
|
||||
|
|
@ -270,6 +217,70 @@ export function useBillingAdmin(mandateId?: string) {
|
|||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (mandateId) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* Folgt dem gleichen Pattern wie useOrgUsers.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useApiRequest } from './useApi';
|
||||
import api from '../api';
|
||||
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||
|
|
@ -19,6 +19,8 @@ import {
|
|||
type MandateUpdateData,
|
||||
type PaginationParams
|
||||
} from '../api/mandateApi';
|
||||
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
|
||||
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
|
||||
|
||||
// Re-export types
|
||||
export type { Mandate, MandateUpdateData, PaginationParams };
|
||||
|
|
@ -164,14 +166,14 @@ export function useAdminMandates() {
|
|||
}));
|
||||
|
||||
// Create mandate
|
||||
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<boolean> => {
|
||||
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => {
|
||||
try {
|
||||
await createMandateApi(request, mandateData);
|
||||
const created = await createMandateApi(request, mandateData);
|
||||
await fetchMandates();
|
||||
return true;
|
||||
return created ?? null;
|
||||
} catch (error: any) {
|
||||
console.error('Error creating mandate:', error);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}, [request, fetchMandates]);
|
||||
|
||||
|
|
@ -235,3 +237,80 @@ export function 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 api from '../api';
|
||||
|
||||
const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']);
|
||||
|
||||
// Types
|
||||
export interface NotificationAction {
|
||||
actionId: string;
|
||||
|
|
@ -51,6 +53,7 @@ export function useNotifications() {
|
|||
|
||||
// Polling interval ref
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const prevUnreadCountRef = useRef<number | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch all notifications for the current user
|
||||
|
|
@ -90,6 +93,26 @@ export function useNotifications() {
|
|||
try {
|
||||
const response = await api.get('/api/notifications/unread-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);
|
||||
return count;
|
||||
} catch (err: any) {
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ export function useCurrentUser() {
|
|||
}
|
||||
};
|
||||
|
||||
const logout = async (msalInstance?: any) => {
|
||||
const logout = async () => {
|
||||
if (!user) {
|
||||
throw new Error('No user to logout');
|
||||
}
|
||||
|
|
@ -160,8 +160,7 @@ export function useCurrentUser() {
|
|||
// Clear user state after successful logout
|
||||
setUser(null);
|
||||
|
||||
// CRITICAL: Clear all authentication data BEFORE any redirects
|
||||
// This ensures cleanup happens even if MSAL redirect interrupts the process
|
||||
// Clear client-side auth hints; gateway session ended via API (cookies cleared by backend).
|
||||
console.log('🧹 Starting comprehensive cleanup...');
|
||||
|
||||
// Clear user data cache from sessionStorage
|
||||
|
|
@ -170,43 +169,15 @@ export function useCurrentUser() {
|
|||
// Clear auth authority from sessionStorage
|
||||
sessionStorage.removeItem('auth_authority');
|
||||
|
||||
// Clear MSAL cache tokens from localStorage
|
||||
// MSAL stores tokens with keys starting with 'msal.'
|
||||
const keysToRemove = [];
|
||||
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++) {
|
||||
// Optional: clear MSAL browser cache only (PowerOn JWT lives in httpOnly cookies + backend).
|
||||
// Do not call msal.logoutRedirect — that signs the user out of Microsoft globally.
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith('msal.')) {
|
||||
msalKeysToRemove.push(key);
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
msalKeysToRemove.forEach(key => {
|
||||
console.log('🗑️ Removing MSAL cache:', 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)
|
||||
// Note: This only works for cookies that are accessible to JavaScript
|
||||
console.log('🍪 Checking cookies for cleanup...');
|
||||
|
|
@ -228,24 +199,7 @@ export function useCurrentUser() {
|
|||
console.log('🍪 Cookies after cleanup attempt:', document.cookie);
|
||||
|
||||
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
|
||||
console.log('🔄 Redirecting to login page...');
|
||||
window.location.href = '/login';
|
||||
|
|
|
|||
|
|
@ -94,11 +94,24 @@
|
|||
min-height: 0;
|
||||
position: relative;
|
||||
--mobile-topbar-height: 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Let child components handle their own scrolling for sticky headers */
|
||||
overflow: hidden;
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,10 @@ const MainLayoutInner: React.FC = () => {
|
|||
|
||||
<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 />
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import useNavigation from '../hooks/useNavigation';
|
||||
import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation';
|
||||
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
|
||||
// =============================================================================
|
||||
|
|
@ -89,7 +76,7 @@ export const DashboardPage: React.FC = () => {
|
|||
}
|
||||
|
||||
if (totalInstances === 0) {
|
||||
return <EmptyState />;
|
||||
return <Navigate to="/store" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
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 { useStore } from '../hooks/useStore';
|
||||
import type { StoreFeature } from '../api/storeApi';
|
||||
|
|
@ -16,6 +16,8 @@ import styles from './Store.module.css';
|
|||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||
automation: <FaCogs />,
|
||||
teamsbot: <FaHeadset />,
|
||||
workspace: <FaComments />,
|
||||
commcoach: <FaComments />,
|
||||
};
|
||||
|
||||
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.',
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -76,7 +76,12 @@ export const AccessManagementHub: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
fetchFeatures();
|
||||
fetchMandates().then(setMandates);
|
||||
fetchMandates().then(data => {
|
||||
setMandates(data);
|
||||
if (data.length > 0 && !selectedMandateId) {
|
||||
setSelectedMandateId(data[0].id);
|
||||
}
|
||||
});
|
||||
}, [fetchFeatures, fetchMandates]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,22 @@
|
|||
|
||||
.adminPage {
|
||||
padding: 1.5rem;
|
||||
/* Fill parent height and enable flex layout for sticky table headers */
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
/* Default: grow with content → scroll on MainLayout .outletShell (expandable panels, long pages). */
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -20,6 +31,7 @@
|
|||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ export const AdminAutomationEventsPage: React.FC = () => {
|
|||
], []);
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Automation Events</h1>
|
||||
|
|
|
|||
|
|
@ -62,7 +62,12 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
|||
// Load features, mandates, and attributes on mount
|
||||
useEffect(() => {
|
||||
fetchFeatures();
|
||||
fetchMandates().then(setMandates);
|
||||
fetchMandates().then(data => {
|
||||
setMandates(data);
|
||||
if (data.length > 0 && !selectedMandateId) {
|
||||
setSelectedMandateId(data[0].id);
|
||||
}
|
||||
});
|
||||
// Fetch FeatureInstance attributes from backend
|
||||
api.get('/api/attributes/FeatureInstance').then(response => {
|
||||
const attrs = response.data?.attributes || response.data || [];
|
||||
|
|
@ -327,7 +332,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
|||
|
||||
if (error && !selectedMandateId) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||
|
|
@ -340,7 +345,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Feature-Instanzen</h1>
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
});
|
||||
|
||||
setCombinedOptions(allOptions);
|
||||
if (allOptions.length > 0 && !selectedCombinedKey) {
|
||||
setSelectedCombinedKey(allOptions[0].combinedKey);
|
||||
}
|
||||
};
|
||||
|
||||
loadCombinedOptions();
|
||||
|
|
@ -387,7 +390,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
|
||||
if (error && !selectedCombinedKey) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||
|
|
@ -400,7 +403,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Feature Instanz Benutzer</h1>
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
|
||||
if (error && !selectedFeatureCode) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>{error}</p>
|
||||
|
|
@ -258,7 +258,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Feature Rollen & Rechte</h1>
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
|
||||
if (error && !selectedMandateId) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||
|
|
@ -255,7 +255,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Einladungen</h1>
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
|
||||
if (error && !selectedMandateId) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||
|
|
@ -307,7 +307,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Rollen</h1>
|
||||
|
|
|
|||
|
|
@ -4,19 +4,27 @@
|
|||
* 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 { 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 { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
export const AdminMandatesPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { request } = useApiRequest();
|
||||
const { showWarning, showSuccess } = useToast();
|
||||
const {
|
||||
mandates,
|
||||
attributes,
|
||||
columns,
|
||||
permissions,
|
||||
pagination,
|
||||
|
|
@ -31,53 +39,76 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
updateOptimistically,
|
||||
} = useAdminMandates();
|
||||
|
||||
// Form attributes from backend - filter for create/edit forms
|
||||
const formAttributes: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id'];
|
||||
return attributes
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({
|
||||
...attr,
|
||||
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 {
|
||||
formAttributes,
|
||||
createFormAttributes,
|
||||
formAttributesWithBilling,
|
||||
createFormAttributesWithBilling,
|
||||
loading: mandateAttrsLoading,
|
||||
} = useMandateFormAttributes();
|
||||
|
||||
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
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
const canUpdate = permissions?.update !== 'n';
|
||||
const canDelete = permissions?.delete !== 'n';
|
||||
|
||||
// Handle edit click
|
||||
// Handle edit click — load mandate + billing settings (separate persistence)
|
||||
const handleEditClick = async (mandate: Mandate) => {
|
||||
setEditingBillingWarning(null);
|
||||
const fullMandate = await fetchMandateById(mandate.id);
|
||||
if (fullMandate) {
|
||||
setEditingMandate(fullMandate);
|
||||
if (!fullMandate) return;
|
||||
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
|
||||
const handleCreateSubmit = async (data: Partial<Mandate>) => {
|
||||
const success = await handleCreate(data);
|
||||
if (success) {
|
||||
setShowCreateModal(false);
|
||||
// Handle create submit — POST mandate, then billing settings
|
||||
const handleCreateSubmit = async (data: Record<string, unknown>) => {
|
||||
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
||||
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);
|
||||
};
|
||||
|
||||
// Handle edit submit
|
||||
const handleEditSubmit = async (data: Partial<Mandate>) => {
|
||||
if (!editingMandate) return;
|
||||
const success = await handleUpdate(editingMandate.id, data);
|
||||
if (success) {
|
||||
setEditingMandate(null);
|
||||
// Handle edit submit — PUT mandate + POST billing settings
|
||||
const handleEditSubmit = async (data: Record<string, unknown>) => {
|
||||
if (!editingFormData?.id) return;
|
||||
const mandateId = String(editingFormData.id);
|
||||
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
||||
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)
|
||||
|
|
@ -91,7 +122,7 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler beim Laden der Mandanten: {error}</p>
|
||||
|
|
@ -104,7 +135,7 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Mandanten</h1>
|
||||
|
|
@ -212,14 +243,18 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
<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.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={createFormAttributes}
|
||||
attributes={createFormAttributesWithBilling}
|
||||
mode="create"
|
||||
onSubmit={handleCreateSubmit}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
|
|
@ -233,20 +268,29 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingMandate && (
|
||||
<div className={styles.modalOverlay} onClick={() => setEditingMandate(null)}>
|
||||
{editingFormData && (
|
||||
<div
|
||||
className={styles.modalOverlay}
|
||||
onClick={() => {
|
||||
setEditingFormData(null);
|
||||
setEditingBillingWarning(null);
|
||||
}}
|
||||
>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>Mandant bearbeiten</h2>
|
||||
<button
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => setEditingMandate(null)}
|
||||
onClick={() => {
|
||||
setEditingFormData(null);
|
||||
setEditingBillingWarning(null);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<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)' }}>
|
||||
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
||||
<span>
|
||||
|
|
@ -254,6 +298,14 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
{editingBillingWarning && (
|
||||
<div
|
||||
className={styles.infoBox}
|
||||
style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}
|
||||
>
|
||||
{editingBillingWarning}
|
||||
</div>
|
||||
)}
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
|
|
@ -261,11 +313,14 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
data={editingMandate}
|
||||
attributes={formAttributesWithBilling}
|
||||
data={editingFormData}
|
||||
mode="edit"
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={() => setEditingMandate(null)}
|
||||
onCancel={() => {
|
||||
setEditingFormData(null);
|
||||
setEditingBillingWarning(null);
|
||||
}}
|
||||
submitButtonText="Speichern"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -515,7 +515,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
|||
|
||||
if (error && !overview) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||
|
|
@ -531,7 +531,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Benutzer-Zugriffsübersicht</h1>
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ export const AdminUserMandatesPage: React.FC = () => {
|
|||
|
||||
if (error && !selectedMandateId) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||
|
|
@ -270,7 +270,7 @@ export const AdminUserMandatesPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Mandanten-Mitglieder</h1>
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ export const AdminUsersPage: React.FC = () => {
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler beim Laden der Benutzer: {error}</p>
|
||||
|
|
@ -138,7 +138,7 @@ export const AdminUsersPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Benutzer</h1>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ import {
|
|||
type Feature,
|
||||
} from '../../../hooks/useFeatureAccess';
|
||||
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';
|
||||
|
||||
const TOTAL_STEPS = 4;
|
||||
|
|
@ -25,12 +30,19 @@ interface RoleOption {
|
|||
}
|
||||
|
||||
export const AdminMandateWizardPage: React.FC = () => {
|
||||
const { showSuccess } = useToast();
|
||||
const { showSuccess, showWarning, showError } = useToast();
|
||||
const { request } = useApiRequest();
|
||||
const {
|
||||
createFormAttributes,
|
||||
createFormAttributesWithBilling,
|
||||
loading: mandateAttrLoading,
|
||||
} = useMandateFormAttributes();
|
||||
|
||||
const {
|
||||
fetchMandateUsers,
|
||||
addUserToMandate,
|
||||
removeUserFromMandate,
|
||||
updateUserRoles,
|
||||
fetchMandates: fetchMandatesList,
|
||||
fetchRoles: fetchMandateRolesList,
|
||||
fetchAllUsers,
|
||||
|
|
@ -44,6 +56,7 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
fetchInstanceUsers,
|
||||
addUserToInstance,
|
||||
removeUserFromInstance,
|
||||
updateInstanceUserRoles,
|
||||
fetchInstanceRoles: fetchInstanceRolesList,
|
||||
} = useFeatureAccess();
|
||||
|
||||
|
|
@ -56,14 +69,13 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
const [selectedMandate, setSelectedMandate] = useState<Record<string, any> | null>(null);
|
||||
const [isCreatingMandate, setIsCreatingMandate] = useState(false);
|
||||
const [mandateForm, setMandateForm] = useState({ name: '' });
|
||||
|
||||
// Step 2: Mandate Users
|
||||
const [mandateUsers, setMandateUsers] = useState<MandateUser[]>([]);
|
||||
const [allSystemUsers, setAllSystemUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]);
|
||||
const [mandateRoles, setMandateRoles] = useState<RoleOption[]>([]);
|
||||
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
|
||||
const [features, setFeatures] = useState<Feature[]>([]);
|
||||
|
|
@ -77,7 +89,12 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
const [instanceUsers, setInstanceUsers] = useState<FeatureAccessUser[]>([]);
|
||||
const [instanceRoles, setInstanceRoles] = useState<RoleOption[]>([]);
|
||||
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
|
||||
|
|
@ -126,6 +143,11 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
fetchFeatures().then(setFeatures);
|
||||
}, [fetchFeatures]);
|
||||
|
||||
useEffect(() => {
|
||||
setRoleEditContext(null);
|
||||
setRoleEditDraft([]);
|
||||
}, [step]);
|
||||
|
||||
// Step 2
|
||||
const loadMandateUsers = useCallback(async () => {
|
||||
if (!selectedMandate) return;
|
||||
|
|
@ -188,42 +210,69 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
// HANDLERS
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleCreateMandate = async () => {
|
||||
if (!mandateForm.name.trim()) { setError('Name ist erforderlich'); return; }
|
||||
const handleCreateMandate = async (data: Record<string, unknown>) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post('/api/mandates/', {
|
||||
name: mandateForm.name,
|
||||
enabled: true,
|
||||
});
|
||||
setSelectedMandate(response.data);
|
||||
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
||||
const body = {
|
||||
...mandatePayload,
|
||||
enabled: mandatePayload.enabled !== undefined ? mandatePayload.enabled : true,
|
||||
};
|
||||
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);
|
||||
showSuccess('Erstellt', 'Mandant erstellt');
|
||||
if (billingSaved) {
|
||||
showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert');
|
||||
}
|
||||
await loadMandates();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || err?.message || 'Fehler beim Erstellen');
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { detail?: string } }; message?: string };
|
||||
setError(e?.response?.data?.detail || e?.message || 'Fehler beim Erstellen');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMandateUser = async () => {
|
||||
if (!selectedMandate || !addMandateUserForm.userId) return;
|
||||
if (!selectedMandate || addMandateUserForm.userIds.length === 0) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const failures: string[] = [];
|
||||
let ok = 0;
|
||||
try {
|
||||
const result = await addUserToMandate(selectedMandate.id, {
|
||||
targetUserId: addMandateUserForm.userId,
|
||||
roleIds: addMandateUserForm.roleIds,
|
||||
});
|
||||
if (result.success) {
|
||||
for (const uid of addMandateUserForm.userIds) {
|
||||
const result = await addUserToMandate(selectedMandate.id, {
|
||||
targetUserId: uid,
|
||||
roleIds: addMandateUserForm.roleIds,
|
||||
});
|
||||
if (result.success) ok += 1;
|
||||
else failures.push(`${uid}: ${result.error || 'Fehler'}`);
|
||||
}
|
||||
if (ok > 0) {
|
||||
setIsAddingMandateUser(false);
|
||||
setAddMandateUserForm({ userId: '', roleIds: [] });
|
||||
showSuccess('Hinzugefügt', 'Benutzer zum Mandanten hinzugefügt');
|
||||
setAddMandateUserForm({ userIds: [], roleIds: [] });
|
||||
showSuccess('Hinzugefügt', `${ok} Benutzer zum Mandanten hinzugefügt`);
|
||||
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 {
|
||||
setIsLoading(false);
|
||||
|
|
@ -276,27 +325,76 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleAddInstanceUser = async () => {
|
||||
if (!selectedInstance || !selectedMandate || !addInstanceUserForm.userId) return;
|
||||
if (!selectedInstance || !selectedMandate || addInstanceUserForm.userIds.length === 0) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const failures: string[] = [];
|
||||
let ok = 0;
|
||||
try {
|
||||
const result = await addUserToInstance(selectedMandate.id, selectedInstance.id, {
|
||||
userId: addInstanceUserForm.userId,
|
||||
roleIds: addInstanceUserForm.roleIds,
|
||||
});
|
||||
if (result.success) {
|
||||
for (const uid of addInstanceUserForm.userIds) {
|
||||
const result = await addUserToInstance(selectedMandate.id, selectedInstance.id, {
|
||||
userId: uid,
|
||||
roleIds: addInstanceUserForm.roleIds,
|
||||
});
|
||||
if (result.success) ok += 1;
|
||||
else failures.push(`${uid}: ${result.error || 'Fehler'}`);
|
||||
}
|
||||
if (ok > 0) {
|
||||
setIsAddingInstanceUser(false);
|
||||
setAddInstanceUserForm({ userId: '', roleIds: [] });
|
||||
showSuccess('Hinzugefügt', 'Benutzer zur Feature-Instanz hinzugefügt');
|
||||
setAddInstanceUserForm({ userIds: [], roleIds: [] });
|
||||
showSuccess('Hinzugefügt', `${ok} Benutzer zur Feature-Instanz hinzugefügt`);
|
||||
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 {
|
||||
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) => {
|
||||
if (!selectedInstance || !selectedMandate) return;
|
||||
const result = await removeUserFromInstance(selectedMandate.id, selectedInstance.id, userId);
|
||||
|
|
@ -324,9 +422,34 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
// 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 = (
|
||||
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,
|
||||
options?: { onEditRoles?: (userId: string, currentRoleIds: string[]) => void },
|
||||
) => (
|
||||
users.length > 0 ? (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
|
|
@ -342,12 +465,30 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
<tbody>
|
||||
{users.map(u => {
|
||||
const uid = u.userId || u.id || '';
|
||||
const ids = u.roleIds || [];
|
||||
return (
|
||||
<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', color: 'var(--text-secondary)' }}>{u.email || '-'}</td>
|
||||
<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 style={{ padding: '8px 12px', textAlign: 'center' }}>
|
||||
<span className={styles.badge} style={{
|
||||
|
|
@ -383,35 +524,55 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
const renderAddUserForm = (
|
||||
availableUsers: Array<{ id?: string; userId?: string; username: string; email?: string | null; fullName?: string; firstname?: string | null; lastname?: string | null }>,
|
||||
roles: RoleOption[],
|
||||
formValue: { userId: string; roleIds: string[] },
|
||||
setFormValue: (fn: (prev: { userId: string; roleIds: string[] }) => { userId: string; roleIds: string[] }) => void,
|
||||
formValue: { userIds: string[]; roleIds: string[] },
|
||||
setFormValue: (fn: (prev: { userIds: string[]; roleIds: string[] }) => { userIds: string[]; roleIds: string[] }) => void,
|
||||
onSubmit: () => void,
|
||||
onCancel: () => void,
|
||||
) => (
|
||||
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label className={styles.formLabel}>Benutzer *</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
style={{ width: '100%' }}
|
||||
value={formValue.userId}
|
||||
onChange={e => setFormValue(p => ({ ...p, userId: e.target.value }))}
|
||||
<label className={styles.formLabel}>Benutzer * (mehrfach möglich)</label>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '220px',
|
||||
overflowY: 'auto',
|
||||
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 => {
|
||||
const uid = u.userId || u.id || '';
|
||||
const name = getUserDisplayName(u as any);
|
||||
return (
|
||||
<option key={uid} value={uid}>
|
||||
{u.username} {u.email ? `(${u.email})` : ''} {name !== u.username ? `- ${name}` : ''}
|
||||
</option>
|
||||
<label key={uid} className={styles.checkboxLabel} style={{ alignItems: 'flex-start' }}>
|
||||
<input
|
||||
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>
|
||||
{roles.length > 0 && (
|
||||
<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' }}>
|
||||
{roles.map(r => (
|
||||
<label key={r.id} className={styles.checkboxLabel}>
|
||||
|
|
@ -434,7 +595,11 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
<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'}
|
||||
</button>
|
||||
<button className={styles.secondaryButton} onClick={onCancel}>
|
||||
|
|
@ -554,21 +719,21 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
</>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={`${styles.formLabel} ${styles.required}`}>Name</label>
|
||||
<input
|
||||
className={styles.formInput}
|
||||
value={mandateForm.name}
|
||||
onChange={e => setMandateForm(p => ({ ...p, name: e.target.value }))}
|
||||
placeholder="z.B. Swiss Trust AG"
|
||||
{mandateAttrLoading || createFormAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer} style={{ padding: '24px' }}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Formular wird geladen...</span>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -606,11 +771,64 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
addMandateUserForm,
|
||||
setAddMandateUserForm,
|
||||
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' }}>
|
||||
{renderUserTable(mandateUsers as any[], handleRemoveMandateUser)}
|
||||
{renderUserTable(mandateUsers as WizardUserRow[], mandateRoles, handleRemoveMandateUser, {
|
||||
onEditRoles: (userId, ids) => {
|
||||
setError(null);
|
||||
setRoleEditContext({ scope: 'mandate', userId });
|
||||
setRoleEditDraft([...ids]);
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
|
||||
|
|
@ -770,13 +988,68 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
addInstanceUserForm,
|
||||
setAddInstanceUserForm,
|
||||
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' }}>
|
||||
{renderUserTable(
|
||||
instanceUsers.map(u => ({ ...u, userId: u.userId || u.id })),
|
||||
instanceUsers.map(u => ({ ...u, userId: u.userId || u.id })) as WizardUserRow[],
|
||||
instanceRoles,
|
||||
handleRemoveInstanceUser,
|
||||
{
|
||||
onEditRoles: (userId, ids) => {
|
||||
setError(null);
|
||||
setRoleEditContext({ scope: 'instance', userId });
|
||||
setRoleEditDraft([...ids]);
|
||||
},
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import React, { useState, useMemo, useEffect } from 'react';
|
|||
import { useConnections, type Connection } from '../../hooks/useConnections';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaSync, FaPlug, FaGoogle, FaMicrosoft, FaLink, FaRedo } from 'react-icons/fa';
|
||||
import { FaSync, FaPlug, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt } from 'react-icons/fa';
|
||||
import { getApiBaseUrl } from '../../../config/config';
|
||||
import styles from '../admin/Admin.module.css';
|
||||
|
||||
export const ConnectionsPage: React.FC = () => {
|
||||
|
|
@ -37,6 +38,7 @@ export const ConnectionsPage: React.FC = () => {
|
|||
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
|
||||
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
|
||||
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
|
||||
const [adminConsentPending, setAdminConsentPending] = useState(false);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
|
|
@ -182,6 +184,24 @@ export const ConnectionsPage: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Open Microsoft Admin Consent flow in a popup
|
||||
const handleAdminConsent = () => {
|
||||
setAdminConsentPending(true);
|
||||
const consentUrl = `${getApiBaseUrl()}/api/msft/adminconsent`;
|
||||
const popup = window.open(consentUrl, 'msft-admin-consent', 'width=600,height=700,scrollbars=yes,resizable=yes');
|
||||
if (!popup) {
|
||||
setAdminConsentPending(false);
|
||||
return;
|
||||
}
|
||||
const checkClosed = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
clearInterval(checkClosed);
|
||||
setAdminConsentPending(false);
|
||||
refetch();
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Form attributes for edit modal
|
||||
const formAttributes = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'userId', '_createdBy', '_createdAt', '_modifiedAt', 'connectedAt', 'lastChecked'];
|
||||
|
|
@ -204,13 +224,21 @@ export const ConnectionsPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Verbindungen</h1>
|
||||
<p className={styles.pageSubtitle}>OAuth-Verbindungen verwalten</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={handleAdminConsent}
|
||||
disabled={adminConsentPending}
|
||||
title="Microsoft Admin Consent — erteilt der App die nötigen Berechtigungen für den gesamten Tenant"
|
||||
>
|
||||
<FaShieldAlt /> Admin Consent
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => refetch()}
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ export const FilesPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
|
|
|
|||
|
|
@ -8,8 +8,13 @@
|
|||
*/
|
||||
|
||||
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 { 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';
|
||||
|
||||
const _formatCurrency = (amount: number) => {
|
||||
|
|
@ -19,37 +24,49 @@ const _formatCurrency = (amount: number) => {
|
|||
}).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
|
||||
// ============================================================================
|
||||
|
||||
interface MandateSelectorProps {
|
||||
mandates: UserMandateRow[];
|
||||
loading: boolean;
|
||||
selectedMandateId: string | null;
|
||||
onSelect: (mandateId: string) => void;
|
||||
}
|
||||
|
||||
const MandateSelector: React.FC<MandateSelectorProps> = ({ selectedMandateId, onSelect }) => {
|
||||
const { mandates, loading } = useAdminMandates();
|
||||
|
||||
return (
|
||||
<div className={styles.formGroup}>
|
||||
<label>Mandant auswählen</label>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={selectedMandateId || ''}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">-- Mandant wählen --</option>
|
||||
{mandates.map((mandate) => (
|
||||
<option key={mandate.id} value={mandate.id}>
|
||||
{mandate.label || mandate.name || mandate.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const MandateSelector: React.FC<MandateSelectorProps> = ({
|
||||
mandates,
|
||||
loading,
|
||||
selectedMandateId,
|
||||
onSelect,
|
||||
}) => (
|
||||
<div className={styles.formGroup}>
|
||||
<label>Mandant auswählen</label>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={selectedMandateId || ''}
|
||||
onChange={e => onSelect(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">-- Mandant wählen --</option>
|
||||
{mandates.map(mandate => (
|
||||
<option key={mandate.id} value={mandate.id}>
|
||||
{_mandateDisplayLabel(mandate)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// SETTINGS EDITOR
|
||||
|
|
@ -63,10 +80,9 @@ interface SettingsEditorProps {
|
|||
|
||||
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
billingModel: settings?.billingModel || 'UNLIMITED',
|
||||
defaultUserCredit: settings?.defaultUserCredit || 10,
|
||||
warningThresholdPercent: settings?.warningThresholdPercent || 10,
|
||||
blockOnZeroBalance: settings?.blockOnZeroBalance ?? true,
|
||||
billingModel: (settings?.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE') as BillingSettings['billingModel'],
|
||||
defaultUserCredit: Number(settings?.defaultUserCredit ?? 0),
|
||||
warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
|
||||
notifyOnWarning: settings?.notifyOnWarning ?? true,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
|
@ -75,11 +91,10 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
|||
useEffect(() => {
|
||||
if (settings) {
|
||||
setFormData({
|
||||
billingModel: settings.billingModel,
|
||||
defaultUserCredit: settings.defaultUserCredit,
|
||||
warningThresholdPercent: settings.warningThresholdPercent,
|
||||
blockOnZeroBalance: settings.blockOnZeroBalance,
|
||||
notifyOnWarning: settings.notifyOnWarning,
|
||||
billingModel: settings.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE',
|
||||
defaultUserCredit: Number(settings.defaultUserCredit ?? 0),
|
||||
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
|
||||
notifyOnWarning: settings.notifyOnWarning ?? true,
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
|
|
@ -116,12 +131,10 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
|||
<select
|
||||
className={styles.select}
|
||||
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_USER">Prepaid (Benutzer)</option>
|
||||
<option value="CREDIT_POSTPAY">Kredit (Postpay)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -151,18 +164,6 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
|||
step="1"
|
||||
/>
|
||||
</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 className={styles.formRow}>
|
||||
|
|
@ -363,7 +364,6 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
|
|||
<div className={styles.accountInfo}>
|
||||
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
|
||||
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
||||
{account.creditLimit && <span>Limit: {formatCurrency(account.creditLimit)}</span>}
|
||||
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
|
||||
</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
|
||||
// ============================================================================
|
||||
|
||||
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 { 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) => {
|
||||
setSelectedMandateId(mandateId || null);
|
||||
|
|
@ -398,47 +498,172 @@ export const BillingAdmin: React.FC = () => {
|
|||
return result;
|
||||
}, [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 (
|
||||
<div className={styles.billingDashboard}>
|
||||
<header className={styles.pageHeader}>
|
||||
<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>
|
||||
|
||||
|
||||
{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}>
|
||||
<MandateSelector
|
||||
selectedMandateId={selectedMandateId}
|
||||
onSelect={handleMandateSelect}
|
||||
<MandateSelector
|
||||
mandates={mandateList}
|
||||
loading={mandatesLoading}
|
||||
selectedMandateId={selectedMandateId}
|
||||
onSelect={handleMandateSelect}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
{selectedMandateId && (
|
||||
<>
|
||||
<SettingsEditor
|
||||
settings={settings}
|
||||
onSave={handleSaveSettings}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<CreditAdder
|
||||
settings={settings}
|
||||
accounts={accounts}
|
||||
users={users}
|
||||
onAddCredit={_handleAddCredit}
|
||||
/>
|
||||
|
||||
<AccountsOverview
|
||||
accounts={accounts}
|
||||
users={users}
|
||||
loading={loading}
|
||||
/>
|
||||
{isSysAdmin && (
|
||||
<SettingsEditor
|
||||
settings={settings}
|
||||
onSave={handleSaveSettings}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSysAdmin && (
|
||||
<CreditAdder
|
||||
settings={settings}
|
||||
accounts={accounts}
|
||||
users={users}
|
||||
onAddCredit={_handleAddCredit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showStripeForMandateAdmin && (
|
||||
<MandateStripeTopUp mandateId={selectedMandateId} createCheckout={createCheckout} />
|
||||
)}
|
||||
|
||||
<AccountsOverview accounts={accounts} users={users} loading={loading} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{!selectedMandateId && (
|
||||
<div className={styles.noData}>
|
||||
Bitte wählen Sie einen Mandanten aus.
|
||||
</div>
|
||||
<div className={styles.noData}>Bitte wählen Sie einen Mandanten aus.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,13 +27,8 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
|
|||
};
|
||||
|
||||
const getBillingModelLabel = (model: string) => {
|
||||
switch (model) {
|
||||
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
|
||||
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
|
||||
case 'CREDIT_POSTPAY': return 'Kredit';
|
||||
case 'UNLIMITED': return 'Unlimited';
|
||||
default: return model;
|
||||
}
|
||||
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
||||
return 'Prepaid (Mandant)';
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -61,18 +61,12 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
|
|||
const [showCheckout, setShowCheckout] = useState(false);
|
||||
|
||||
const _getBillingModelLabel = (model: string) => {
|
||||
switch (model) {
|
||||
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
|
||||
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
|
||||
case 'CREDIT_POSTPAY': return 'Kredit';
|
||||
case 'UNLIMITED': return 'Unlimited';
|
||||
default: return model;
|
||||
}
|
||||
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
||||
return 'Prepaid (Mandant)';
|
||||
};
|
||||
|
||||
const canTopUp = balance.billingModel === 'PREPAY_USER'
|
||||
|| balance.billingModel === 'PREPAY_MANDATE'
|
||||
|| balance.billingModel === 'CREDIT_POSTPAY';
|
||||
|| balance.billingModel === 'PREPAY_MANDATE';
|
||||
|
||||
return (
|
||||
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
||||
|
|
|
|||
|
|
@ -39,13 +39,8 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
|
|||
};
|
||||
|
||||
const getBillingModelLabel = (model: string) => {
|
||||
switch (model) {
|
||||
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
|
||||
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
|
||||
case 'CREDIT_POSTPAY': return 'Kredit';
|
||||
case 'UNLIMITED': return 'Unlimited';
|
||||
default: return model;
|
||||
}
|
||||
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
||||
return 'Prepaid (Mandant)';
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -433,7 +433,7 @@ export const AutomationDefinitionsView: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Automatisierungen</h1>
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export const AutomationTemplatesView: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Automation-Vorlagen</h1>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,18 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
|
|||
<span>{msg.message}</span>
|
||||
) : (
|
||||
<div className="workspace-markdown">
|
||||
{msg.documentsLabel && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: msg.message ? 8 : 0,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
{msg.documentsLabel}
|
||||
</div>
|
||||
)}
|
||||
{msg.message && (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import api from '../../../api';
|
||||
import { startSseStream, SseEvent } from '../../../utils/sseClient';
|
||||
import type { Message } from '../../../components/UiComponents/Messages/MessagesTypes';
|
||||
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
|
||||
|
||||
export interface AgentProgress {
|
||||
round: number;
|
||||
|
|
@ -173,13 +173,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
|
||||
api.get(`/api/workspace/${instanceId}/workflows/${wfId}/messages`)
|
||||
.then(res => {
|
||||
const msgs = (res.data.messages || []).map((m: any) => ({
|
||||
id: m.id || `loaded-${Math.random()}`,
|
||||
workflowId: wfId,
|
||||
role: m.role || 'assistant',
|
||||
message: m.content || m.message || '',
|
||||
publishedAt: m.createdAt || Date.now() / 1000,
|
||||
}));
|
||||
const msgs = (res.data.messages || [])
|
||||
.map((m: any) => _mapLoadedWorkspaceMessage(m, wfId))
|
||||
.sort(_compareWorkspaceMessages);
|
||||
setMessages(msgs);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
|
@ -210,6 +206,13 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
role: 'user',
|
||||
message: prompt,
|
||||
publishedAt: Date.now() / 1000,
|
||||
documents: _documentsFromFileIds(files, fileIds),
|
||||
documentsLabel: _attachmentLabelFromContext(
|
||||
dataSourceIds,
|
||||
featureDataSourceIds,
|
||||
dataSources,
|
||||
featureDataSources,
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
@ -351,13 +354,27 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
onStopped: () => setIsProcessing(false),
|
||||
onError: (event) => {
|
||||
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 => [
|
||||
...prev,
|
||||
{
|
||||
id: `error-${Date.now()}`,
|
||||
workflowId: '',
|
||||
role: 'system',
|
||||
message: `Error: ${event.content || 'Unknown error'}`,
|
||||
message: msg,
|
||||
publishedAt: Date.now() / 1000,
|
||||
},
|
||||
]);
|
||||
|
|
@ -379,7 +396,15 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
onStreamEnd: () => setIsProcessing(false),
|
||||
});
|
||||
},
|
||||
[instanceId, isProcessing, workflowId, refreshFiles],
|
||||
[
|
||||
instanceId,
|
||||
isProcessing,
|
||||
workflowId,
|
||||
refreshFiles,
|
||||
files,
|
||||
dataSources,
|
||||
featureDataSources,
|
||||
],
|
||||
);
|
||||
|
||||
const stopProcessing = useCallback(() => {
|
||||
|
|
@ -454,6 +479,94 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: loaded message mapping & attachment display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _mapLoadedWorkspaceMessage(m: Record<string, unknown>, wfId: string): Message {
|
||||
const publishedAt =
|
||||
(typeof m.publishedAt === 'number' ? m.publishedAt : undefined) ??
|
||||
(typeof m.createdAt === 'number' ? m.createdAt : undefined) ??
|
||||
Date.now() / 1000;
|
||||
const docsRaw = Array.isArray(m.documents) ? m.documents : [];
|
||||
const documents: MessageDocument[] = docsRaw.map((d: any) => ({
|
||||
id: String(d.id || `doc-${d.fileId}`),
|
||||
messageId: String(d.messageId || ''),
|
||||
fileId: String(d.fileId || ''),
|
||||
fileName: String(d.fileName || ''),
|
||||
mimeType: String(d.mimeType || 'application/octet-stream'),
|
||||
fileSize: Number(d.fileSize || 0),
|
||||
roundNumber: Number(d.roundNumber ?? 0),
|
||||
taskNumber: Number(d.taskNumber ?? 0),
|
||||
actionNumber: Number(d.actionNumber ?? 0),
|
||||
actionId: String(d.actionId || ''),
|
||||
}));
|
||||
return {
|
||||
id: String(m.id || `loaded-${Math.random()}`),
|
||||
workflowId: wfId,
|
||||
role: String(m.role || 'assistant'),
|
||||
message: String(m.content ?? m.message ?? ''),
|
||||
publishedAt,
|
||||
sequenceNr: typeof m.sequenceNr === 'number' ? m.sequenceNr : undefined,
|
||||
documents: documents.length ? documents : undefined,
|
||||
documentsLabel: typeof m.documentsLabel === 'string' ? m.documentsLabel : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function _compareWorkspaceMessages(a: Message, b: Message): number {
|
||||
const ta = (a.publishedAt || 0) - (b.publishedAt || 0);
|
||||
if (ta !== 0) return ta;
|
||||
const sa = (a.sequenceNr ?? 0) - (b.sequenceNr ?? 0);
|
||||
if (sa !== 0) return sa;
|
||||
return String(a.id).localeCompare(String(b.id));
|
||||
}
|
||||
|
||||
function _documentsFromFileIds(files: WorkspaceFile[], fileIds: string[]): MessageDocument[] | undefined {
|
||||
const out: MessageDocument[] = [];
|
||||
for (const fid of fileIds) {
|
||||
const f = files.find(x => x.id === fid);
|
||||
if (f) {
|
||||
out.push({
|
||||
id: `local-${fid}-${Date.now()}`,
|
||||
messageId: '',
|
||||
fileId: f.id,
|
||||
fileName: f.fileName,
|
||||
mimeType: f.mimeType,
|
||||
fileSize: f.fileSize,
|
||||
roundNumber: 0,
|
||||
taskNumber: 0,
|
||||
actionNumber: 0,
|
||||
actionId: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return out.length ? out : undefined;
|
||||
}
|
||||
|
||||
function _attachmentLabelFromContext(
|
||||
dataSourceIds: string[],
|
||||
featureDataSourceIds: string[],
|
||||
dataSources: DataSource[],
|
||||
featureDataSources: FeatureDataSource[],
|
||||
): string | undefined {
|
||||
const parts: string[] = [];
|
||||
const dsLabels = dataSourceIds
|
||||
.map(id => {
|
||||
const ds = dataSources.find(d => d.id === id);
|
||||
return ds?.label || ds?.path;
|
||||
})
|
||||
.filter((x): x is string => Boolean(x));
|
||||
if (dsLabels.length) parts.push(`Datenquellen: ${dsLabels.join(', ')}`);
|
||||
const fdsLabels = featureDataSourceIds
|
||||
.map(id => {
|
||||
const fds = featureDataSources.find(x => x.id === id);
|
||||
return fds ? `${fds.tableName} (${fds.label})` : '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (fdsLabels.length) parts.push(`Feature-Daten: ${fdsLabels.join(', ')}`);
|
||||
return parts.length ? parts.join(' | ') : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal event handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
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