Merge pull request #21 from valueonag/int

Int
This commit is contained in:
Patrick Motsch 2026-03-22 01:28:21 +01:00 committed by GitHub
commit 708687a5e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1694 additions and 416 deletions

View 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 45 ergänzen.*

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

View file

@ -41,7 +41,7 @@ import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin } from './pages/billing'; import { BillingDataView, BillingAdmin, BillingMandateView } from './pages/billing';
function App() { function App() {
// Load saved theme preference and set app name on app mount // Load saved theme preference and set app name on app mount
useEffect(() => { useEffect(() => {
@ -185,7 +185,10 @@ function App() {
<Route path="mandate-roles" element={<AdminMandateRolesPage />} /> <Route path="mandate-roles" element={<AdminMandateRolesPage />} />
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} /> <Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} /> <Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
<Route path="billing" element={<BillingAdmin />} /> <Route path="billing">
<Route index element={<BillingAdmin />} />
<Route path="mandates" element={<BillingMandateView />} />
</Route>
<Route path="automation-events" element={<AdminAutomationEventsPage />} /> <Route path="automation-events" element={<AdminAutomationEventsPage />} />
<Route path="logs" element={<AdminLogsPage />} /> <Route path="logs" element={<AdminLogsPage />} />
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} /> <Route path="mandate-wizard" element={<AdminMandateWizardPage />} />

View file

@ -126,9 +126,10 @@ api.interceptors.response.use(
async (error) => { async (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
// Don't redirect to login if the request was to a login endpoint // Don't redirect to login if the request was to a login endpoint
const isLoginEndpoint = error.config?.url?.includes('/login') || const isLoginEndpoint = error.config?.url?.includes('/login') ||
error.config?.url?.includes('/api/local/login') || error.config?.url?.includes('/api/local/login') ||
error.config?.url?.includes('/api/msft/login'); error.config?.url?.includes('/api/msft/auth/login') ||
error.config?.url?.includes('/api/google/auth/login');
// Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work) // Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work)
const pathname = window.location.pathname; const pathname = window.location.pathname;

View file

@ -4,19 +4,10 @@ import { ApiRequestOptions } from '../hooks/useApi';
// TYPES & INTERFACES // TYPES & INTERFACES
// ============================================================================ // ============================================================================
export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER' | 'CREDIT_POSTPAY' | 'UNLIMITED'; export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER';
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT'; export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM'; export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
export interface BillingAddress {
company: string;
street: string;
zip: string;
city: string;
country: string;
vatNumber?: string;
}
export interface BillingBalance { export interface BillingBalance {
mandateId: string; mandateId: string;
mandateName: string; mandateName: string;
@ -25,7 +16,6 @@ export interface BillingBalance {
currency: string; currency: string;
warningThreshold: number; warningThreshold: number;
isWarning: boolean; isWarning: boolean;
creditLimit?: number;
} }
export interface BillingTransaction { export interface BillingTransaction {
@ -54,20 +44,16 @@ export interface BillingSettings {
billingModel: BillingModel; billingModel: BillingModel;
defaultUserCredit: number; defaultUserCredit: number;
warningThresholdPercent: number; warningThresholdPercent: number;
blockOnZeroBalance: boolean;
notifyOnWarning: boolean; notifyOnWarning: boolean;
notifyEmails: string[]; notifyEmails: string[];
billingAddress?: BillingAddress;
} }
export interface BillingSettingsUpdate { export interface BillingSettingsUpdate {
billingModel?: BillingModel; billingModel?: BillingModel;
defaultUserCredit?: number; defaultUserCredit?: number;
warningThresholdPercent?: number; warningThresholdPercent?: number;
blockOnZeroBalance?: boolean;
notifyOnWarning?: boolean; notifyOnWarning?: boolean;
notifyEmails?: string[]; notifyEmails?: string[];
billingAddress?: BillingAddress;
} }
export interface UsageReport { export interface UsageReport {
@ -85,7 +71,6 @@ export interface AccountSummary {
userId?: string; userId?: string;
accountType: string; accountType: string;
balance: number; balance: number;
creditLimit?: number;
warningThreshold: number; warningThreshold: number;
enabled: boolean; enabled: boolean;
} }
@ -325,7 +310,6 @@ export interface MandateBalance {
userCount: number; userCount: number;
defaultUserCredit: number; defaultUserCredit: number;
warningThresholdPercent: number; warningThresholdPercent: number;
blockOnZeroBalance: boolean;
} }
/** /**

View file

@ -102,18 +102,20 @@ export async function fetchCurrentUser(
/** /**
* Logout current user * Logout current user
* Endpoint: POST /api/local/logout | /api/msft/logout * Endpoint: POST /api/local/logout | /api/msft/logout | /api/google/logout
*/ */
export async function logoutUser( export async function logoutUser(
request: ApiRequestFunction, request: ApiRequestFunction,
authAuthority: string = 'local' authAuthority: string = 'local'
): Promise<void> { ): Promise<void> {
let endpoint = '/api/local/logout'; let endpoint = '/api/local/logout';
if (authAuthority === 'msft') { if (authAuthority === 'msft') {
endpoint = '/api/msft/logout'; endpoint = '/api/msft/logout';
} else if (authAuthority === 'google') {
endpoint = '/api/google/logout';
} }
await request({ await request({
url: endpoint, url: endpoint,
method: 'post' method: 'post'

View file

@ -250,6 +250,18 @@ export function FormGeneratorForm<T extends Record<string, any>>({
} }
} }
}); });
// Number/float fields: avoid empty inputs — use attribute default or type default (e.g. 0)
filteredAttrs.forEach(attr => {
if (isNumberType(attr.type as AttributeType)) {
const v = processedData[attr.name];
if (v === undefined || v === null || v === '') {
processedData[attr.name] =
attr.default !== undefined
? attr.default
: getDefaultValueForType(attr.type as AttributeType);
}
}
});
setFormData(processedData as T); setFormData(processedData as T);
} else { } else {
const filteredAttrs = getFilteredAttributes(); const filteredAttrs = getFilteredAttributes();
@ -967,9 +979,15 @@ export function FormGeneratorForm<T extends Record<string, any>>({
const inputType = attributeTypeToInputType(attr.type); const inputType = attributeTypeToInputType(attr.type);
// For timestamp fields, convert Unix timestamp (float) to datetime-local format for display // For timestamp fields, convert Unix timestamp (float) to datetime-local format for display
const displayValue = attr.type === 'timestamp' // Number: must not use (value || '') — 0 is valid and would show empty
? timestampToDatetimeLocal(value) const displayValue =
: (value || ''); 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 ( return (
<div className={styles.floatingLabelInput} key={attr.name}> <div className={styles.floatingLabelInput} key={attr.name}>

View file

@ -103,6 +103,14 @@
pointer-events: none; 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) */ /* DEPTH-SPECIFIC STYLES (via data-depth) */
/* ============================================ */ /* ============================================ */

View file

@ -125,6 +125,8 @@ function isTreeSeparator(item: TreeItem): item is TreeSeparatorItem {
interface TreeNodeProps { interface TreeNodeProps {
node: TreeNodeItem; node: TreeNodeItem;
level: number; 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; autoExpandActive: boolean;
currentPath: string; currentPath: string;
onNodeClick?: (node: TreeNodeItem) => void; onNodeClick?: (node: TreeNodeItem) => void;
@ -134,6 +136,7 @@ interface TreeNodeProps {
const TreeNode: React.FC<TreeNodeProps> = ({ const TreeNode: React.FC<TreeNodeProps> = ({
node, node,
level, level,
parentHasIcon = false,
autoExpandActive, autoExpandActive,
currentPath, currentPath,
onNodeClick, onNodeClick,
@ -219,8 +222,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
</> </>
); );
// Determine if we should render as NavLink or button // Unterknoten ohne Icon unter einem Knoten mit Icon: Text mit Eltern-Titel ausrichten (nicht mit Icon-Spalte)
const nodeClasses = `${styles.treeNode} ${isLeafActive ? styles.active : ''} ${isGroupActive ? styles.activeGroup : ''} ${node.disabled ? styles.disabled : ''} ${node.className || ''}`; 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 ? ( const nodeElement = node.path ? (
<NavLink <NavLink
@ -258,6 +262,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
key={child.id || `${node.id}-child-${index}`} key={child.id || `${node.id}-child-${index}`}
node={child} node={child}
level={level + 1} level={level + 1}
parentHasIcon={!!node.icon}
autoExpandActive={autoExpandActive} autoExpandActive={autoExpandActive}
currentPath={currentPath} currentPath={currentPath}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
@ -304,6 +309,7 @@ const TreeSection: React.FC<TreeSectionProps> = ({
key={node.id || `section-${section.title}-${index}`} key={node.id || `section-${section.title}-${index}`}
node={node} node={node}
level={0} level={0}
parentHasIcon={false}
autoExpandActive={autoExpandActive} autoExpandActive={autoExpandActive}
currentPath={currentPath} currentPath={currentPath}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
@ -355,6 +361,7 @@ export const TreeNavigation: React.FC<TreeNavigationProps> = ({
key={item.id || `node-${index}`} key={item.id || `node-${index}`}
node={item} node={item}
level={0} level={0}
parentHasIcon={false}
autoExpandActive={autoExpandActive} autoExpandActive={autoExpandActive}
currentPath={currentPath} currentPath={currentPath}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}

View file

@ -7,13 +7,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useCurrentUser } from '../../hooks/useUsers'; import { useCurrentUser } from '../../hooks/useUsers';
import { useMsal } from '@azure/msal-react';
import { NotificationBell } from '../NotificationBell'; import { NotificationBell } from '../NotificationBell';
import styles from './UserSection.module.css'; import styles from './UserSection.module.css';
export const UserSection: React.FC = () => { export const UserSection: React.FC = () => {
const { user, logout } = useCurrentUser(); const { user, logout } = useCurrentUser();
const { instance: msalInstance } = useMsal();
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoggingOut, setIsLoggingOut] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false);
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
@ -22,7 +20,7 @@ export const UserSection: React.FC = () => {
const handleLogout = async () => { const handleLogout = async () => {
setIsLoggingOut(true); setIsLoggingOut(true);
try { try {
await logout(msalInstance); await logout();
} catch (error) { } catch (error) {
console.error('Logout failed:', error); console.error('Logout failed:', error);
setIsLoggingOut(false); setIsLoggingOut(false);

View file

@ -32,6 +32,8 @@ export interface Message {
role?: string; role?: string;
status?: string; status?: string;
sequenceNr?: number; sequenceNr?: number;
/** ISO or number from API; workspace may use publishedAt only */
createdAt?: number;
publishedAt?: number; publishedAt?: number;
success?: boolean; success?: boolean;
actionId?: string; actionId?: string;

View file

@ -123,7 +123,7 @@ export function useMsalAuth() {
// Open popup window // Open popup window
const popup = window.open( const popup = window.open(
`${backendUrl}/api/msft/login?state=login`, `${backendUrl}/api/msft/auth/login`,
'microsoft-login', 'microsoft-login',
'width=600,height=700,left=100,top=100' 'width=600,height=700,left=100,top=100'
); );
@ -301,7 +301,7 @@ export function useGoogleAuth() {
// Open popup window // Open popup window
const popup = window.open( const popup = window.open(
`${backendUrl}/api/google/login?state=login`, `${backendUrl}/api/google/auth/login`,
'google-login', 'google-login',
'width=600,height=700,left=100,top=100' 'width=600,height=700,left=100,top=100'
); );

View file

@ -169,59 +169,6 @@ export function useBillingAdmin(mandateId?: string) {
} }
}, [request, mandateId]); }, [request, mandateId]);
// Update settings
const saveSettings = useCallback(async (
settingsUpdate: BillingSettingsUpdate,
targetMandateId?: string
) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
try {
const data = await updateSettingsAdmin(request, mId, settingsUpdate);
setSettings(data);
return data;
} catch (err) {
console.error('Error saving billing settings:', err);
throw err;
}
}, [request, mandateId]);
// Add credit (manual, admin)
const addCredit = useCallback(async (
creditRequest: CreditAddRequest,
targetMandateId?: string
) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
try {
const result = await addCreditAdmin(request, mId, creditRequest);
// Reload accounts after adding credit
await loadAccounts(mId);
return result;
} catch (err) {
console.error('Error adding credit:', err);
throw err;
}
}, [request, mandateId]);
// Create Stripe Checkout Session (returns redirect URL)
const createCheckout = useCallback(async (
checkoutRequest: CheckoutCreateRequest,
targetMandateId?: string
) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
try {
return await createCheckoutSessionApi(request, mId, checkoutRequest);
} catch (err) {
console.error('Error creating checkout session:', err);
throw err;
}
}, [request, mandateId]);
// Fetch accounts for a mandate // Fetch accounts for a mandate
const loadAccounts = useCallback(async (targetMandateId?: string) => { const loadAccounts = useCallback(async (targetMandateId?: string) => {
const mId = targetMandateId || mandateId; const mId = targetMandateId || mandateId;
@ -270,6 +217,70 @@ export function useBillingAdmin(mandateId?: string) {
} }
}, [request, mandateId]); }, [request, mandateId]);
// Update settings — after billing model change, reload dependent data (accounts / users / tx)
const saveSettings = useCallback(
async (settingsUpdate: BillingSettingsUpdate, targetMandateId?: string) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
const previousModel = settings?.billingModel;
try {
const data = await updateSettingsAdmin(request, mId, settingsUpdate);
setSettings(data);
const newModel = settingsUpdate.billingModel;
const modelChanged =
newModel !== undefined && newModel !== null && newModel !== previousModel;
if (modelChanged) {
await Promise.all([
loadAccounts(mId),
loadTransactions(mId, 100),
loadUsers(mId),
]);
}
return data;
} catch (err) {
console.error('Error saving billing settings:', err);
throw err;
}
},
[request, mandateId, settings?.billingModel, loadAccounts, loadTransactions, loadUsers]
);
// Add credit (manual, admin)
const addCredit = useCallback(
async (creditRequest: CreditAddRequest, targetMandateId?: string) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
try {
const result = await addCreditAdmin(request, mId, creditRequest);
await loadAccounts(mId);
return result;
} catch (err) {
console.error('Error adding credit:', err);
throw err;
}
},
[request, mandateId, loadAccounts]
);
// Create Stripe Checkout Session (returns redirect URL)
const createCheckout = useCallback(
async (checkoutRequest: CheckoutCreateRequest, targetMandateId?: string) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
try {
return await createCheckoutSessionApi(request, mId, checkoutRequest);
} catch (err) {
console.error('Error creating checkout session:', err);
throw err;
}
},
[request, mandateId]
);
// Load data when mandateId changes // Load data when mandateId changes
useEffect(() => { useEffect(() => {
if (mandateId) { if (mandateId) {

View file

@ -5,7 +5,7 @@
* Folgt dem gleichen Pattern wie useOrgUsers. * Folgt dem gleichen Pattern wie useOrgUsers.
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { useApiRequest } from './useApi'; import { useApiRequest } from './useApi';
import api from '../api'; import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions'; import { usePermissions, type UserPermissions } from './usePermissions';
@ -19,6 +19,8 @@ import {
type MandateUpdateData, type MandateUpdateData,
type PaginationParams type PaginationParams
} from '../api/mandateApi'; } from '../api/mandateApi';
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
// Re-export types // Re-export types
export type { Mandate, MandateUpdateData, PaginationParams }; export type { Mandate, MandateUpdateData, PaginationParams };
@ -164,14 +166,14 @@ export function useAdminMandates() {
})); }));
// Create mandate // Create mandate
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<boolean> => { const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => {
try { try {
await createMandateApi(request, mandateData); const created = await createMandateApi(request, mandateData);
await fetchMandates(); await fetchMandates();
return true; return created ?? null;
} catch (error: any) { } catch (error: any) {
console.error('Error creating mandate:', error); console.error('Error creating mandate:', error);
return false; return null;
} }
}, [request, fetchMandates]); }, [request, fetchMandates]);
@ -235,3 +237,80 @@ export function useAdminMandates() {
} }
export default useAdminMandates; export default useAdminMandates;
/**
* Mandate model attributes for FormGenerator (create/edit) shared by Admin page and wizard.
*/
export function useMandateFormAttributes() {
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await api.get('/api/attributes/Mandate');
let attrs: AttributeDefinition[] = [];
const data = response.data;
if (data?.attributes && Array.isArray(data.attributes)) {
attrs = data.attributes;
} else if (Array.isArray(data)) {
attrs = data;
} else if (data && typeof data === 'object') {
for (const key of Object.keys(data)) {
if (Array.isArray((data as Record<string, unknown>)[key])) {
attrs = (data as Record<string, AttributeDefinition[]>)[key];
break;
}
}
}
setAttributes(attrs);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Attribute-Laden fehlgeschlagen';
setError(msg);
setAttributes([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const formAttributes: FormGenAttr[] = useMemo(() => {
return attributes
.filter(attr => attr.name !== 'id')
.map(attr => ({ ...attr, type: attr.type })) as FormGenAttr[];
}, [attributes]);
const createFormAttributes: FormGenAttr[] = useMemo(
() => formAttributes.filter(attr => attr.name !== 'isSystem'),
[formAttributes]
);
const billingFormAttributes: FormGenAttr[] = useMemo(() => getMandateBillingFormAttributes(), []);
/** Mandate attributes + billing (Abrechnung) for SysAdmin create flows */
const createFormAttributesWithBilling: FormGenAttr[] = useMemo(
() => [...createFormAttributes, ...billingFormAttributes],
[createFormAttributes, billingFormAttributes]
);
/** Mandate attributes + billing for SysAdmin edit flows */
const formAttributesWithBilling: FormGenAttr[] = useMemo(
() => [...formAttributes, ...billingFormAttributes],
[formAttributes, billingFormAttributes]
);
return {
formAttributes,
createFormAttributes,
formAttributesWithBilling,
createFormAttributesWithBilling,
loading,
error,
refetch: load,
};
}

View file

@ -8,6 +8,8 @@
import { useState, useCallback, useEffect, useRef } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import api from '../api'; import api from '../api';
const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']);
// Types // Types
export interface NotificationAction { export interface NotificationAction {
actionId: string; actionId: string;
@ -51,6 +53,7 @@ export function useNotifications() {
// Polling interval ref // Polling interval ref
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null); const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const prevUnreadCountRef = useRef<number | null>(null);
/** /**
* Fetch all notifications for the current user * Fetch all notifications for the current user
@ -90,6 +93,26 @@ export function useNotifications() {
try { try {
const response = await api.get('/api/notifications/unread-count'); const response = await api.get('/api/notifications/unread-count');
const count = response.data.count; const count = response.data.count;
const prev = prevUnreadCountRef.current;
prevUnreadCountRef.current = count;
if (prev !== null && count > prev) {
try {
const listRes = await api.get('/api/notifications', {
params: { status: 'unread', limit: 25 },
});
const list = listRes.data as UserNotification[];
if (
Array.isArray(list) &&
list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType))
) {
window.dispatchEvent(new Event('features-changed'));
}
} catch {
/* ignore */
}
}
setUnreadCount(count); setUnreadCount(count);
return count; return count;
} catch (err: any) { } catch (err: any) {

View file

@ -140,7 +140,7 @@ export function useCurrentUser() {
} }
}; };
const logout = async (msalInstance?: any) => { const logout = async () => {
if (!user) { if (!user) {
throw new Error('No user to logout'); throw new Error('No user to logout');
} }
@ -160,8 +160,7 @@ export function useCurrentUser() {
// Clear user state after successful logout // Clear user state after successful logout
setUser(null); setUser(null);
// CRITICAL: Clear all authentication data BEFORE any redirects // Clear client-side auth hints; gateway session ended via API (cookies cleared by backend).
// This ensures cleanup happens even if MSAL redirect interrupts the process
console.log('🧹 Starting comprehensive cleanup...'); console.log('🧹 Starting comprehensive cleanup...');
// Clear user data cache from sessionStorage // Clear user data cache from sessionStorage
@ -170,43 +169,15 @@ export function useCurrentUser() {
// Clear auth authority from sessionStorage // Clear auth authority from sessionStorage
sessionStorage.removeItem('auth_authority'); sessionStorage.removeItem('auth_authority');
// Clear MSAL cache tokens from localStorage // Optional: clear MSAL browser cache only (PowerOn JWT lives in httpOnly cookies + backend).
// MSAL stores tokens with keys starting with 'msal.' // Do not call msal.logoutRedirect — that signs the user out of Microsoft globally.
const keysToRemove = []; for (let i = localStorage.length - 1; i >= 0; i--) {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (
key.startsWith('msal.') ||
key === 'auth_token' ||
key === 'refresh_token' ||
key.includes('token') ||
key.includes('auth') ||
key.includes('msal')
)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => {
console.log('🗑️ Removing token:', key);
localStorage.removeItem(key);
});
// Clear ALL MSAL cache data (including account keys, token keys, version)
const msalKeysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
if (key && key.startsWith('msal.')) { if (key && key.startsWith('msal.')) {
msalKeysToRemove.push(key); 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) // Clear cookies as backup (in case backend doesn't clear them properly)
// Note: This only works for cookies that are accessible to JavaScript // Note: This only works for cookies that are accessible to JavaScript
console.log('🍪 Checking cookies for cleanup...'); console.log('🍪 Checking cookies for cleanup...');
@ -228,24 +199,7 @@ export function useCurrentUser() {
console.log('🍪 Cookies after cleanup attempt:', document.cookie); console.log('🍪 Cookies after cleanup attempt:', document.cookie);
console.log('✅ Cleanup completed'); console.log('✅ Cleanup completed');
// Handle MSAL logout for Microsoft authentication
if (user.authenticationAuthority === 'msft' && msalInstance) {
try {
console.log('🔄 Starting MSAL logout redirect...');
await msalInstance.logoutRedirect({
onRedirectNavigate: () => {
console.log('🔄 MSAL redirect initiated - cleanup already completed');
return true;
}
});
return; // MSAL will handle the redirect
} catch (msalError) {
console.error('MSAL logout failed:', msalError);
// Continue with regular redirect if MSAL logout fails
}
}
// Redirect to login or home page // Redirect to login or home page
console.log('🔄 Redirecting to login page...'); console.log('🔄 Redirecting to login page...');
window.location.href = '/login'; window.location.href = '/login';

View file

@ -94,11 +94,24 @@
min-height: 0; min-height: 0;
position: relative; position: relative;
--mobile-topbar-height: 0px; --mobile-topbar-height: 0px;
display: flex;
flex-direction: column;
/* Let child components handle their own scrolling for sticky headers */ /* Let child components handle their own scrolling for sticky headers */
overflow: hidden; overflow: hidden;
background: var(--bg-primary, #ffffff); background: var(--bg-primary, #ffffff);
} }
/* Fills .content flex column so admin pages get a bounded height for inner scroll */
.outletShell {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
}
.mobileTopBar { .mobileTopBar {
display: none; display: none;
} }

View file

@ -107,7 +107,10 @@ const MainLayoutInner: React.FC = () => {
<WorkspaceKeepAlive isVisible={_WORKSPACE_ROUTE_RE.test(location.pathname)} /> <WorkspaceKeepAlive isVisible={_WORKSPACE_ROUTE_RE.test(location.pathname)} />
<div style={{ display: _WORKSPACE_ROUTE_RE.test(location.pathname) ? 'none' : 'contents' }}> <div
className={styles.outletShell}
style={{ display: _WORKSPACE_ROUTE_RE.test(location.pathname) ? 'none' : undefined }}
>
<Outlet /> <Outlet />
</div> </div>
</main> </main>

View file

@ -7,7 +7,7 @@
*/ */
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link, Navigate } from 'react-router-dom';
import useNavigation from '../hooks/useNavigation'; import useNavigation from '../hooks/useNavigation';
import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation'; import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation';
import { getPageIcon } from '../config/pageRegistry'; import { getPageIcon } from '../config/pageRegistry';
@ -47,19 +47,6 @@ const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature }) => {
); );
}; };
// =============================================================================
// EMPTY STATE
// =============================================================================
const EmptyState: React.FC = () => (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>📋</div>
<h2>Willkommen bei PowerOn</h2>
<p>Du hast aktuell Zugriff auf keine Feature-Instanzen.</p>
<p>Kontaktiere einen Administrator, um Zugriff zu erhalten.</p>
</div>
);
// ============================================================================= // =============================================================================
// DASHBOARD PAGE // DASHBOARD PAGE
// ============================================================================= // =============================================================================
@ -89,7 +76,7 @@ export const DashboardPage: React.FC = () => {
} }
if (totalInstances === 0) { if (totalInstances === 0) {
return <EmptyState />; return <Navigate to="/store" replace />;
} }
return ( return (

View file

@ -7,7 +7,7 @@
*/ */
import React from 'react'; import React from 'react';
import { FaCogs, FaHeadset } from 'react-icons/fa'; import { FaCogs, FaComments, FaHeadset } from 'react-icons/fa';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useStore } from '../hooks/useStore'; import { useStore } from '../hooks/useStore';
import type { StoreFeature } from '../api/storeApi'; import type { StoreFeature } from '../api/storeApi';
@ -16,6 +16,8 @@ import styles from './Store.module.css';
const FEATURE_ICONS: Record<string, React.ReactNode> = { const FEATURE_ICONS: Record<string, React.ReactNode> = {
automation: <FaCogs />, automation: <FaCogs />,
teamsbot: <FaHeadset />, teamsbot: <FaHeadset />,
workspace: <FaComments />,
commcoach: <FaComments />,
}; };
const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = { const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = {
@ -29,6 +31,16 @@ const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = {
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.', en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
fr: 'Integrez un bot IA dans vos reunions et canaux Microsoft Teams.', fr: 'Integrez un bot IA dans vos reunions et canaux Microsoft Teams.',
}, },
workspace: {
de: 'Nutze den gemeinsamen AI Workspace: Chats, Tools und Kontext pro Instanz.',
en: 'Use the shared AI workspace: chats, tools, and context per instance.',
fr: 'Utilisez l\'espace de travail IA partage: chats, outils et contexte par instance.',
},
commcoach: {
de: 'CommCoach: Kommunikation trainieren mit KI-gestütztem Coaching und Feedback.',
en: 'CommCoach: practice communication with AI-assisted coaching and feedback.',
fr: 'CommCoach: entrainer la communication avec un coaching assiste par IA.',
},
}; };
function _getLabel(labels: Record<string, string>, lang: string): string { function _getLabel(labels: Record<string, string>, lang: string): string {

View file

@ -76,7 +76,12 @@ export const AccessManagementHub: React.FC = () => {
useEffect(() => { useEffect(() => {
fetchFeatures(); fetchFeatures();
fetchMandates().then(setMandates); fetchMandates().then(data => {
setMandates(data);
if (data.length > 0 && !selectedMandateId) {
setSelectedMandateId(data[0].id);
}
});
}, [fetchFeatures, fetchMandates]); }, [fetchFeatures, fetchMandates]);
useEffect(() => { useEffect(() => {

View file

@ -6,11 +6,22 @@
.adminPage { .adminPage {
padding: 1.5rem; padding: 1.5rem;
/* Fill parent height and enable flex layout for sticky table headers */ /* Default: grow with content → scroll on MainLayout .outletShell (expandable panels, long pages). */
height: 100%; flex: 0 0 auto;
max-height: 100%; width: 100%;
box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
/*
* FormGeneratorTable expects a bounded height chain (height:100% / flex:1).
* With default .adminPage (flex:0 0 auto), .tableContainer flex:1 collapses empty table.
* Use together: className={`${styles.adminPage} ${styles.adminPageFill}`}
*/
.adminPage.adminPageFill {
flex: 1 1 auto;
min-height: 0;
overflow: hidden; overflow: hidden;
} }
@ -20,6 +31,7 @@
align-items: center; align-items: center;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
flex-shrink: 0; flex-shrink: 0;
min-height: 0;
} }
.pageTitle { .pageTitle {

View file

@ -153,7 +153,7 @@ export const AdminAutomationEventsPage: React.FC = () => {
], []); ], []);
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Automation Events</h1> <h1 className={styles.pageTitle}>Automation Events</h1>

View file

@ -62,7 +62,12 @@ export const AdminFeatureAccessPage: React.FC = () => {
// Load features, mandates, and attributes on mount // Load features, mandates, and attributes on mount
useEffect(() => { useEffect(() => {
fetchFeatures(); fetchFeatures();
fetchMandates().then(setMandates); fetchMandates().then(data => {
setMandates(data);
if (data.length > 0 && !selectedMandateId) {
setSelectedMandateId(data[0].id);
}
});
// Fetch FeatureInstance attributes from backend // Fetch FeatureInstance attributes from backend
api.get('/api/attributes/FeatureInstance').then(response => { api.get('/api/attributes/FeatureInstance').then(response => {
const attrs = response.data?.attributes || response.data || []; const attrs = response.data?.attributes || response.data || [];
@ -327,7 +332,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
if (error && !selectedMandateId) { if (error && !selectedMandateId) {
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<span className={styles.errorIcon}></span> <span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p> <p className={styles.errorMessage}>Fehler: {error}</p>
@ -340,7 +345,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
} }
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Feature-Instanzen</h1> <h1 className={styles.pageTitle}>Feature-Instanzen</h1>

View file

@ -108,6 +108,9 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
}); });
setCombinedOptions(allOptions); setCombinedOptions(allOptions);
if (allOptions.length > 0 && !selectedCombinedKey) {
setSelectedCombinedKey(allOptions[0].combinedKey);
}
}; };
loadCombinedOptions(); loadCombinedOptions();
@ -387,7 +390,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
if (error && !selectedCombinedKey) { if (error && !selectedCombinedKey) {
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<span className={styles.errorIcon}></span> <span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p> <p className={styles.errorMessage}>Fehler: {error}</p>
@ -400,7 +403,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
} }
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Feature Instanz Benutzer</h1> <h1 className={styles.pageTitle}>Feature Instanz Benutzer</h1>

View file

@ -245,7 +245,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
if (error && !selectedFeatureCode) { if (error && !selectedFeatureCode) {
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<span className={styles.errorIcon}></span> <span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>{error}</p> <p className={styles.errorMessage}>{error}</p>
@ -258,7 +258,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
} }
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Feature Rollen & Rechte</h1> <h1 className={styles.pageTitle}>Feature Rollen & Rechte</h1>

View file

@ -242,7 +242,7 @@ export const AdminInvitationsPage: React.FC = () => {
if (error && !selectedMandateId) { if (error && !selectedMandateId) {
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<span className={styles.errorIcon}></span> <span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p> <p className={styles.errorMessage}>Fehler: {error}</p>
@ -255,7 +255,7 @@ export const AdminInvitationsPage: React.FC = () => {
} }
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Einladungen</h1> <h1 className={styles.pageTitle}>Einladungen</h1>

View file

@ -294,7 +294,7 @@ export const AdminMandateRolesPage: React.FC = () => {
if (error && !selectedMandateId) { if (error && !selectedMandateId) {
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<span className={styles.errorIcon}></span> <span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p> <p className={styles.errorMessage}>Fehler: {error}</p>
@ -307,7 +307,7 @@ export const AdminMandateRolesPage: React.FC = () => {
} }
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Rollen</h1> <h1 className={styles.pageTitle}>Rollen</h1>

View file

@ -4,19 +4,27 @@
* Admin page for managing Mandates (tenants) using FormGeneratorTable. * Admin page for managing Mandates (tenants) using FormGeneratorTable.
*/ */
import React, { useState, useMemo } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAdminMandates, type Mandate } from '../../hooks/useMandates'; import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates';
import { useApiRequest } from '../../hooks/useApi';
import { fetchSettingsAdmin, updateSettingsAdmin } from '../../api/billingApi';
import {
mergeBillingIntoMandateFormData,
splitMandateAndBillingFromForm,
} from '../../utils/mandateBillingFormMerge';
import { useToast } from '../../contexts/ToastContext';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa'; import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
export const AdminMandatesPage: React.FC = () => { export const AdminMandatesPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { request } = useApiRequest();
const { showWarning, showSuccess } = useToast();
const { const {
mandates, mandates,
attributes,
columns, columns,
permissions, permissions,
pagination, pagination,
@ -31,53 +39,76 @@ export const AdminMandatesPage: React.FC = () => {
updateOptimistically, updateOptimistically,
} = useAdminMandates(); } = useAdminMandates();
// Form attributes from backend - filter for create/edit forms const {
const formAttributes: AttributeDefinition[] = useMemo(() => { formAttributes,
const excludedFields = ['id']; createFormAttributes,
return attributes formAttributesWithBilling,
.filter(attr => !excludedFields.includes(attr.name)) createFormAttributesWithBilling,
.map(attr => ({ loading: mandateAttrsLoading,
...attr, } = useMandateFormAttributes();
type: attr.type,
})) as AttributeDefinition[];
}, [attributes]);
// Create form attributes - exclude isSystem (only set by system, not user)
const createFormAttributes: AttributeDefinition[] = useMemo(() => {
return formAttributes.filter(attr => attr.name !== 'isSystem');
}, [formAttributes]);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [editingMandate, setEditingMandate] = useState<Mandate | null>(null); /** Mandate row merged with billing fields for FormGenerator */
const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null);
const [editingBillingWarning, setEditingBillingWarning] = useState<string | null>(null);
// Check if user can create // Check if user can create
const canCreate = permissions?.create !== 'n'; const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n'; const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n'; const canDelete = permissions?.delete !== 'n';
// Handle edit click // Handle edit click — load mandate + billing settings (separate persistence)
const handleEditClick = async (mandate: Mandate) => { const handleEditClick = async (mandate: Mandate) => {
setEditingBillingWarning(null);
const fullMandate = await fetchMandateById(mandate.id); const fullMandate = await fetchMandateById(mandate.id);
if (fullMandate) { if (!fullMandate) return;
setEditingMandate(fullMandate); try {
const settings = await fetchSettingsAdmin(request, fullMandate.id);
setEditingFormData(
mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, settings)
);
} catch {
setEditingFormData(mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, null));
setEditingBillingWarning(
'Abrechnungseinstellungen konnten nicht geladen werden. Nur Mandantendaten sind sicher bearbeitbar.'
);
} }
}; };
// Handle create submit // Handle create submit — POST mandate, then billing settings
const handleCreateSubmit = async (data: Partial<Mandate>) => { const handleCreateSubmit = async (data: Record<string, unknown>) => {
const success = await handleCreate(data); const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
if (success) { const created = await handleCreate(mandatePayload as Partial<Mandate>);
setShowCreateModal(false); 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 // Handle edit submit — PUT mandate + POST billing settings
const handleEditSubmit = async (data: Partial<Mandate>) => { const handleEditSubmit = async (data: Record<string, unknown>) => {
if (!editingMandate) return; if (!editingFormData?.id) return;
const success = await handleUpdate(editingMandate.id, data); const mandateId = String(editingFormData.id);
if (success) { const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
setEditingMandate(null); const mandateOk = await handleUpdate(mandateId, mandatePayload as Partial<Mandate>);
if (!mandateOk) return;
try {
await updateSettingsAdmin(request, mandateId, billingUpdate);
showSuccess('Gespeichert', 'Mandant und Abrechnung aktualisiert.');
} catch (e: unknown) {
console.error(e);
showWarning('Teilweise gespeichert', 'Mandant gespeichert, Abrechnung konnte nicht aktualisiert werden.');
} }
setEditingFormData(null);
setEditingBillingWarning(null);
}; };
// Handle delete (confirmation handled by DeleteActionButton) // Handle delete (confirmation handled by DeleteActionButton)
@ -91,7 +122,7 @@ export const AdminMandatesPage: React.FC = () => {
if (error) { if (error) {
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<span className={styles.errorIcon}></span> <span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Mandanten: {error}</p> <p className={styles.errorMessage}>Fehler beim Laden der Mandanten: {error}</p>
@ -104,7 +135,7 @@ export const AdminMandatesPage: React.FC = () => {
} }
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Mandanten</h1> <h1 className={styles.pageTitle}>Mandanten</h1>
@ -212,14 +243,18 @@ export const AdminMandatesPage: React.FC = () => {
</button> </button>
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
{createFormAttributes.length === 0 ? ( <p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginTop: 0, marginBottom: '12px' }}>
Stammdaten kommen aus dem Modell <code>Mandate</code> (API). Abrechnung wird in{' '}
<code>BillingSettings</code> pro Mandant gespeichert.
</p>
{mandateAttrsLoading || createFormAttributes.length === 0 ? (
<div className={styles.loadingContainer}> <div className={styles.loadingContainer}>
<div className={styles.spinner} /> <div className={styles.spinner} />
<span>Lade Formular...</span> <span>Lade Formular...</span>
</div> </div>
) : ( ) : (
<FormGeneratorForm <FormGeneratorForm
attributes={createFormAttributes} attributes={createFormAttributesWithBilling}
mode="create" mode="create"
onSubmit={handleCreateSubmit} onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)} onCancel={() => setShowCreateModal(false)}
@ -233,20 +268,29 @@ export const AdminMandatesPage: React.FC = () => {
)} )}
{/* Edit Modal */} {/* Edit Modal */}
{editingMandate && ( {editingFormData && (
<div className={styles.modalOverlay} onClick={() => setEditingMandate(null)}> <div
className={styles.modalOverlay}
onClick={() => {
setEditingFormData(null);
setEditingBillingWarning(null);
}}
>
<div className={styles.modal} onClick={e => e.stopPropagation()}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Mandant bearbeiten</h2> <h2 className={styles.modalTitle}>Mandant bearbeiten</h2>
<button <button
className={styles.modalClose} className={styles.modalClose}
onClick={() => setEditingMandate(null)} onClick={() => {
setEditingFormData(null);
setEditingBillingWarning(null);
}}
> >
</button> </button>
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
{editingMandate.isSystem && ( {Boolean(editingFormData.isSystem) && (
<div className={styles.infoBox} style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}> <div className={styles.infoBox} style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} /> <FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
<span> <span>
@ -254,6 +298,14 @@ export const AdminMandatesPage: React.FC = () => {
</span> </span>
</div> </div>
)} )}
{editingBillingWarning && (
<div
className={styles.infoBox}
style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}
>
{editingBillingWarning}
</div>
)}
{formAttributes.length === 0 ? ( {formAttributes.length === 0 ? (
<div className={styles.loadingContainer}> <div className={styles.loadingContainer}>
<div className={styles.spinner} /> <div className={styles.spinner} />
@ -261,11 +313,14 @@ export const AdminMandatesPage: React.FC = () => {
</div> </div>
) : ( ) : (
<FormGeneratorForm <FormGeneratorForm
attributes={formAttributes} attributes={formAttributesWithBilling}
data={editingMandate} data={editingFormData}
mode="edit" mode="edit"
onSubmit={handleEditSubmit} onSubmit={handleEditSubmit}
onCancel={() => setEditingMandate(null)} onCancel={() => {
setEditingFormData(null);
setEditingBillingWarning(null);
}}
submitButtonText="Speichern" submitButtonText="Speichern"
cancelButtonText="Abbrechen" cancelButtonText="Abbrechen"
/> />

View file

@ -515,7 +515,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
if (error && !overview) { if (error && !overview) {
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<span className={styles.errorIcon}></span> <span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p> <p className={styles.errorMessage}>Fehler: {error}</p>
@ -531,7 +531,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
} }
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Benutzer-Zugriffsübersicht</h1> <h1 className={styles.pageTitle}>Benutzer-Zugriffsübersicht</h1>

View file

@ -257,7 +257,7 @@ export const AdminUserMandatesPage: React.FC = () => {
if (error && !selectedMandateId) { if (error && !selectedMandateId) {
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<span className={styles.errorIcon}></span> <span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p> <p className={styles.errorMessage}>Fehler: {error}</p>
@ -270,7 +270,7 @@ export const AdminUserMandatesPage: React.FC = () => {
} }
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Mandanten-Mitglieder</h1> <h1 className={styles.pageTitle}>Mandanten-Mitglieder</h1>

View file

@ -125,7 +125,7 @@ export const AdminUsersPage: React.FC = () => {
if (error) { if (error) {
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<span className={styles.errorIcon}></span> <span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Benutzer: {error}</p> <p className={styles.errorMessage}>Fehler beim Laden der Benutzer: {error}</p>
@ -138,7 +138,7 @@ export const AdminUsersPage: React.FC = () => {
} }
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Benutzer</h1> <h1 className={styles.pageTitle}>Benutzer</h1>

View file

@ -13,7 +13,12 @@ import {
type Feature, type Feature,
} from '../../../hooks/useFeatureAccess'; } from '../../../hooks/useFeatureAccess';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api'; import { useApiRequest } from '../../../hooks/useApi';
import { useMandateFormAttributes } from '../../../hooks/useMandates';
import { createMandate } from '../../../api/mandateApi';
import { updateSettingsAdmin } from '../../../api/billingApi';
import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFormMerge';
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
import styles from '../Admin.module.css'; import styles from '../Admin.module.css';
const TOTAL_STEPS = 4; const TOTAL_STEPS = 4;
@ -25,12 +30,19 @@ interface RoleOption {
} }
export const AdminMandateWizardPage: React.FC = () => { export const AdminMandateWizardPage: React.FC = () => {
const { showSuccess } = useToast(); const { showSuccess, showWarning, showError } = useToast();
const { request } = useApiRequest();
const {
createFormAttributes,
createFormAttributesWithBilling,
loading: mandateAttrLoading,
} = useMandateFormAttributes();
const { const {
fetchMandateUsers, fetchMandateUsers,
addUserToMandate, addUserToMandate,
removeUserFromMandate, removeUserFromMandate,
updateUserRoles,
fetchMandates: fetchMandatesList, fetchMandates: fetchMandatesList,
fetchRoles: fetchMandateRolesList, fetchRoles: fetchMandateRolesList,
fetchAllUsers, fetchAllUsers,
@ -44,6 +56,7 @@ export const AdminMandateWizardPage: React.FC = () => {
fetchInstanceUsers, fetchInstanceUsers,
addUserToInstance, addUserToInstance,
removeUserFromInstance, removeUserFromInstance,
updateInstanceUserRoles,
fetchInstanceRoles: fetchInstanceRolesList, fetchInstanceRoles: fetchInstanceRolesList,
} = useFeatureAccess(); } = useFeatureAccess();
@ -56,14 +69,13 @@ export const AdminMandateWizardPage: React.FC = () => {
const [mandates, setMandates] = useState<Mandate[]>([]); const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandate, setSelectedMandate] = useState<Record<string, any> | null>(null); const [selectedMandate, setSelectedMandate] = useState<Record<string, any> | null>(null);
const [isCreatingMandate, setIsCreatingMandate] = useState(false); const [isCreatingMandate, setIsCreatingMandate] = useState(false);
const [mandateForm, setMandateForm] = useState({ name: '' });
// Step 2: Mandate Users // Step 2: Mandate Users
const [mandateUsers, setMandateUsers] = useState<MandateUser[]>([]); const [mandateUsers, setMandateUsers] = useState<MandateUser[]>([]);
const [allSystemUsers, setAllSystemUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]); const [allSystemUsers, setAllSystemUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]);
const [mandateRoles, setMandateRoles] = useState<RoleOption[]>([]); const [mandateRoles, setMandateRoles] = useState<RoleOption[]>([]);
const [isAddingMandateUser, setIsAddingMandateUser] = useState(false); const [isAddingMandateUser, setIsAddingMandateUser] = useState(false);
const [addMandateUserForm, setAddMandateUserForm] = useState({ userId: '', roleIds: [] as string[] }); const [addMandateUserForm, setAddMandateUserForm] = useState({ userIds: [] as string[], roleIds: [] as string[] });
// Step 3: Instances // Step 3: Instances
const [features, setFeatures] = useState<Feature[]>([]); const [features, setFeatures] = useState<Feature[]>([]);
@ -77,7 +89,12 @@ export const AdminMandateWizardPage: React.FC = () => {
const [instanceUsers, setInstanceUsers] = useState<FeatureAccessUser[]>([]); const [instanceUsers, setInstanceUsers] = useState<FeatureAccessUser[]>([]);
const [instanceRoles, setInstanceRoles] = useState<RoleOption[]>([]); const [instanceRoles, setInstanceRoles] = useState<RoleOption[]>([]);
const [isAddingInstanceUser, setIsAddingInstanceUser] = useState(false); const [isAddingInstanceUser, setIsAddingInstanceUser] = useState(false);
const [addInstanceUserForm, setAddInstanceUserForm] = useState({ userId: '', roleIds: [] as string[] }); const [addInstanceUserForm, setAddInstanceUserForm] = useState({ userIds: [] as string[], roleIds: [] as string[] });
const [roleEditContext, setRoleEditContext] = useState<
null | { scope: 'mandate' | 'instance'; userId: string }
>(null);
const [roleEditDraft, setRoleEditDraft] = useState<string[]>([]);
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// HELPERS // HELPERS
@ -126,6 +143,11 @@ export const AdminMandateWizardPage: React.FC = () => {
fetchFeatures().then(setFeatures); fetchFeatures().then(setFeatures);
}, [fetchFeatures]); }, [fetchFeatures]);
useEffect(() => {
setRoleEditContext(null);
setRoleEditDraft([]);
}, [step]);
// Step 2 // Step 2
const loadMandateUsers = useCallback(async () => { const loadMandateUsers = useCallback(async () => {
if (!selectedMandate) return; if (!selectedMandate) return;
@ -188,42 +210,69 @@ export const AdminMandateWizardPage: React.FC = () => {
// HANDLERS // HANDLERS
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
const handleCreateMandate = async () => { const handleCreateMandate = async (data: Record<string, unknown>) => {
if (!mandateForm.name.trim()) { setError('Name ist erforderlich'); return; }
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const response = await api.post('/api/mandates/', { const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
name: mandateForm.name, const body = {
enabled: true, ...mandatePayload,
}); enabled: mandatePayload.enabled !== undefined ? mandatePayload.enabled : true,
setSelectedMandate(response.data); };
const created = await createMandate(request, body);
let billingSaved = false;
try {
await updateSettingsAdmin(request, String(created.id), billingUpdate);
billingSaved = true;
} catch (billingErr: unknown) {
console.error(billingErr);
showWarning(
'Mandant erstellt',
'Abrechnungseinstellungen konnten nicht gespeichert werden. Bitte unter Administration → Abrechnung nachpflegen.',
);
}
setSelectedMandate(created as Record<string, unknown>);
setIsCreatingMandate(false); setIsCreatingMandate(false);
showSuccess('Erstellt', 'Mandant erstellt'); if (billingSaved) {
showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert');
}
await loadMandates(); await loadMandates();
} catch (err: any) { } catch (err: unknown) {
setError(err?.response?.data?.detail || err?.message || 'Fehler beim Erstellen'); const e = err as { response?: { data?: { detail?: string } }; message?: string };
setError(e?.response?.data?.detail || e?.message || 'Fehler beim Erstellen');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleAddMandateUser = async () => { const handleAddMandateUser = async () => {
if (!selectedMandate || !addMandateUserForm.userId) return; if (!selectedMandate || addMandateUserForm.userIds.length === 0) return;
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const failures: string[] = [];
let ok = 0;
try { try {
const result = await addUserToMandate(selectedMandate.id, { for (const uid of addMandateUserForm.userIds) {
targetUserId: addMandateUserForm.userId, const result = await addUserToMandate(selectedMandate.id, {
roleIds: addMandateUserForm.roleIds, targetUserId: uid,
}); roleIds: addMandateUserForm.roleIds,
if (result.success) { });
if (result.success) ok += 1;
else failures.push(`${uid}: ${result.error || 'Fehler'}`);
}
if (ok > 0) {
setIsAddingMandateUser(false); setIsAddingMandateUser(false);
setAddMandateUserForm({ userId: '', roleIds: [] }); setAddMandateUserForm({ userIds: [], roleIds: [] });
showSuccess('Hinzugefügt', 'Benutzer zum Mandanten hinzugefügt'); showSuccess('Hinzugefügt', `${ok} Benutzer zum Mandanten hinzugefügt`);
await loadMandateUsers(); await loadMandateUsers();
} else { }
setError(result.error || 'Fehler beim Hinzufügen'); if (failures.length > 0) {
showWarning(
'Teilweise fehlgeschlagen',
failures.slice(0, 5).join('; ') + (failures.length > 5 ? '…' : ''),
);
if (ok === 0) setError(failures.join('; '));
else await loadMandateUsers();
} }
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -276,27 +325,76 @@ export const AdminMandateWizardPage: React.FC = () => {
}; };
const handleAddInstanceUser = async () => { const handleAddInstanceUser = async () => {
if (!selectedInstance || !selectedMandate || !addInstanceUserForm.userId) return; if (!selectedInstance || !selectedMandate || addInstanceUserForm.userIds.length === 0) return;
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const failures: string[] = [];
let ok = 0;
try { try {
const result = await addUserToInstance(selectedMandate.id, selectedInstance.id, { for (const uid of addInstanceUserForm.userIds) {
userId: addInstanceUserForm.userId, const result = await addUserToInstance(selectedMandate.id, selectedInstance.id, {
roleIds: addInstanceUserForm.roleIds, userId: uid,
}); roleIds: addInstanceUserForm.roleIds,
if (result.success) { });
if (result.success) ok += 1;
else failures.push(`${uid}: ${result.error || 'Fehler'}`);
}
if (ok > 0) {
setIsAddingInstanceUser(false); setIsAddingInstanceUser(false);
setAddInstanceUserForm({ userId: '', roleIds: [] }); setAddInstanceUserForm({ userIds: [], roleIds: [] });
showSuccess('Hinzugefügt', 'Benutzer zur Feature-Instanz hinzugefügt'); showSuccess('Hinzugefügt', `${ok} Benutzer zur Feature-Instanz hinzugefügt`);
await loadInstanceUsers(); await loadInstanceUsers();
} else { }
setError(result.error || 'Fehler beim Hinzufügen'); if (failures.length > 0) {
showWarning(
'Teilweise fehlgeschlagen',
failures.slice(0, 5).join('; ') + (failures.length > 5 ? '…' : ''),
);
if (ok === 0) setError(failures.join('; '));
else await loadInstanceUsers();
} }
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const _saveMandateRoleEdit = async () => {
if (!selectedMandate || roleEditContext?.scope !== 'mandate') return;
setIsLoading(true);
setError(null);
const r = await updateUserRoles(selectedMandate.id, roleEditContext.userId, roleEditDraft);
if (r.success) {
showSuccess('Gespeichert', 'Rollen aktualisiert');
setRoleEditContext(null);
setRoleEditDraft([]);
await loadMandateUsers();
} else {
showError('Fehler', r.error || 'Rollen konnten nicht gespeichert werden');
}
setIsLoading(false);
};
const _saveInstanceRoleEdit = async () => {
if (!selectedMandate || !selectedInstance || roleEditContext?.scope !== 'instance') return;
setIsLoading(true);
setError(null);
const r = await updateInstanceUserRoles(
selectedMandate.id,
selectedInstance.id,
roleEditContext.userId,
{ roleIds: roleEditDraft },
);
if (r.success) {
showSuccess('Gespeichert', 'Rollen aktualisiert');
setRoleEditContext(null);
setRoleEditDraft([]);
await loadInstanceUsers();
} else {
showError('Fehler', r.error || 'Rollen konnten nicht gespeichert werden');
}
setIsLoading(false);
};
const handleRemoveInstanceUser = async (userId: string) => { const handleRemoveInstanceUser = async (userId: string) => {
if (!selectedInstance || !selectedMandate) return; if (!selectedInstance || !selectedMandate) return;
const result = await removeUserFromInstance(selectedMandate.id, selectedInstance.id, userId); const result = await removeUserFromInstance(selectedMandate.id, selectedInstance.id, userId);
@ -324,9 +422,34 @@ export const AdminMandateWizardPage: React.FC = () => {
// SHARED UI // SHARED UI
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
type WizardUserRow = {
userId?: string;
id?: string;
username: string;
email?: string | null;
fullName?: string;
firstname?: string | null;
lastname?: string | null;
enabled?: boolean;
roleIds?: string[];
roleLabels?: string[];
};
const _roleTextForRow = (u: WizardUserRow, roleLookup: RoleOption[]) => {
if (u.roleLabels && u.roleLabels.length > 0) return u.roleLabels.join(', ');
if (u.roleIds && u.roleIds.length > 0) {
return u.roleIds
.map(rid => roleLookup.find(r => r.id === rid)?.roleLabel || rid)
.join(', ');
}
return '-';
};
const renderUserTable = ( const renderUserTable = (
users: Array<{ userId?: string; id?: string; username: string; email?: string | null; fullName?: string; firstname?: string | null; lastname?: string | null; enabled?: boolean; roleLabels?: string[] }>, users: WizardUserRow[],
roleLookup: RoleOption[],
onRemove: (userId: string) => void, onRemove: (userId: string) => void,
options?: { onEditRoles?: (userId: string, currentRoleIds: string[]) => void },
) => ( ) => (
users.length > 0 ? ( users.length > 0 ? (
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
@ -342,12 +465,30 @@ export const AdminMandateWizardPage: React.FC = () => {
<tbody> <tbody>
{users.map(u => { {users.map(u => {
const uid = u.userId || u.id || ''; const uid = u.userId || u.id || '';
const ids = u.roleIds || [];
return ( return (
<tr key={uid} style={{ borderBottom: '1px solid var(--border-color, #f1f5f9)' }}> <tr key={uid} style={{ borderBottom: '1px solid var(--border-color, #f1f5f9)' }}>
<td style={{ padding: '8px 12px', fontSize: '13px' }}>{getUserDisplayName(u as any)}</td> <td style={{ padding: '8px 12px', fontSize: '13px' }}>{getUserDisplayName(u as any)}</td>
<td style={{ padding: '8px 12px', fontSize: '13px', color: 'var(--text-secondary)' }}>{u.email || '-'}</td> <td style={{ padding: '8px 12px', fontSize: '13px', color: 'var(--text-secondary)' }}>{u.email || '-'}</td>
<td style={{ padding: '8px 12px', fontSize: '12px', color: 'var(--text-secondary)' }}> <td style={{ padding: '8px 12px', fontSize: '12px', color: 'var(--text-secondary)' }}>
{u.roleLabels?.join(', ') || '-'} <span>{_roleTextForRow(u, roleLookup)}</span>
{options?.onEditRoles && roleLookup.length > 0 && (
<button
type="button"
style={{
marginLeft: '8px',
padding: '2px 6px',
fontSize: '11px',
border: '1px solid var(--border-color, #e5e7eb)',
borderRadius: '4px',
background: 'var(--surface-color, #fff)',
cursor: 'pointer',
}}
onClick={() => options.onEditRoles!(uid, ids)}
>
Bearbeiten
</button>
)}
</td> </td>
<td style={{ padding: '8px 12px', textAlign: 'center' }}> <td style={{ padding: '8px 12px', textAlign: 'center' }}>
<span className={styles.badge} style={{ <span className={styles.badge} style={{
@ -383,35 +524,55 @@ export const AdminMandateWizardPage: React.FC = () => {
const renderAddUserForm = ( const renderAddUserForm = (
availableUsers: Array<{ id?: string; userId?: string; username: string; email?: string | null; fullName?: string; firstname?: string | null; lastname?: string | null }>, availableUsers: Array<{ id?: string; userId?: string; username: string; email?: string | null; fullName?: string; firstname?: string | null; lastname?: string | null }>,
roles: RoleOption[], roles: RoleOption[],
formValue: { userId: string; roleIds: string[] }, formValue: { userIds: string[]; roleIds: string[] },
setFormValue: (fn: (prev: { userId: string; roleIds: string[] }) => { userId: string; roleIds: string[] }) => void, setFormValue: (fn: (prev: { userIds: string[]; roleIds: string[] }) => { userIds: string[]; roleIds: string[] }) => void,
onSubmit: () => void, onSubmit: () => void,
onCancel: () => void, onCancel: () => void,
) => ( ) => (
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}> <div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
<div> <div>
<label className={styles.formLabel}>Benutzer *</label> <label className={styles.formLabel}>Benutzer * (mehrfach möglich)</label>
<select <div
className={styles.filterSelect} style={{
style={{ width: '100%' }} maxHeight: '220px',
value={formValue.userId} overflowY: 'auto',
onChange={e => setFormValue(p => ({ ...p, userId: e.target.value }))} border: '1px solid var(--border-color, #e5e7eb)',
borderRadius: '8px',
padding: '8px',
display: 'grid',
gap: '6px',
background: 'var(--surface-color, #fff)',
}}
> >
<option value="">-- Benutzer wählen --</option>
{availableUsers.map(u => { {availableUsers.map(u => {
const uid = u.userId || u.id || ''; const uid = u.userId || u.id || '';
const name = getUserDisplayName(u as any); const name = getUserDisplayName(u as any);
return ( return (
<option key={uid} value={uid}> <label key={uid} className={styles.checkboxLabel} style={{ alignItems: 'flex-start' }}>
{u.username} {u.email ? `(${u.email})` : ''} {name !== u.username ? `- ${name}` : ''} <input
</option> type="checkbox"
checked={formValue.userIds.includes(uid)}
onChange={e => {
setFormValue(p => ({
...p,
userIds: e.target.checked
? [...p.userIds, uid]
: p.userIds.filter(id => id !== uid),
}));
}}
/>
<span>
{u.username} {u.email ? <span style={{ color: 'var(--text-secondary)' }}>({u.email})</span> : null}
{name !== u.username ? <span style={{ color: 'var(--text-secondary)' }}> {name}</span> : null}
</span>
</label>
); );
})} })}
</select> </div>
</div> </div>
{roles.length > 0 && ( {roles.length > 0 && (
<div> <div>
<label className={styles.formLabel}>Rollen</label> <label className={styles.formLabel}>Rollen (für alle ausgewählten Benutzer)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{roles.map(r => ( {roles.map(r => (
<label key={r.id} className={styles.checkboxLabel}> <label key={r.id} className={styles.checkboxLabel}>
@ -434,7 +595,11 @@ export const AdminMandateWizardPage: React.FC = () => {
</div> </div>
)} )}
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<button className={styles.primaryButton} onClick={onSubmit} disabled={isLoading || !formValue.userId}> <button
className={styles.primaryButton}
onClick={onSubmit}
disabled={isLoading || formValue.userIds.length === 0}
>
{isLoading ? 'Hinzufügen...' : 'Hinzufügen'} {isLoading ? 'Hinzufügen...' : 'Hinzufügen'}
</button> </button>
<button className={styles.secondaryButton} onClick={onCancel}> <button className={styles.secondaryButton} onClick={onCancel}>
@ -554,21 +719,21 @@ export const AdminMandateWizardPage: React.FC = () => {
</> </>
) : ( ) : (
<div style={{ display: 'grid', gap: '12px' }}> <div style={{ display: 'grid', gap: '12px' }}>
<div className={styles.formGroup}> {mandateAttrLoading || createFormAttributes.length === 0 ? (
<label className={`${styles.formLabel} ${styles.required}`}>Name</label> <div className={styles.loadingContainer} style={{ padding: '24px' }}>
<input <div className={styles.spinner} />
className={styles.formInput} <span>Formular wird geladen...</span>
value={mandateForm.name} </div>
onChange={e => setMandateForm(p => ({ ...p, name: e.target.value }))} ) : (
placeholder="z.B. Swiss Trust AG" <FormGeneratorForm
attributes={createFormAttributesWithBilling}
mode="create"
onSubmit={handleCreateMandate}
onCancel={() => setIsCreatingMandate(false)}
submitButtonText={isLoading ? 'Erstellen...' : 'Mandant erstellen'}
cancelButtonText="Abbrechen"
/> />
</div> )}
<div style={{ display: 'flex', gap: '8px' }}>
<button className={styles.primaryButton} onClick={handleCreateMandate} disabled={isLoading}>
{isLoading ? 'Erstellen...' : 'Mandant erstellen'}
</button>
<button className={styles.secondaryButton} onClick={() => setIsCreatingMandate(false)}>Abbrechen</button>
</div>
</div> </div>
)} )}
@ -606,11 +771,64 @@ export const AdminMandateWizardPage: React.FC = () => {
addMandateUserForm, addMandateUserForm,
setAddMandateUserForm, setAddMandateUserForm,
handleAddMandateUser, handleAddMandateUser,
() => { setIsAddingMandateUser(false); setAddMandateUserForm({ userId: '', roleIds: [] }); }, () => { setIsAddingMandateUser(false); setAddMandateUserForm({ userIds: [], roleIds: [] }); },
)}
{roleEditContext?.scope === 'mandate' && (
<div style={{
padding: '16px',
background: 'var(--bg-secondary, #f8fafc)',
borderRadius: '8px',
marginBottom: '16px',
display: 'grid',
gap: '12px',
border: '1px solid var(--border-color, #e5e7eb)',
}}>
<div style={{ fontWeight: 600, fontSize: '14px' }}>Rollen bearbeiten</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{mandateRoles.map(r => (
<label key={r.id} className={styles.checkboxLabel}>
<input
type="checkbox"
checked={roleEditDraft.includes(r.id)}
onChange={e => {
setRoleEditDraft(prev =>
e.target.checked ? [...prev, r.id] : prev.filter(id => id !== r.id),
);
}}
/>
{r.roleLabel}
</label>
))}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
className={styles.primaryButton}
onClick={() => _saveMandateRoleEdit()}
disabled={isLoading}
>
Speichern
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={() => { setRoleEditContext(null); setRoleEditDraft([]); }}
>
Abbrechen
</button>
</div>
</div>
)} )}
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
{renderUserTable(mandateUsers as any[], handleRemoveMandateUser)} {renderUserTable(mandateUsers as WizardUserRow[], mandateRoles, handleRemoveMandateUser, {
onEditRoles: (userId, ids) => {
setError(null);
setRoleEditContext({ scope: 'mandate', userId });
setRoleEditDraft([...ids]);
},
})}
</div> </div>
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}> <div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
@ -770,13 +988,68 @@ export const AdminMandateWizardPage: React.FC = () => {
addInstanceUserForm, addInstanceUserForm,
setAddInstanceUserForm, setAddInstanceUserForm,
handleAddInstanceUser, handleAddInstanceUser,
() => { setIsAddingInstanceUser(false); setAddInstanceUserForm({ userId: '', roleIds: [] }); }, () => { setIsAddingInstanceUser(false); setAddInstanceUserForm({ userIds: [], roleIds: [] }); },
)}
{roleEditContext?.scope === 'instance' && (
<div style={{
padding: '16px',
background: 'var(--bg-secondary, #f8fafc)',
borderRadius: '8px',
marginBottom: '16px',
display: 'grid',
gap: '12px',
border: '1px solid var(--border-color, #e5e7eb)',
}}>
<div style={{ fontWeight: 600, fontSize: '14px' }}>Rollen bearbeiten (Feature-Instanz)</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{instanceRoles.map(r => (
<label key={r.id} className={styles.checkboxLabel}>
<input
type="checkbox"
checked={roleEditDraft.includes(r.id)}
onChange={e => {
setRoleEditDraft(prev =>
e.target.checked ? [...prev, r.id] : prev.filter(id => id !== r.id),
);
}}
/>
{r.roleLabel}
</label>
))}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
className={styles.primaryButton}
onClick={() => _saveInstanceRoleEdit()}
disabled={isLoading}
>
Speichern
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={() => { setRoleEditContext(null); setRoleEditDraft([]); }}
>
Abbrechen
</button>
</div>
</div>
)} )}
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
{renderUserTable( {renderUserTable(
instanceUsers.map(u => ({ ...u, userId: u.userId || u.id })), instanceUsers.map(u => ({ ...u, userId: u.userId || u.id })) as WizardUserRow[],
instanceRoles,
handleRemoveInstanceUser, handleRemoveInstanceUser,
{
onEditRoles: (userId, ids) => {
setError(null);
setRoleEditContext({ scope: 'instance', userId });
setRoleEditDraft([...ids]);
},
},
)} )}
</div> </div>

View file

@ -9,7 +9,8 @@ import React, { useState, useMemo, useEffect } from 'react';
import { useConnections, type Connection } from '../../hooks/useConnections'; import { useConnections, type Connection } from '../../hooks/useConnections';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; 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'; import styles from '../admin/Admin.module.css';
export const ConnectionsPage: React.FC = () => { export const ConnectionsPage: React.FC = () => {
@ -37,6 +38,7 @@ export const ConnectionsPage: React.FC = () => {
const [editingConnection, setEditingConnection] = useState<Connection | null>(null); const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set()); const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set()); const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
const [adminConsentPending, setAdminConsentPending] = useState(false);
// Initial fetch // Initial fetch
useEffect(() => { 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 // Form attributes for edit modal
const formAttributes = useMemo(() => { const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'userId', '_createdBy', '_createdAt', '_modifiedAt', 'connectedAt', 'lastChecked']; const excludedFields = ['id', 'mandateId', 'userId', '_createdBy', '_createdAt', '_modifiedAt', 'connectedAt', 'lastChecked'];
@ -204,13 +224,21 @@ export const ConnectionsPage: React.FC = () => {
} }
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Verbindungen</h1> <h1 className={styles.pageTitle}>Verbindungen</h1>
<p className={styles.pageSubtitle}>OAuth-Verbindungen verwalten</p> <p className={styles.pageSubtitle}>OAuth-Verbindungen verwalten</p>
</div> </div>
<div className={styles.headerActions}> <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 <button
className={styles.secondaryButton} className={styles.secondaryButton}
onClick={() => refetch()} onClick={() => refetch()}

View file

@ -307,7 +307,7 @@ export const FilesPage: React.FC = () => {
} }
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"

View file

@ -8,8 +8,13 @@
*/ */
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling'; import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
import { useAdminMandates } from '../../hooks/useMandates'; import type { CheckoutCreateRequest } from '../../api/billingApi';
import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates';
import { useCurrentUser } from '../../hooks/useUsers';
import api from '../../api';
import { getUserDataCache } from '../../utils/userCache';
import styles from './Billing.module.css'; import styles from './Billing.module.css';
const _formatCurrency = (amount: number) => { const _formatCurrency = (amount: number) => {
@ -19,37 +24,49 @@ const _formatCurrency = (amount: number) => {
}).format(amount); }).format(amount);
}; };
const _mandateDisplayLabel = (m: UserMandateRow): string => {
if (m.label) return m.label;
if (typeof m.name === 'object' && m.name) {
const n = m.name as Record<string, string>;
return n.de || n.en || Object.values(n)[0] || m.id;
}
return (m.name as string) || m.id;
};
// ============================================================================ // ============================================================================
// MANDATE SELECTOR // MANDATE SELECTOR
// ============================================================================ // ============================================================================
interface MandateSelectorProps { interface MandateSelectorProps {
mandates: UserMandateRow[];
loading: boolean;
selectedMandateId: string | null; selectedMandateId: string | null;
onSelect: (mandateId: string) => void; onSelect: (mandateId: string) => void;
} }
const MandateSelector: React.FC<MandateSelectorProps> = ({ selectedMandateId, onSelect }) => { const MandateSelector: React.FC<MandateSelectorProps> = ({
const { mandates, loading } = useAdminMandates(); mandates,
loading,
return ( selectedMandateId,
<div className={styles.formGroup}> onSelect,
<label>Mandant auswählen</label> }) => (
<select <div className={styles.formGroup}>
className={styles.select} <label>Mandant auswählen</label>
value={selectedMandateId || ''} <select
onChange={(e) => onSelect(e.target.value)} className={styles.select}
disabled={loading} value={selectedMandateId || ''}
> onChange={e => onSelect(e.target.value)}
<option value="">-- Mandant wählen --</option> disabled={loading}
{mandates.map((mandate) => ( >
<option key={mandate.id} value={mandate.id}> <option value="">-- Mandant wählen --</option>
{mandate.label || mandate.name || mandate.id} {mandates.map(mandate => (
</option> <option key={mandate.id} value={mandate.id}>
))} {_mandateDisplayLabel(mandate)}
</select> </option>
</div> ))}
); </select>
}; </div>
);
// ============================================================================ // ============================================================================
// SETTINGS EDITOR // SETTINGS EDITOR
@ -63,10 +80,9 @@ interface SettingsEditorProps {
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => { const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
billingModel: settings?.billingModel || 'UNLIMITED', billingModel: (settings?.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE') as BillingSettings['billingModel'],
defaultUserCredit: settings?.defaultUserCredit || 10, defaultUserCredit: Number(settings?.defaultUserCredit ?? 0),
warningThresholdPercent: settings?.warningThresholdPercent || 10, warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
blockOnZeroBalance: settings?.blockOnZeroBalance ?? true,
notifyOnWarning: settings?.notifyOnWarning ?? true, notifyOnWarning: settings?.notifyOnWarning ?? true,
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -75,11 +91,10 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
setFormData({ setFormData({
billingModel: settings.billingModel, billingModel: settings.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE',
defaultUserCredit: settings.defaultUserCredit, defaultUserCredit: Number(settings.defaultUserCredit ?? 0),
warningThresholdPercent: settings.warningThresholdPercent, warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
blockOnZeroBalance: settings.blockOnZeroBalance, notifyOnWarning: settings.notifyOnWarning ?? true,
notifyOnWarning: settings.notifyOnWarning,
}); });
} }
}, [settings]); }, [settings]);
@ -116,12 +131,10 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
<select <select
className={styles.select} className={styles.select}
value={formData.billingModel} value={formData.billingModel}
onChange={(e) => setFormData(prev => ({ ...prev, billingModel: e.target.value as any }))} onChange={(e) => setFormData(prev => ({ ...prev, billingModel: e.target.value as BillingSettings['billingModel'] }))}
> >
<option value="UNLIMITED">Unlimited</option>
<option value="PREPAY_MANDATE">Prepaid (Mandant)</option> <option value="PREPAY_MANDATE">Prepaid (Mandant)</option>
<option value="PREPAY_USER">Prepaid (Benutzer)</option> <option value="PREPAY_USER">Prepaid (Benutzer)</option>
<option value="CREDIT_POSTPAY">Kredit (Postpay)</option>
</select> </select>
</div> </div>
@ -151,18 +164,6 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
step="1" step="1"
/> />
</div> </div>
<div className={styles.formGroup}>
<label>&nbsp;</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.blockOnZeroBalance}
onChange={(e) => setFormData(prev => ({ ...prev, blockOnZeroBalance: e.target.checked }))}
/>
Bei Guthaben 0 blockieren
</label>
</div>
</div> </div>
<div className={styles.formRow}> <div className={styles.formRow}>
@ -363,7 +364,6 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
<div className={styles.accountInfo}> <div className={styles.accountInfo}>
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>} {account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span> <span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
{account.creditLimit && <span>Limit: {formatCurrency(account.creditLimit)}</span>}
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span> <span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
</div> </div>
</div> </div>
@ -373,13 +373,113 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
); );
}; };
// ============================================================================
// MANDATE ADMIN — STRIPE TOP-UP (same URL as SysAdmin billing admin)
// ============================================================================
interface MandateStripeTopUpProps {
mandateId: string;
createCheckout: (
checkoutRequest: CheckoutCreateRequest,
targetMandateId?: string
) => Promise<{ redirectUrl?: string } | null>;
}
const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, createCheckout }) => {
const [amount, setAmount] = useState('');
const [busy, setBusy] = useState(false);
const [localMsg, setLocalMsg] = useState<string | null>(null);
const _handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const n = parseFloat(amount);
if (!n || n <= 0) {
setLocalMsg('Betrag muss positiv sein');
return;
}
setBusy(true);
setLocalMsg(null);
try {
const currentUser = getUserDataCache();
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.delete('success');
currentUrl.searchParams.delete('canceled');
currentUrl.searchParams.delete('session_id');
currentUrl.hash = '';
const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`;
const result = await createCheckout(
{ userId: currentUser?.id, amount: n, returnUrl },
mandateId
);
if (result?.redirectUrl) {
window.location.href = result.redirectUrl;
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Checkout fehlgeschlagen';
setLocalMsg(msg);
setBusy(false);
}
};
return (
<div className={styles.adminSection}>
<h3>Guthaben via Stripe aufladen</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary, #64748b)', marginTop: 0 }}>
Sie werden zu Stripe weitergeleitet. Nach erfolgreicher Zahlung kehren Sie hierher zurück.
</p>
{localMsg && <div className={styles.errorMessage}>{localMsg}</div>}
<form onSubmit={_handleSubmit}>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Betrag (CHF)</label>
<input
type="number"
className={styles.input}
value={amount}
onChange={e => setAmount(e.target.value)}
placeholder="z.B. 50"
min="0.01"
step="0.01"
required
/>
</div>
</div>
<button
type="submit"
className={`${styles.button} ${styles.buttonPrimary}`}
disabled={busy || !amount}
>
{busy ? 'Weiterleitung...' : 'Mit Stripe bezahlen'}
</button>
</form>
</div>
);
};
// ============================================================================ // ============================================================================
// MAIN COMPONENT // MAIN COMPONENT
// ============================================================================ // ============================================================================
export const BillingAdmin: React.FC = () => { export const BillingAdmin: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { user: currentUser } = useCurrentUser();
const isSysAdmin = currentUser?.isSysAdmin === true;
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null); const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const { settings, accounts, users, loading, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); const [mandateList, setMandateList] = useState<UserMandateRow[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true);
const { fetchMandates } = useUserMandates();
const {
settings,
accounts,
users,
loading,
saveSettings,
addCredit,
loadAccounts,
createCheckout,
} = useBillingAdmin(selectedMandateId || undefined);
const handleMandateSelect = (mandateId: string) => { const handleMandateSelect = (mandateId: string) => {
setSelectedMandateId(mandateId || null); setSelectedMandateId(mandateId || null);
@ -398,47 +498,172 @@ export const BillingAdmin: React.FC = () => {
return result; return result;
}, [selectedMandateId, addCredit, loadAccounts]); }, [selectedMandateId, addCredit, loadAccounts]);
useEffect(() => {
let cancelled = false;
(async () => {
setMandatesLoading(true);
try {
const data = await fetchMandates();
if (!cancelled) {
setMandateList(Array.isArray(data) ? data : []);
}
} finally {
if (!cancelled) setMandatesLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [fetchMandates]);
useEffect(() => {
if (!isSysAdmin && mandateList.length === 1 && selectedMandateId === null) {
setSelectedMandateId(mandateList[0].id);
}
}, [isSysAdmin, mandateList, selectedMandateId]);
const [stripeReturnMessage, setStripeReturnMessage] = useState<{
type: 'success' | 'error';
text: string;
} | null>(null);
const successParam = searchParams.get('success');
const canceledParam = searchParams.get('canceled');
const sessionIdParam = searchParams.get('session_id');
useEffect(() => {
let cancelled = false;
const _confirmCheckoutIfNeeded = async () => {
if (successParam !== 'true') {
if (canceledParam === 'true' && !cancelled) {
setStripeReturnMessage({ type: 'error', text: 'Zahlung abgebrochen.' });
}
return;
}
if (!sessionIdParam) {
if (!cancelled) {
setStripeReturnMessage({
type: 'success',
text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.',
});
}
if (selectedMandateId) await loadAccounts(selectedMandateId);
return;
}
try {
await api.post('/api/billing/checkout/confirm', { sessionId: sessionIdParam });
if (!cancelled) {
setStripeReturnMessage({
type: 'success',
text: 'Zahlung erfolgreich. Guthaben wurde verbucht.',
});
}
} catch (err: unknown) {
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
if (!cancelled) {
setStripeReturnMessage({
type: 'error',
text:
detail ||
'Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.',
});
}
} finally {
if (selectedMandateId) await loadAccounts(selectedMandateId);
}
};
_confirmCheckoutIfNeeded();
return () => {
cancelled = true;
};
}, [successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]);
const _clearStripeParams = useCallback(() => {
searchParams.delete('success');
searchParams.delete('canceled');
searchParams.delete('session_id');
setSearchParams(searchParams, { replace: true });
setStripeReturnMessage(null);
}, [searchParams, setSearchParams]);
const showStripeForMandateAdmin = !isSysAdmin && !!selectedMandateId && !!settings;
return ( return (
<div className={styles.billingDashboard}> <div className={styles.billingDashboard}>
<header className={styles.pageHeader}> <header className={styles.pageHeader}>
<h1>Billing Administration</h1> <h1>Billing Administration</h1>
<p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p> <p className={styles.subtitle}>
{isSysAdmin
? 'Verwaltung von Abrechnungseinstellungen und Guthaben'
: 'Guthaben und Konten für Ihre Mandanten'}
</p>
{isSysAdmin && (
<p style={{ marginTop: '8px' }}>
<Link to="/admin/billing/mandates" style={{ color: 'var(--color-primary)' }}>
Mandanten-Übersicht (Balances &amp; Transaktionen)
</Link>
</p>
)}
</header> </header>
{stripeReturnMessage && (
<div
className={
stripeReturnMessage.type === 'success' ? styles.successMessage : styles.errorMessage
}
style={{ marginBottom: '1rem' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '12px', alignItems: 'center' }}>
<span>{stripeReturnMessage.text}</span>
<button type="button" className={styles.button} onClick={_clearStripeParams}>
OK
</button>
</div>
</div>
)}
<section className={styles.section}> <section className={styles.section}>
<MandateSelector <MandateSelector
selectedMandateId={selectedMandateId} mandates={mandateList}
onSelect={handleMandateSelect} loading={mandatesLoading}
selectedMandateId={selectedMandateId}
onSelect={handleMandateSelect}
/> />
</section> </section>
{selectedMandateId && ( {selectedMandateId && (
<> <>
<SettingsEditor {isSysAdmin && (
settings={settings} <SettingsEditor
onSave={handleSaveSettings} settings={settings}
loading={loading} onSave={handleSaveSettings}
/> loading={loading}
/>
<CreditAdder )}
settings={settings}
accounts={accounts} {isSysAdmin && (
users={users} <CreditAdder
onAddCredit={_handleAddCredit} settings={settings}
/> accounts={accounts}
users={users}
<AccountsOverview onAddCredit={_handleAddCredit}
accounts={accounts} />
users={users} )}
loading={loading}
/> {showStripeForMandateAdmin && (
<MandateStripeTopUp mandateId={selectedMandateId} createCheckout={createCheckout} />
)}
<AccountsOverview accounts={accounts} users={users} loading={loading} />
</> </>
)} )}
{!selectedMandateId && ( {!selectedMandateId && (
<div className={styles.noData}> <div className={styles.noData}>Bitte wählen Sie einen Mandanten aus.</div>
Bitte wählen Sie einen Mandanten aus.
</div>
)} )}
</div> </div>
); );

View file

@ -27,13 +27,8 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
}; };
const getBillingModelLabel = (model: string) => { const getBillingModelLabel = (model: string) => {
switch (model) { if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; return 'Prepaid (Mandant)';
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
case 'CREDIT_POSTPAY': return 'Kredit';
case 'UNLIMITED': return 'Unlimited';
default: return model;
}
}; };
return ( return (

View file

@ -61,18 +61,12 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
const [showCheckout, setShowCheckout] = useState(false); const [showCheckout, setShowCheckout] = useState(false);
const _getBillingModelLabel = (model: string) => { const _getBillingModelLabel = (model: string) => {
switch (model) { if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; return 'Prepaid (Mandant)';
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
case 'CREDIT_POSTPAY': return 'Kredit';
case 'UNLIMITED': return 'Unlimited';
default: return model;
}
}; };
const canTopUp = balance.billingModel === 'PREPAY_USER' const canTopUp = balance.billingModel === 'PREPAY_USER'
|| balance.billingModel === 'PREPAY_MANDATE' || balance.billingModel === 'PREPAY_MANDATE';
|| balance.billingModel === 'CREDIT_POSTPAY';
return ( return (
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}> <div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>

View file

@ -39,13 +39,8 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
}; };
const getBillingModelLabel = (model: string) => { const getBillingModelLabel = (model: string) => {
switch (model) { if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; return 'Prepaid (Mandant)';
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
case 'CREDIT_POSTPAY': return 'Kredit';
case 'UNLIMITED': return 'Unlimited';
default: return model;
}
}; };
return ( return (

View file

@ -433,7 +433,7 @@ export const AutomationDefinitionsView: React.FC = () => {
} }
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Automatisierungen</h1> <h1 className={styles.pageTitle}>Automatisierungen</h1>

View file

@ -123,7 +123,7 @@ export const AutomationTemplatesView: React.FC = () => {
} }
return ( return (
<div className={styles.adminPage}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Automation-Vorlagen</h1> <h1 className={styles.pageTitle}>Automation-Vorlagen</h1>

View file

@ -74,6 +74,18 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
<span>{msg.message}</span> <span>{msg.message}</span>
) : ( ) : (
<div className="workspace-markdown"> <div className="workspace-markdown">
{msg.documentsLabel && (
<div
style={{
fontSize: 12,
color: '#666',
marginBottom: msg.message ? 8 : 0,
fontStyle: 'italic',
}}
>
{msg.documentsLabel}
</div>
)}
{msg.message && ( {msg.message && (
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}

View file

@ -9,7 +9,7 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import api from '../../../api'; import api from '../../../api';
import { startSseStream, SseEvent } from '../../../utils/sseClient'; 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 { export interface AgentProgress {
round: number; round: number;
@ -173,13 +173,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
api.get(`/api/workspace/${instanceId}/workflows/${wfId}/messages`) api.get(`/api/workspace/${instanceId}/workflows/${wfId}/messages`)
.then(res => { .then(res => {
const msgs = (res.data.messages || []).map((m: any) => ({ const msgs = (res.data.messages || [])
id: m.id || `loaded-${Math.random()}`, .map((m: any) => _mapLoadedWorkspaceMessage(m, wfId))
workflowId: wfId, .sort(_compareWorkspaceMessages);
role: m.role || 'assistant',
message: m.content || m.message || '',
publishedAt: m.createdAt || Date.now() / 1000,
}));
setMessages(msgs); setMessages(msgs);
}) })
.catch(() => {}); .catch(() => {});
@ -210,6 +206,13 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
role: 'user', role: 'user',
message: prompt, message: prompt,
publishedAt: Date.now() / 1000, 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), onStopped: () => setIsProcessing(false),
onError: (event) => { onError: (event) => {
setIsProcessing(false); setIsProcessing(false);
const item = event.item as Record<string, unknown> | undefined;
let msg = event.content || 'Unknown error';
if (item && item.error === 'INSUFFICIENT_BALANCE') {
const preferDe =
typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de');
const de = typeof item.messageDe === 'string' ? item.messageDe : '';
const en = typeof item.messageEn === 'string' ? item.messageEn : '';
msg = preferDe ? de || en || msg : en || de || msg;
if (item.userAction === 'TOP_UP_SELF' && typeof item.billingUiPath === 'string') {
msg += `\n\n→ ${item.billingUiPath}`;
}
} else {
msg = `Error: ${msg}`;
}
setMessages(prev => [ setMessages(prev => [
...prev, ...prev,
{ {
id: `error-${Date.now()}`, id: `error-${Date.now()}`,
workflowId: '', workflowId: '',
role: 'system', role: 'system',
message: `Error: ${event.content || 'Unknown error'}`, message: msg,
publishedAt: Date.now() / 1000, publishedAt: Date.now() / 1000,
}, },
]); ]);
@ -379,7 +396,15 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
onStreamEnd: () => setIsProcessing(false), onStreamEnd: () => setIsProcessing(false),
}); });
}, },
[instanceId, isProcessing, workflowId, refreshFiles], [
instanceId,
isProcessing,
workflowId,
refreshFiles,
files,
dataSources,
featureDataSources,
],
); );
const stopProcessing = useCallback(() => { 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 // Internal event handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View 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 };
}