From 9b990206864c427941946ae1300cfe937c279249 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 21 Mar 2026 01:34:47 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(billing):=20Nutzerhinweise=20bei=20lee?= =?UTF-8?q?rem=20Budget=20+=20Mandats-Mail=20(402/SSE)=20Gateway=20-=20Ins?= =?UTF-8?q?ufficientBalanceException:=20billingModel,=20userAction=20(TOP?= =?UTF-8?q?=5FUP=5FSELF=20/=20=20=20CONTACT=5FMANDATE=5FADMIN),=20DE/EN-Te?= =?UTF-8?q?xte,=20toClientDict(),=20fromBalanceCheck()=20-=20HTTP=20402=20?= =?UTF-8?q?+=20JSON=20detail=20f=C3=BCr=20globale=20API-Fehlerbehandlung?= =?UTF-8?q?=20-=20AI/Chatbot:=20vor=20Raise=20ggf.=20E-Mail=20an=20Billing?= =?UTF-8?q?Settings.notifyEmails=20=20=20(PREPAY=5FMANDATE,=20Throttle=201?= =?UTF-8?q?h/Mandat)=20via=20billingExhaustedNotify=20-=20Agent-Loop=20&?= =?UTF-8?q?=20Workspace-Route:=20SSE-ERROR=20mit=20strukturiertem=20Billin?= =?UTF-8?q?g-Payload=20-=20datamodelBilling:=20notifyEmails-Doku=20f=C3=BC?= =?UTF-8?q?r=20Pool-Alerts=20frontend=5Fnyla=20-=20useWorkspace:=20SSE=20o?= =?UTF-8?q?nError=20f=C3=BCr=20INSUFFICIENT=5FBALANCE=20mit=20messageDe/En?= =?UTF-8?q?=20=20=20und=20Hinweis=20auf=20Billing-Pfad=20bei=20TOP=5FUP=5F?= =?UTF-8?q?SELF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MONETARISIERUNG_FEATURES_INTERAKTIV.md | 227 ++++++++++ docs/MONETARISIERUNG_KURZ_PRAESENTATION.md | 68 +++ src/App.tsx | 7 +- src/api.ts | 5 +- src/api/billingApi.ts | 18 +- src/api/userApi.ts | 8 +- .../FormGeneratorForm/FormGeneratorForm.tsx | 24 +- src/components/Navigation/UserSection.tsx | 4 +- src/hooks/useAuthentication.ts | 4 +- src/hooks/useBilling.ts | 117 ++--- src/hooks/useMandates.ts | 89 +++- src/hooks/useNotifications.ts | 23 + src/hooks/useUsers.ts | 62 +-- src/layouts/MainLayout.module.css | 13 + src/layouts/MainLayout.tsx | 5 +- src/pages/Dashboard.tsx | 17 +- src/pages/Store.tsx | 14 +- src/pages/admin/AccessManagementHub.tsx | 7 +- src/pages/admin/Admin.module.css | 18 +- src/pages/admin/AdminAutomationEventsPage.tsx | 2 +- src/pages/admin/AdminFeatureAccessPage.tsx | 11 +- .../admin/AdminFeatureInstanceUsersPage.tsx | 7 +- src/pages/admin/AdminFeatureRolesPage.tsx | 4 +- src/pages/admin/AdminInvitationsPage.tsx | 4 +- src/pages/admin/AdminMandateRolesPage.tsx | 4 +- src/pages/admin/AdminMandatesPage.tsx | 147 +++++-- .../admin/AdminUserAccessOverviewPage.tsx | 4 +- src/pages/admin/AdminUserMandatesPage.tsx | 4 +- src/pages/admin/AdminUsersPage.tsx | 4 +- .../admin/wizards/AdminMandateWizardPage.tsx | 413 +++++++++++++++--- src/pages/billing/BillingAdmin.tsx | 379 ++++++++++++---- src/pages/billing/BillingDashboard.tsx | 9 +- src/pages/billing/BillingDataView.tsx | 12 +- src/pages/billing/BillingMandateView.tsx | 9 +- src/pages/views/workspace/useWorkspace.ts | 16 +- src/utils/mandateBillingFormMerge.ts | 163 +++++++ 36 files changed, 1522 insertions(+), 400 deletions(-) create mode 100644 docs/MONETARISIERUNG_FEATURES_INTERAKTIV.md create mode 100644 docs/MONETARISIERUNG_KURZ_PRAESENTATION.md create mode 100644 src/utils/mandateBillingFormMerge.ts diff --git a/docs/MONETARISIERUNG_FEATURES_INTERAKTIV.md b/docs/MONETARISIERUNG_FEATURES_INTERAKTIV.md new file mode 100644 index 0000000..2c73318 --- /dev/null +++ b/docs/MONETARISIERUNG_FEATURES_INTERAKTIV.md @@ -0,0 +1,227 @@ +# Monetarisierung der Features — interaktives Klärungs-Playbook + +Dieses Dokument ist für **Live-Workshops** gedacht (Product, Sales, Tech). Du kannst es in Cursor/VS Code oder GitHub öffnen: **Checkboxen** (`- [ ]`) und **leere Tabellenzellen** werden direkt im Editor abgehakt bzw. ausgefüllt. + +--- + +## 1. Ausgangslage aus `frontend_nyla` (gemeinsames Bild) + +Die App denkt in **Mandanten → Features → Instanzen → Rechte (Views)**. Nutzer haben keinen direkten „Mandanten-Login“, sondern **Zugriff auf Feature-Instanzen** (`featureStore`). + +| Baustein | Bedeutung für Pricing | +|----------|------------------------| +| **Feature-Code** (z. B. `chatbot`, `workspace`) | logische Produktlinie / Modul | +| **Instanz** | oft Kunde, Abteilung, Bot, “Organisation” — **horizontale Skalierung** (mehr Instanzen = mehr Wert oder mehr Kosten) | +| **Views / Berechtigungen** | micro-Segmente im Produkt (**Add-ons**, Rollenpakete, „Light vs Pro“) | +| **Feature Store** (`Store.tsx`) | Self-Service-Aktivierung (z. B. `automation`, `teamsbot`) — Kandidaten für **Freemium / Trial / Upsell** | +| **Billing im Frontend** (`billingApi.ts`) | Modelle `PREPAY_MANDATE`, `PREPAY_USER`, `UNLIMITED`; Transaktionen mit u. a. `featureCode`, `featureInstanceId`, Provider/Modell | + +**Leitidee:** Preis = *was* (Feature/View) × *wie viel* (Instanz, Nutzer, Volumen) × *wie abgerechnet* (Pauschale, Credits, Nachzahlung). + +--- + +## 2. Schnell-Check: Welches Geschäftsmodell passt grob? + +Arbeite die Fragen der Reihe nach durch und hake ab. + +- [ ] **Primärer Käufer:** zahlt der **Mandant** (Firma), der **Endnutzer**, oder ein **Partner**? +- [ ] **Value Metric:** was korreliert am ehesten mit Kundennutzen? (z. B. Mandanten, Instanzen, aktive Nutzer, gespeicherte Dokumente, API-Calls, AI-Tokens/CHF-Verbrauch) +- [ ] **Margen-Risiko:** wo sind **variable Kosten** (LLM, Storage, Third-Party) — müssen die **durchgereicht** oder **gepuffert** werden? +- [ ] **Go-to-Market:** brauchst du **Selbstbedienung** (Store) oder nur **Sales / Onboarding** (Admin legt Instanzen an)? +- [ ] **Fairness:** soll ein Power-User **pro Nutzer** limitiert sein oder **Kontingent pro Mandant**? + +### Entscheidungsbaum (Diskussionsvorlage) + +```mermaid +flowchart TD + A[Nutzerwert skaliert primär mit Volumen?] -->|Ja| B[Usage / Credits / Nachzahlung] + A -->|Nein| C[Festpreis / Seat / Instanz] + B --> D[Pro Feature oder globaler Credit-Pool?] + C --> E[Wer zählt als Seat: Login oder aktive Nutzung?] + D --> F[Sichtbar im Billing-Dashboard pro FeatureCode?] + E --> G[Wie viele Instanzen sind inkludiert?] +``` + +Trage Ergebnis hier ein (ein Satz): + +> **Entscheidung Grobmodell:** … + +--- + +## 3. Billing-Modelle (Anknüpfung an `billingApi.ts`) + +Ordnet euer **Angebot** den technisch vorhandenen **BillingModel**-Werten zu (Namen aus dem Frontend): + +| `BillingModel` | Typische Kundenstory | Wann sinnvoll? | +|----------------|---------------------|----------------| +| `PREPAY_MANDATE` | „Wir laden ein Konto auf, alle ziehen daraus.“ | Gemeinsamer Pool, ein Rechnungsempfänger | +| `PREPAY_USER` | „Jeder Nutzer hat ein Kontingent.“ | faire Verteilung bei heterogenem Nutzungsverhalten | +| `UNLIMITED` | „Flat rate / Enterprise-Vertrag.“ | Paketpreis deckt erwarteten Mix | + +**Workshop-Aufgabe** + +- [ ] Standard für **SMB**: +- [ ] Standard für **Enterprise**: +- [ ] Ausnahme / Piloten: + +--- + +## 4. Feature-Inventar (aus `FEATURE_REGISTRY`) + +Die folgende Tabelle ist die **Checkliste pro Modul**. Pro Zeile: **was** verkaufen wir, **woran** messen wir, **was** ist Inhalt eines Pakets? + +| `featureCode` | Produktname (DE) | Hauptnutzen (1 Satz) | Typische „Einheit“ für Preis (*Seat / Instanz / Request / GB / CHF-Verbrauch*) | Im **Feature Store** relevant? | Variable Kosten? (ja/nein/klein) | Notizen | +|---------------|------------------|----------------------|----------------------------------------------------------------------------------|-------------------------------|-----------------------------------|---------| +| `trustee` | Treuhand | | | ☐ ja ☐ nein | | | +| `realestate` | Immobilien | | | ☐ ja ☐ nein | | | +| `chatbot` | Chatbot | | | ☐ ja ☐ nein | | | +| `chatworkflow` | Workflow | | | ☐ ja ☐ nein | | | +| `automation` | Automatisierung | | | ☐ ja ☐ nein | | | +| `teamsbot` | Teams Bot | | | ☐ ja ☐ nein | | | +| `neutralization` | Neutralisierung | | | ☐ ja ☐ nein | | | +| `commcoach` | Kommunikations-Coach | | | ☐ ja ☐ nein | | | +| `workspace` | AI Workspace | | | ☐ ja ☐ nein | | | + +> **Hinweis Store:** In `Store.tsx` sind derzeit u. a. `automation` und `teamsbot` als Self-Service beschrieben — weitere Features können folgen, sobald Backend/Policy das erlaubt. + +--- + +## 5. Views als Upsell-Stufen (fein granulare Monetarisierung) + +Viele **Views** sind Kandidaten für „Basic / Pro“ oder Add-ons (technisch: Rechte pro View). + +**Vorgehen pro Feature:** Liste die Views und markiere: **Base** (muss rein), **Upsell**, **Admin-only** (meist kein direkter Upsell). + +### `trustee` + +- [ ] `dashboard` — Base / Upsell / Admin-only +- [ ] `positions` — … +- [ ] `documents` — … +- [ ] `position-documents` — … +- [ ] `expense-import` — … +- [ ] `scan-upload` — … +- [ ] `instance-roles` (adminOnly) — … +- [ ] `settings` — … + +### `chatbot` + +- [ ] `conversations` — … +- [ ] `settings` — … + +### `automation` + +- [ ] `definitions` — … +- [ ] `templates` — … +- [ ] `logs` — … + +### `teamsbot` + +- [ ] `dashboard` — … +- [ ] `sessions` — … +- [ ] `settings` — … + +### `neutralization` + +- [ ] `playground` / `dashboard` — … +- [ ] `config` — … +- [ ] `attributes` — … + +### `commcoach` + +- [ ] `dashboard` — … +- [ ] `coaching` — … +- [ ] `dossier` — … +- [ ] `settings` — … + +### `workspace` + +- [ ] `dashboard` — … +- [ ] `editor` — … +- [ ] `settings` — … + +### `realestate` + +- [ ] `dashboard` — … +- [ ] `instance-roles` (adminOnly) — … + +### `chatworkflow` + +- [ ] `dashboard` — … +- [ ] `runs` — … +- [ ] `files` — … + +**Paket-Entscheid (freies Feld):** + +| Paketname | Enthaltene `featureCode`s | Enthaltene Views / Ausnahmen | Limits (Instanzen, Nutzer, Speicher, Credits) | +|-----------|---------------------------|------------------------------|-----------------------------------------------| +| Starter | | | | +| Business | | | | +| Enterprise | | | | + +--- + +## 6. Nutzungsmessung vs. Produktversprechen + +Transaktionen im Billing können unter anderem **`featureCode`**, **`featureInstanceId`**, **`aicoreProvider`**, **`aicoreModel`** tragen — gut für **trans-parente** oder **Feature-spezifische** Auswertung. + +**Abgleich (interaktiv):** + +- [ ] Welche Features sollen im Kunden-UI **getrennt** sichtbar sein (`costByFeature` im Usage-Report)? +- [ ] Welche Kosten werden **in einen Topf** gelegt (einfacheres Pricing, weniger Erklärungsbedarf)? +- [ ] Gibt es **überproportionale** Kostenfaktoren (z. B. Web-Recherche beim Chatbot), die ein **Aufsatz** oder **Limit** brauchen? + +**Policy-Sätze (ausfüllen):** + +1. Wenn Guthaben **`warningThreshold`** unterschreitet, dann: … +2. Wenn **`blockOnZeroBalance`** aktiv ist, dann welche Features blocken wir zuerst: … +3. **Trial:** welche Features sind zeitlich / volumenmäßig limitiert: … + +--- + +## 7. Angebots-Builder (1 Seite für Sales) + +_Kopiere diesen Block pro Lead._ + +- **Kunde / Segment:** … +- **Mandanten-Setup:** # Instanzen geplant pro Feature: … +- **Nutzer / Rollen:** … +- **Inkludierte Module (`featureCode`):** … +- **Add-ons (Views):** … +- **BillingModel:** `PREPAY_MANDATE` / `PREPAY_USER` / `UNLIMITED` +- **Kontingente:** CHF/Monat, Tokens, API-Calls, Speicher: … +- **Preisgestaltung:** Listenpreis, Rabatt %, Laufzeit: … +- **Risiken / Sonderkosten (LLM, Integrationen):** … + +**Einwand-Notizen:** + +| Einwand | Antwort / Kompromiss | +|---------|----------------------| +| „Wir wollen unbegrenzt.“ | | +| „Wir haben viele Abteilungen (Instanzen).“ | | +| „Wir brauchen nur eine View.“ | | + +--- + +## 8. Definition of Done (Pricing ist „fertig“ besprochen) + +- [ ] Jedes `featureCode` hat **Paket** + **Messgröße** + **Ausnahmen** +- [ ] Jede revenue-relevante View ist **Base/Upsell** zugeordnet +- [ ] Store-Features haben **Activation Policy** (wer darf, was passiert nach Trial) +- [ ] Billing-Model pro Segment ist festgelegt inkl. **Warn-/Block-Verhalten** +- [ ] Sales-Template (Abschnitt 7) ist befüllt für **Pilotkunde 1** + +--- + +## 9. Referenz im Repo (für Technik-Abgleich) + +| Thema | Datei | +|-------|--------| +| Feature-Definitionen (Codes, Views) | `src/types/mandate.ts` (`FEATURE_REGISTRY`) | +| Mandant → Instanzen → Rechte | `src/stores/featureStore.tsx` | +| Self-Service Store | `src/pages/Store.tsx`, `src/api/storeApi.ts` | +| Billing-Typen & Reports | `src/api/billingApi.ts` | +| UI-Komponenten / Routing-Helfer | `src/config/pageRegistry.tsx` | + +--- + +*Version: 2026-03-20 — ausgerichtet auf den Stand von `frontend_nyla`; bei neuen Features die Tabellen in Abschnitt 4–5 ergänzen.* diff --git a/docs/MONETARISIERUNG_KURZ_PRAESENTATION.md b/docs/MONETARISIERUNG_KURZ_PRAESENTATION.md new file mode 100644 index 0000000..d8f9415 --- /dev/null +++ b/docs/MONETARISIERUNG_KURZ_PRAESENTATION.md @@ -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). diff --git a/src/App.tsx b/src/App.tsx index 0789bba..531ac7f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,7 +41,7 @@ import { FeatureViewPage } from './pages/FeatureView'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin'; import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; -import { BillingDataView, BillingAdmin } from './pages/billing'; +import { BillingDataView, BillingAdmin, BillingMandateView } from './pages/billing'; function App() { // Load saved theme preference and set app name on app mount useEffect(() => { @@ -185,7 +185,10 @@ function App() { } /> } /> } /> - } /> + + } /> + } /> + } /> } /> } /> diff --git a/src/api.ts b/src/api.ts index 55d54c0..1a5f2b8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -126,9 +126,10 @@ api.interceptors.response.use( async (error) => { if (error.response?.status === 401) { // Don't redirect to login if the request was to a login endpoint - const isLoginEndpoint = error.config?.url?.includes('/login') || + const isLoginEndpoint = error.config?.url?.includes('/login') || error.config?.url?.includes('/api/local/login') || - error.config?.url?.includes('/api/msft/login'); + error.config?.url?.includes('/api/msft/auth/login') || + error.config?.url?.includes('/api/google/auth/login'); // Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work) const pathname = window.location.pathname; diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index e82e438..403ba89 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -4,19 +4,10 @@ import { ApiRequestOptions } from '../hooks/useApi'; // TYPES & INTERFACES // ============================================================================ -export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER' | 'CREDIT_POSTPAY' | 'UNLIMITED'; +export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER'; export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT'; export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM'; -export interface BillingAddress { - company: string; - street: string; - zip: string; - city: string; - country: string; - vatNumber?: string; -} - export interface BillingBalance { mandateId: string; mandateName: string; @@ -25,7 +16,6 @@ export interface BillingBalance { currency: string; warningThreshold: number; isWarning: boolean; - creditLimit?: number; } export interface BillingTransaction { @@ -54,20 +44,16 @@ export interface BillingSettings { billingModel: BillingModel; defaultUserCredit: number; warningThresholdPercent: number; - blockOnZeroBalance: boolean; notifyOnWarning: boolean; notifyEmails: string[]; - billingAddress?: BillingAddress; } export interface BillingSettingsUpdate { billingModel?: BillingModel; defaultUserCredit?: number; warningThresholdPercent?: number; - blockOnZeroBalance?: boolean; notifyOnWarning?: boolean; notifyEmails?: string[]; - billingAddress?: BillingAddress; } export interface UsageReport { @@ -85,7 +71,6 @@ export interface AccountSummary { userId?: string; accountType: string; balance: number; - creditLimit?: number; warningThreshold: number; enabled: boolean; } @@ -325,7 +310,6 @@ export interface MandateBalance { userCount: number; defaultUserCredit: number; warningThresholdPercent: number; - blockOnZeroBalance: boolean; } /** diff --git a/src/api/userApi.ts b/src/api/userApi.ts index 0ba5f24..6df4dbd 100644 --- a/src/api/userApi.ts +++ b/src/api/userApi.ts @@ -102,18 +102,20 @@ export async function fetchCurrentUser( /** * Logout current user - * Endpoint: POST /api/local/logout | /api/msft/logout + * Endpoint: POST /api/local/logout | /api/msft/logout | /api/google/logout */ export async function logoutUser( request: ApiRequestFunction, authAuthority: string = 'local' ): Promise { let endpoint = '/api/local/logout'; - + if (authAuthority === 'msft') { endpoint = '/api/msft/logout'; + } else if (authAuthority === 'google') { + endpoint = '/api/google/logout'; } - + await request({ url: endpoint, method: 'post' diff --git a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx index a4fc05f..efa568d 100644 --- a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx +++ b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx @@ -250,6 +250,18 @@ export function FormGeneratorForm>({ } } }); + // Number/float fields: avoid empty inputs — use attribute default or type default (e.g. 0) + filteredAttrs.forEach(attr => { + if (isNumberType(attr.type as AttributeType)) { + const v = processedData[attr.name]; + if (v === undefined || v === null || v === '') { + processedData[attr.name] = + attr.default !== undefined + ? attr.default + : getDefaultValueForType(attr.type as AttributeType); + } + } + }); setFormData(processedData as T); } else { const filteredAttrs = getFilteredAttributes(); @@ -967,9 +979,15 @@ export function FormGeneratorForm>({ const inputType = attributeTypeToInputType(attr.type); // For timestamp fields, convert Unix timestamp (float) to datetime-local format for display - const displayValue = attr.type === 'timestamp' - ? timestampToDatetimeLocal(value) - : (value || ''); + // Number: must not use (value || '') — 0 is valid and would show empty + const displayValue = + attr.type === 'timestamp' + ? timestampToDatetimeLocal(value) + : isNumberType(attr.type as AttributeType) + ? value === '' || value === undefined || value === null || (typeof value === 'number' && Number.isNaN(value)) + ? '' + : String(value) + : value || ''; return (
diff --git a/src/components/Navigation/UserSection.tsx b/src/components/Navigation/UserSection.tsx index 1b6f9e7..656976d 100644 --- a/src/components/Navigation/UserSection.tsx +++ b/src/components/Navigation/UserSection.tsx @@ -7,13 +7,11 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useCurrentUser } from '../../hooks/useUsers'; -import { useMsal } from '@azure/msal-react'; import { NotificationBell } from '../NotificationBell'; import styles from './UserSection.module.css'; export const UserSection: React.FC = () => { const { user, logout } = useCurrentUser(); - const { instance: msalInstance } = useMsal(); const navigate = useNavigate(); const [isLoggingOut, setIsLoggingOut] = useState(false); const [showMenu, setShowMenu] = useState(false); @@ -22,7 +20,7 @@ export const UserSection: React.FC = () => { const handleLogout = async () => { setIsLoggingOut(true); try { - await logout(msalInstance); + await logout(); } catch (error) { console.error('Logout failed:', error); setIsLoggingOut(false); diff --git a/src/hooks/useAuthentication.ts b/src/hooks/useAuthentication.ts index 3365511..9600549 100644 --- a/src/hooks/useAuthentication.ts +++ b/src/hooks/useAuthentication.ts @@ -123,7 +123,7 @@ export function useMsalAuth() { // Open popup window const popup = window.open( - `${backendUrl}/api/msft/login?state=login`, + `${backendUrl}/api/msft/auth/login`, 'microsoft-login', 'width=600,height=700,left=100,top=100' ); @@ -301,7 +301,7 @@ export function useGoogleAuth() { // Open popup window const popup = window.open( - `${backendUrl}/api/google/login?state=login`, + `${backendUrl}/api/google/auth/login`, 'google-login', 'width=600,height=700,left=100,top=100' ); diff --git a/src/hooks/useBilling.ts b/src/hooks/useBilling.ts index 15f20aa..f2c4314 100644 --- a/src/hooks/useBilling.ts +++ b/src/hooks/useBilling.ts @@ -169,59 +169,6 @@ export function useBillingAdmin(mandateId?: string) { } }, [request, mandateId]); - // Update settings - const saveSettings = useCallback(async ( - settingsUpdate: BillingSettingsUpdate, - targetMandateId?: string - ) => { - const mId = targetMandateId || mandateId; - if (!mId) return null; - - try { - const data = await updateSettingsAdmin(request, mId, settingsUpdate); - setSettings(data); - return data; - } catch (err) { - console.error('Error saving billing settings:', err); - throw err; - } - }, [request, mandateId]); - - // Add credit (manual, admin) - const addCredit = useCallback(async ( - creditRequest: CreditAddRequest, - targetMandateId?: string - ) => { - const mId = targetMandateId || mandateId; - if (!mId) return null; - - try { - const result = await addCreditAdmin(request, mId, creditRequest); - // Reload accounts after adding credit - await loadAccounts(mId); - return result; - } catch (err) { - console.error('Error adding credit:', err); - throw err; - } - }, [request, mandateId]); - - // Create Stripe Checkout Session (returns redirect URL) - const createCheckout = useCallback(async ( - checkoutRequest: CheckoutCreateRequest, - targetMandateId?: string - ) => { - const mId = targetMandateId || mandateId; - if (!mId) return null; - - try { - return await createCheckoutSessionApi(request, mId, checkoutRequest); - } catch (err) { - console.error('Error creating checkout session:', err); - throw err; - } - }, [request, mandateId]); - // Fetch accounts for a mandate const loadAccounts = useCallback(async (targetMandateId?: string) => { const mId = targetMandateId || mandateId; @@ -270,6 +217,70 @@ export function useBillingAdmin(mandateId?: string) { } }, [request, mandateId]); + // Update settings — after billing model change, reload dependent data (accounts / users / tx) + const saveSettings = useCallback( + async (settingsUpdate: BillingSettingsUpdate, targetMandateId?: string) => { + const mId = targetMandateId || mandateId; + if (!mId) return null; + + const previousModel = settings?.billingModel; + + try { + const data = await updateSettingsAdmin(request, mId, settingsUpdate); + setSettings(data); + const newModel = settingsUpdate.billingModel; + const modelChanged = + newModel !== undefined && newModel !== null && newModel !== previousModel; + if (modelChanged) { + await Promise.all([ + loadAccounts(mId), + loadTransactions(mId, 100), + loadUsers(mId), + ]); + } + return data; + } catch (err) { + console.error('Error saving billing settings:', err); + throw err; + } + }, + [request, mandateId, settings?.billingModel, loadAccounts, loadTransactions, loadUsers] + ); + + // Add credit (manual, admin) + const addCredit = useCallback( + async (creditRequest: CreditAddRequest, targetMandateId?: string) => { + const mId = targetMandateId || mandateId; + if (!mId) return null; + + try { + const result = await addCreditAdmin(request, mId, creditRequest); + await loadAccounts(mId); + return result; + } catch (err) { + console.error('Error adding credit:', err); + throw err; + } + }, + [request, mandateId, loadAccounts] + ); + + // Create Stripe Checkout Session (returns redirect URL) + const createCheckout = useCallback( + async (checkoutRequest: CheckoutCreateRequest, targetMandateId?: string) => { + const mId = targetMandateId || mandateId; + if (!mId) return null; + + try { + return await createCheckoutSessionApi(request, mId, checkoutRequest); + } catch (err) { + console.error('Error creating checkout session:', err); + throw err; + } + }, + [request, mandateId] + ); + // Load data when mandateId changes useEffect(() => { if (mandateId) { diff --git a/src/hooks/useMandates.ts b/src/hooks/useMandates.ts index 572cf7f..a276200 100644 --- a/src/hooks/useMandates.ts +++ b/src/hooks/useMandates.ts @@ -5,7 +5,7 @@ * Folgt dem gleichen Pattern wie useOrgUsers. */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useApiRequest } from './useApi'; import api from '../api'; import { usePermissions, type UserPermissions } from './usePermissions'; @@ -19,6 +19,8 @@ import { type MandateUpdateData, type PaginationParams } from '../api/mandateApi'; +import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm'; +import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge'; // Re-export types export type { Mandate, MandateUpdateData, PaginationParams }; @@ -164,14 +166,14 @@ export function useAdminMandates() { })); // Create mandate - const handleCreate = useCallback(async (mandateData: Partial): Promise => { + const handleCreate = useCallback(async (mandateData: Partial): Promise => { try { - await createMandateApi(request, mandateData); + const created = await createMandateApi(request, mandateData); await fetchMandates(); - return true; + return created ?? null; } catch (error: any) { console.error('Error creating mandate:', error); - return false; + return null; } }, [request, fetchMandates]); @@ -235,3 +237,80 @@ export function useAdminMandates() { } export default useAdminMandates; + +/** + * Mandate model attributes for FormGenerator (create/edit) — shared by Admin page and wizard. + */ +export function useMandateFormAttributes() { + const [attributes, setAttributes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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)[key])) { + attrs = (data as Record)[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, + }; +} diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 13ed028..58a989b 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -8,6 +8,8 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import api from '../api'; +const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']); + // Types export interface NotificationAction { actionId: string; @@ -51,6 +53,7 @@ export function useNotifications() { // Polling interval ref const pollingIntervalRef = useRef(null); + const prevUnreadCountRef = useRef(null); /** * Fetch all notifications for the current user @@ -90,6 +93,26 @@ export function useNotifications() { try { const response = await api.get('/api/notifications/unread-count'); const count = response.data.count; + const prev = prevUnreadCountRef.current; + prevUnreadCountRef.current = count; + + if (prev !== null && count > prev) { + try { + const listRes = await api.get('/api/notifications', { + params: { status: 'unread', limit: 25 }, + }); + const list = listRes.data as UserNotification[]; + if ( + Array.isArray(list) && + list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType)) + ) { + window.dispatchEvent(new Event('features-changed')); + } + } catch { + /* ignore */ + } + } + setUnreadCount(count); return count; } catch (err: any) { diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts index 91823dc..49f1a44 100644 --- a/src/hooks/useUsers.ts +++ b/src/hooks/useUsers.ts @@ -140,7 +140,7 @@ export function useCurrentUser() { } }; - const logout = async (msalInstance?: any) => { + const logout = async () => { if (!user) { throw new Error('No user to logout'); } @@ -160,8 +160,7 @@ export function useCurrentUser() { // Clear user state after successful logout setUser(null); - // CRITICAL: Clear all authentication data BEFORE any redirects - // This ensures cleanup happens even if MSAL redirect interrupts the process + // Clear client-side auth hints; gateway session ended via API (cookies cleared by backend). console.log('🧹 Starting comprehensive cleanup...'); // Clear user data cache from sessionStorage @@ -170,43 +169,15 @@ export function useCurrentUser() { // Clear auth authority from sessionStorage sessionStorage.removeItem('auth_authority'); - // Clear MSAL cache tokens from localStorage - // MSAL stores tokens with keys starting with 'msal.' - const keysToRemove = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && ( - key.startsWith('msal.') || - key === 'auth_token' || - key === 'refresh_token' || - key.includes('token') || - key.includes('auth') || - key.includes('msal') - )) { - keysToRemove.push(key); - } - } - keysToRemove.forEach(key => { - console.log('🗑️ Removing token:', key); - localStorage.removeItem(key); - }); - - // Clear ALL MSAL cache data (including account keys, token keys, version) - const msalKeysToRemove = []; - for (let i = 0; i < localStorage.length; i++) { + // Optional: clear MSAL browser cache only (PowerOn JWT lives in httpOnly cookies + backend). + // Do not call msal.logoutRedirect — that signs the user out of Microsoft globally. + for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key && key.startsWith('msal.')) { - msalKeysToRemove.push(key); + localStorage.removeItem(key); } } - msalKeysToRemove.forEach(key => { - console.log('🗑️ Removing MSAL cache:', key); - localStorage.removeItem(key); - }); - - // Clear sessionStorage as well (CSRF tokens, etc.) - sessionStorage.clear(); - + // Clear cookies as backup (in case backend doesn't clear them properly) // Note: This only works for cookies that are accessible to JavaScript console.log('🍪 Checking cookies for cleanup...'); @@ -228,24 +199,7 @@ export function useCurrentUser() { console.log('🍪 Cookies after cleanup attempt:', document.cookie); console.log('✅ Cleanup completed'); - - // Handle MSAL logout for Microsoft authentication - if (user.authenticationAuthority === 'msft' && msalInstance) { - try { - console.log('🔄 Starting MSAL logout redirect...'); - await msalInstance.logoutRedirect({ - onRedirectNavigate: () => { - console.log('🔄 MSAL redirect initiated - cleanup already completed'); - return true; - } - }); - return; // MSAL will handle the redirect - } catch (msalError) { - console.error('MSAL logout failed:', msalError); - // Continue with regular redirect if MSAL logout fails - } - } - + // Redirect to login or home page console.log('🔄 Redirecting to login page...'); window.location.href = '/login'; diff --git a/src/layouts/MainLayout.module.css b/src/layouts/MainLayout.module.css index 25069b0..15caf06 100644 --- a/src/layouts/MainLayout.module.css +++ b/src/layouts/MainLayout.module.css @@ -94,11 +94,24 @@ min-height: 0; position: relative; --mobile-topbar-height: 0px; + display: flex; + flex-direction: column; /* Let child components handle their own scrolling for sticky headers */ overflow: hidden; background: var(--bg-primary, #ffffff); } +/* Fills .content flex column so admin pages get a bounded height for inner scroll */ +.outletShell { + flex: 1; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + overflow-x: hidden; + overflow-y: auto; +} + .mobileTopBar { display: none; } diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 7244eb0..dd1cd70 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -107,7 +107,10 @@ const MainLayoutInner: React.FC = () => { -
+
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index f7f5e06..7ff80d0 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link, Navigate } from 'react-router-dom'; import useNavigation from '../hooks/useNavigation'; import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation'; import { getPageIcon } from '../config/pageRegistry'; @@ -47,19 +47,6 @@ const InstanceCard: React.FC = ({ instance, feature }) => { ); }; -// ============================================================================= -// EMPTY STATE -// ============================================================================= - -const EmptyState: React.FC = () => ( -
-
📋
-

Willkommen bei PowerOn

-

Du hast aktuell Zugriff auf keine Feature-Instanzen.

-

Kontaktiere einen Administrator, um Zugriff zu erhalten.

-
-); - // ============================================================================= // DASHBOARD PAGE // ============================================================================= @@ -89,7 +76,7 @@ export const DashboardPage: React.FC = () => { } if (totalInstances === 0) { - return ; + return ; } return ( diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx index febcce3..ac68e57 100644 --- a/src/pages/Store.tsx +++ b/src/pages/Store.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { FaCogs, FaHeadset } from 'react-icons/fa'; +import { FaCogs, FaComments, FaHeadset } from 'react-icons/fa'; import { useLanguage } from '../providers/language/LanguageContext'; import { useStore } from '../hooks/useStore'; import type { StoreFeature } from '../api/storeApi'; @@ -16,6 +16,8 @@ import styles from './Store.module.css'; const FEATURE_ICONS: Record = { automation: , teamsbot: , + workspace: , + commcoach: , }; const FEATURE_DESCRIPTIONS: Record> = { @@ -29,6 +31,16 @@ const FEATURE_DESCRIPTIONS: Record> = { en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.', fr: 'Integrez un bot IA dans vos reunions et canaux Microsoft Teams.', }, + workspace: { + de: 'Nutze den gemeinsamen AI Workspace: Chats, Tools und Kontext pro Instanz.', + en: 'Use the shared AI workspace: chats, tools, and context per instance.', + fr: 'Utilisez l\'espace de travail IA partage: chats, outils et contexte par instance.', + }, + commcoach: { + de: 'CommCoach: Kommunikation trainieren mit KI-gestütztem Coaching und Feedback.', + en: 'CommCoach: practice communication with AI-assisted coaching and feedback.', + fr: 'CommCoach: entrainer la communication avec un coaching assiste par IA.', + }, }; function _getLabel(labels: Record, lang: string): string { diff --git a/src/pages/admin/AccessManagementHub.tsx b/src/pages/admin/AccessManagementHub.tsx index 13c795e..21554f1 100644 --- a/src/pages/admin/AccessManagementHub.tsx +++ b/src/pages/admin/AccessManagementHub.tsx @@ -76,7 +76,12 @@ export const AccessManagementHub: React.FC = () => { useEffect(() => { fetchFeatures(); - fetchMandates().then(setMandates); + fetchMandates().then(data => { + setMandates(data); + if (data.length > 0 && !selectedMandateId) { + setSelectedMandateId(data[0].id); + } + }); }, [fetchFeatures, fetchMandates]); useEffect(() => { diff --git a/src/pages/admin/Admin.module.css b/src/pages/admin/Admin.module.css index 4fcc8df..20e14f6 100644 --- a/src/pages/admin/Admin.module.css +++ b/src/pages/admin/Admin.module.css @@ -6,11 +6,22 @@ .adminPage { padding: 1.5rem; - /* Fill parent height and enable flex layout for sticky table headers */ - height: 100%; - max-height: 100%; + /* Default: grow with content → scroll on MainLayout .outletShell (expandable panels, long pages). */ + flex: 0 0 auto; + width: 100%; + box-sizing: border-box; display: flex; flex-direction: column; +} + +/* + * FormGeneratorTable expects a bounded height chain (height:100% / flex:1). + * With default .adminPage (flex:0 0 auto), .tableContainer flex:1 collapses → empty table. + * Use together: className={`${styles.adminPage} ${styles.adminPageFill}`} + */ +.adminPage.adminPageFill { + flex: 1 1 auto; + min-height: 0; overflow: hidden; } @@ -20,6 +31,7 @@ align-items: center; margin-bottom: 1.5rem; flex-shrink: 0; + min-height: 0; } .pageTitle { diff --git a/src/pages/admin/AdminAutomationEventsPage.tsx b/src/pages/admin/AdminAutomationEventsPage.tsx index aef57ce..dee1558 100644 --- a/src/pages/admin/AdminAutomationEventsPage.tsx +++ b/src/pages/admin/AdminAutomationEventsPage.tsx @@ -153,7 +153,7 @@ export const AdminAutomationEventsPage: React.FC = () => { ], []); return ( -
+

Automation Events

diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx index 40727b6..7b8693f 100644 --- a/src/pages/admin/AdminFeatureAccessPage.tsx +++ b/src/pages/admin/AdminFeatureAccessPage.tsx @@ -62,7 +62,12 @@ export const AdminFeatureAccessPage: React.FC = () => { // Load features, mandates, and attributes on mount useEffect(() => { fetchFeatures(); - fetchMandates().then(setMandates); + fetchMandates().then(data => { + setMandates(data); + if (data.length > 0 && !selectedMandateId) { + setSelectedMandateId(data[0].id); + } + }); // Fetch FeatureInstance attributes from backend api.get('/api/attributes/FeatureInstance').then(response => { const attrs = response.data?.attributes || response.data || []; @@ -327,7 +332,7 @@ export const AdminFeatureAccessPage: React.FC = () => { if (error && !selectedMandateId) { return ( -
+
⚠️

Fehler: {error}

@@ -340,7 +345,7 @@ export const AdminFeatureAccessPage: React.FC = () => { } return ( -
+

Feature-Instanzen

diff --git a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx index 29b335a..0a1e8a9 100644 --- a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx +++ b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx @@ -108,6 +108,9 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { }); setCombinedOptions(allOptions); + if (allOptions.length > 0 && !selectedCombinedKey) { + setSelectedCombinedKey(allOptions[0].combinedKey); + } }; loadCombinedOptions(); @@ -387,7 +390,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { if (error && !selectedCombinedKey) { return ( -
+
⚠️

Fehler: {error}

@@ -400,7 +403,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { } return ( -
+

Feature Instanz Benutzer

diff --git a/src/pages/admin/AdminFeatureRolesPage.tsx b/src/pages/admin/AdminFeatureRolesPage.tsx index cdd0448..da1697d 100644 --- a/src/pages/admin/AdminFeatureRolesPage.tsx +++ b/src/pages/admin/AdminFeatureRolesPage.tsx @@ -245,7 +245,7 @@ export const AdminFeatureRolesPage: React.FC = () => { if (error && !selectedFeatureCode) { return ( -
+
⚠️

{error}

@@ -258,7 +258,7 @@ export const AdminFeatureRolesPage: React.FC = () => { } return ( -
+

Feature Rollen & Rechte

diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx index abe2d29..6105266 100644 --- a/src/pages/admin/AdminInvitationsPage.tsx +++ b/src/pages/admin/AdminInvitationsPage.tsx @@ -242,7 +242,7 @@ export const AdminInvitationsPage: React.FC = () => { if (error && !selectedMandateId) { return ( -
+
⚠️

Fehler: {error}

@@ -255,7 +255,7 @@ export const AdminInvitationsPage: React.FC = () => { } return ( -
+

Einladungen

diff --git a/src/pages/admin/AdminMandateRolesPage.tsx b/src/pages/admin/AdminMandateRolesPage.tsx index 19ddff4..afeb110 100644 --- a/src/pages/admin/AdminMandateRolesPage.tsx +++ b/src/pages/admin/AdminMandateRolesPage.tsx @@ -294,7 +294,7 @@ export const AdminMandateRolesPage: React.FC = () => { if (error && !selectedMandateId) { return ( -
+
⚠️

Fehler: {error}

@@ -307,7 +307,7 @@ export const AdminMandateRolesPage: React.FC = () => { } return ( -
+

Rollen

diff --git a/src/pages/admin/AdminMandatesPage.tsx b/src/pages/admin/AdminMandatesPage.tsx index 3aac2d4..f17c49f 100644 --- a/src/pages/admin/AdminMandatesPage.tsx +++ b/src/pages/admin/AdminMandatesPage.tsx @@ -4,19 +4,27 @@ * Admin page for managing Mandates (tenants) using FormGeneratorTable. */ -import React, { useState, useMemo } from 'react'; +import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAdminMandates, type Mandate } from '../../hooks/useMandates'; +import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchSettingsAdmin, updateSettingsAdmin } from '../../api/billingApi'; +import { + mergeBillingIntoMandateFormData, + splitMandateAndBillingFromForm, +} from '../../utils/mandateBillingFormMerge'; +import { useToast } from '../../contexts/ToastContext'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; -import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; +import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa'; import styles from './Admin.module.css'; export const AdminMandatesPage: React.FC = () => { const navigate = useNavigate(); + const { request } = useApiRequest(); + const { showWarning, showSuccess } = useToast(); const { mandates, - attributes, columns, permissions, pagination, @@ -31,53 +39,76 @@ export const AdminMandatesPage: React.FC = () => { updateOptimistically, } = useAdminMandates(); - // Form attributes from backend - filter for create/edit forms - const formAttributes: AttributeDefinition[] = useMemo(() => { - const excludedFields = ['id']; - return attributes - .filter(attr => !excludedFields.includes(attr.name)) - .map(attr => ({ - ...attr, - type: attr.type, - })) as AttributeDefinition[]; - }, [attributes]); - - // Create form attributes - exclude isSystem (only set by system, not user) - const createFormAttributes: AttributeDefinition[] = useMemo(() => { - return formAttributes.filter(attr => attr.name !== 'isSystem'); - }, [formAttributes]); + const { + formAttributes, + createFormAttributes, + formAttributesWithBilling, + createFormAttributesWithBilling, + loading: mandateAttrsLoading, + } = useMandateFormAttributes(); const [showCreateModal, setShowCreateModal] = useState(false); - const [editingMandate, setEditingMandate] = useState(null); + /** Mandate row merged with billing fields for FormGenerator */ + const [editingFormData, setEditingFormData] = useState | null>(null); + const [editingBillingWarning, setEditingBillingWarning] = useState(null); // Check if user can create const canCreate = permissions?.create !== 'n'; const canUpdate = permissions?.update !== 'n'; const canDelete = permissions?.delete !== 'n'; - // Handle edit click + // Handle edit click — load mandate + billing settings (separate persistence) const handleEditClick = async (mandate: Mandate) => { + setEditingBillingWarning(null); const fullMandate = await fetchMandateById(mandate.id); - if (fullMandate) { - setEditingMandate(fullMandate); + if (!fullMandate) return; + try { + const settings = await fetchSettingsAdmin(request, fullMandate.id); + setEditingFormData( + mergeBillingIntoMandateFormData(fullMandate as Record, settings) + ); + } catch { + setEditingFormData(mergeBillingIntoMandateFormData(fullMandate as Record, null)); + setEditingBillingWarning( + 'Abrechnungseinstellungen konnten nicht geladen werden. Nur Mandantendaten sind sicher bearbeitbar.' + ); } }; - // Handle create submit - const handleCreateSubmit = async (data: Partial) => { - const success = await handleCreate(data); - if (success) { - setShowCreateModal(false); + // Handle create submit — POST mandate, then billing settings + const handleCreateSubmit = async (data: Record) => { + const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data); + const created = await handleCreate(mandatePayload as Partial); + if (!created?.id) return; + try { + await updateSettingsAdmin(request, created.id, billingUpdate); + showSuccess('Erstellt', 'Mandant inkl. Abrechnungseinstellungen gespeichert.'); + } catch (e: unknown) { + console.error(e); + showWarning( + 'Mandant erstellt', + 'Abrechnungseinstellungen konnten nicht gespeichert werden. Bitte unter Administration → Abrechnung nachpflegen.' + ); } + setShowCreateModal(false); }; - // Handle edit submit - const handleEditSubmit = async (data: Partial) => { - if (!editingMandate) return; - const success = await handleUpdate(editingMandate.id, data); - if (success) { - setEditingMandate(null); + // Handle edit submit — PUT mandate + POST billing settings + const handleEditSubmit = async (data: Record) => { + if (!editingFormData?.id) return; + const mandateId = String(editingFormData.id); + const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data); + const mandateOk = await handleUpdate(mandateId, mandatePayload as Partial); + if (!mandateOk) return; + try { + await updateSettingsAdmin(request, mandateId, billingUpdate); + showSuccess('Gespeichert', 'Mandant und Abrechnung aktualisiert.'); + } catch (e: unknown) { + console.error(e); + showWarning('Teilweise gespeichert', 'Mandant gespeichert, Abrechnung konnte nicht aktualisiert werden.'); } + setEditingFormData(null); + setEditingBillingWarning(null); }; // Handle delete (confirmation handled by DeleteActionButton) @@ -91,7 +122,7 @@ export const AdminMandatesPage: React.FC = () => { if (error) { return ( -
+
⚠️

Fehler beim Laden der Mandanten: {error}

@@ -104,7 +135,7 @@ export const AdminMandatesPage: React.FC = () => { } return ( -
+

Mandanten

@@ -212,14 +243,18 @@ export const AdminMandatesPage: React.FC = () => {
- {createFormAttributes.length === 0 ? ( +

+ Stammdaten kommen aus dem Modell Mandate (API). Abrechnung wird in{' '} + BillingSettings pro Mandant gespeichert. +

+ {mandateAttrsLoading || createFormAttributes.length === 0 ? (
Lade Formular...
) : ( setShowCreateModal(false)} @@ -233,20 +268,29 @@ export const AdminMandatesPage: React.FC = () => { )} {/* Edit Modal */} - {editingMandate && ( -
setEditingMandate(null)}> + {editingFormData && ( +
{ + setEditingFormData(null); + setEditingBillingWarning(null); + }} + >
e.stopPropagation()}>

Mandant bearbeiten

-
- {editingMandate.isSystem && ( + {Boolean(editingFormData.isSystem) && (
@@ -254,6 +298,14 @@ export const AdminMandatesPage: React.FC = () => {
)} + {editingBillingWarning && ( +
+ {editingBillingWarning} +
+ )} {formAttributes.length === 0 ? (
@@ -261,11 +313,14 @@ export const AdminMandatesPage: React.FC = () => {
) : ( setEditingMandate(null)} + onCancel={() => { + setEditingFormData(null); + setEditingBillingWarning(null); + }} submitButtonText="Speichern" cancelButtonText="Abbrechen" /> diff --git a/src/pages/admin/AdminUserAccessOverviewPage.tsx b/src/pages/admin/AdminUserAccessOverviewPage.tsx index 284806c..72adbc6 100644 --- a/src/pages/admin/AdminUserAccessOverviewPage.tsx +++ b/src/pages/admin/AdminUserAccessOverviewPage.tsx @@ -515,7 +515,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => { if (error && !overview) { return ( -
+
⚠️

Fehler: {error}

@@ -531,7 +531,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => { } return ( -
+

Benutzer-Zugriffsübersicht

diff --git a/src/pages/admin/AdminUserMandatesPage.tsx b/src/pages/admin/AdminUserMandatesPage.tsx index 72cd2fd..b6886ce 100644 --- a/src/pages/admin/AdminUserMandatesPage.tsx +++ b/src/pages/admin/AdminUserMandatesPage.tsx @@ -257,7 +257,7 @@ export const AdminUserMandatesPage: React.FC = () => { if (error && !selectedMandateId) { return ( -
+
⚠️

Fehler: {error}

@@ -270,7 +270,7 @@ export const AdminUserMandatesPage: React.FC = () => { } return ( -
+

Mandanten-Mitglieder

diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx index 8dec594..0e8ceca 100644 --- a/src/pages/admin/AdminUsersPage.tsx +++ b/src/pages/admin/AdminUsersPage.tsx @@ -125,7 +125,7 @@ export const AdminUsersPage: React.FC = () => { if (error) { return ( -
+
⚠️

Fehler beim Laden der Benutzer: {error}

@@ -138,7 +138,7 @@ export const AdminUsersPage: React.FC = () => { } return ( -
+

Benutzer

diff --git a/src/pages/admin/wizards/AdminMandateWizardPage.tsx b/src/pages/admin/wizards/AdminMandateWizardPage.tsx index 8e66add..43814f8 100644 --- a/src/pages/admin/wizards/AdminMandateWizardPage.tsx +++ b/src/pages/admin/wizards/AdminMandateWizardPage.tsx @@ -13,7 +13,12 @@ import { type Feature, } from '../../../hooks/useFeatureAccess'; import { useToast } from '../../../contexts/ToastContext'; -import api from '../../../api'; +import { useApiRequest } from '../../../hooks/useApi'; +import { useMandateFormAttributes } from '../../../hooks/useMandates'; +import { createMandate } from '../../../api/mandateApi'; +import { updateSettingsAdmin } from '../../../api/billingApi'; +import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFormMerge'; +import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm'; import styles from '../Admin.module.css'; const TOTAL_STEPS = 4; @@ -25,12 +30,19 @@ interface RoleOption { } export const AdminMandateWizardPage: React.FC = () => { - const { showSuccess } = useToast(); + const { showSuccess, showWarning, showError } = useToast(); + const { request } = useApiRequest(); + const { + createFormAttributes, + createFormAttributesWithBilling, + loading: mandateAttrLoading, + } = useMandateFormAttributes(); const { fetchMandateUsers, addUserToMandate, removeUserFromMandate, + updateUserRoles, fetchMandates: fetchMandatesList, fetchRoles: fetchMandateRolesList, fetchAllUsers, @@ -44,6 +56,7 @@ export const AdminMandateWizardPage: React.FC = () => { fetchInstanceUsers, addUserToInstance, removeUserFromInstance, + updateInstanceUserRoles, fetchInstanceRoles: fetchInstanceRolesList, } = useFeatureAccess(); @@ -56,14 +69,13 @@ export const AdminMandateWizardPage: React.FC = () => { const [mandates, setMandates] = useState([]); const [selectedMandate, setSelectedMandate] = useState | null>(null); const [isCreatingMandate, setIsCreatingMandate] = useState(false); - const [mandateForm, setMandateForm] = useState({ name: '' }); // Step 2: Mandate Users const [mandateUsers, setMandateUsers] = useState([]); const [allSystemUsers, setAllSystemUsers] = useState>([]); const [mandateRoles, setMandateRoles] = useState([]); const [isAddingMandateUser, setIsAddingMandateUser] = useState(false); - const [addMandateUserForm, setAddMandateUserForm] = useState({ userId: '', roleIds: [] as string[] }); + const [addMandateUserForm, setAddMandateUserForm] = useState({ userIds: [] as string[], roleIds: [] as string[] }); // Step 3: Instances const [features, setFeatures] = useState([]); @@ -77,7 +89,12 @@ export const AdminMandateWizardPage: React.FC = () => { const [instanceUsers, setInstanceUsers] = useState([]); const [instanceRoles, setInstanceRoles] = useState([]); 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([]); // ───────────────────────────────────────────────────────────────────────── // HELPERS @@ -126,6 +143,11 @@ export const AdminMandateWizardPage: React.FC = () => { fetchFeatures().then(setFeatures); }, [fetchFeatures]); + useEffect(() => { + setRoleEditContext(null); + setRoleEditDraft([]); + }, [step]); + // Step 2 const loadMandateUsers = useCallback(async () => { if (!selectedMandate) return; @@ -188,42 +210,69 @@ export const AdminMandateWizardPage: React.FC = () => { // HANDLERS // ───────────────────────────────────────────────────────────────────────── - const handleCreateMandate = async () => { - if (!mandateForm.name.trim()) { setError('Name ist erforderlich'); return; } + const handleCreateMandate = async (data: Record) => { setIsLoading(true); setError(null); try { - const response = await api.post('/api/mandates/', { - name: mandateForm.name, - enabled: true, - }); - setSelectedMandate(response.data); + const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data); + const body = { + ...mandatePayload, + enabled: mandatePayload.enabled !== undefined ? mandatePayload.enabled : true, + }; + const created = await createMandate(request, body); + let billingSaved = false; + try { + await updateSettingsAdmin(request, String(created.id), billingUpdate); + billingSaved = true; + } catch (billingErr: unknown) { + console.error(billingErr); + showWarning( + 'Mandant erstellt', + 'Abrechnungseinstellungen konnten nicht gespeichert werden. Bitte unter Administration → Abrechnung nachpflegen.', + ); + } + setSelectedMandate(created as Record); setIsCreatingMandate(false); - showSuccess('Erstellt', 'Mandant erstellt'); + if (billingSaved) { + showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert'); + } await loadMandates(); - } catch (err: any) { - setError(err?.response?.data?.detail || err?.message || 'Fehler beim Erstellen'); + } catch (err: unknown) { + const e = err as { response?: { data?: { detail?: string } }; message?: string }; + setError(e?.response?.data?.detail || e?.message || 'Fehler beim Erstellen'); } finally { setIsLoading(false); } }; const handleAddMandateUser = async () => { - if (!selectedMandate || !addMandateUserForm.userId) return; + if (!selectedMandate || addMandateUserForm.userIds.length === 0) return; setIsLoading(true); setError(null); + const failures: string[] = []; + let ok = 0; try { - const result = await addUserToMandate(selectedMandate.id, { - targetUserId: addMandateUserForm.userId, - roleIds: addMandateUserForm.roleIds, - }); - if (result.success) { + for (const uid of addMandateUserForm.userIds) { + const result = await addUserToMandate(selectedMandate.id, { + targetUserId: uid, + roleIds: addMandateUserForm.roleIds, + }); + if (result.success) ok += 1; + else failures.push(`${uid}: ${result.error || 'Fehler'}`); + } + if (ok > 0) { setIsAddingMandateUser(false); - setAddMandateUserForm({ userId: '', roleIds: [] }); - showSuccess('Hinzugefügt', 'Benutzer zum Mandanten hinzugefügt'); + setAddMandateUserForm({ userIds: [], roleIds: [] }); + showSuccess('Hinzugefügt', `${ok} Benutzer zum Mandanten hinzugefügt`); await loadMandateUsers(); - } else { - setError(result.error || 'Fehler beim Hinzufügen'); + } + if (failures.length > 0) { + showWarning( + 'Teilweise fehlgeschlagen', + failures.slice(0, 5).join('; ') + (failures.length > 5 ? '…' : ''), + ); + if (ok === 0) setError(failures.join('; ')); + else await loadMandateUsers(); } } finally { setIsLoading(false); @@ -276,27 +325,76 @@ export const AdminMandateWizardPage: React.FC = () => { }; const handleAddInstanceUser = async () => { - if (!selectedInstance || !selectedMandate || !addInstanceUserForm.userId) return; + if (!selectedInstance || !selectedMandate || addInstanceUserForm.userIds.length === 0) return; setIsLoading(true); setError(null); + const failures: string[] = []; + let ok = 0; try { - const result = await addUserToInstance(selectedMandate.id, selectedInstance.id, { - userId: addInstanceUserForm.userId, - roleIds: addInstanceUserForm.roleIds, - }); - if (result.success) { + for (const uid of addInstanceUserForm.userIds) { + const result = await addUserToInstance(selectedMandate.id, selectedInstance.id, { + userId: uid, + roleIds: addInstanceUserForm.roleIds, + }); + if (result.success) ok += 1; + else failures.push(`${uid}: ${result.error || 'Fehler'}`); + } + if (ok > 0) { setIsAddingInstanceUser(false); - setAddInstanceUserForm({ userId: '', roleIds: [] }); - showSuccess('Hinzugefügt', 'Benutzer zur Feature-Instanz hinzugefügt'); + setAddInstanceUserForm({ userIds: [], roleIds: [] }); + showSuccess('Hinzugefügt', `${ok} Benutzer zur Feature-Instanz hinzugefügt`); await loadInstanceUsers(); - } else { - setError(result.error || 'Fehler beim Hinzufügen'); + } + if (failures.length > 0) { + showWarning( + 'Teilweise fehlgeschlagen', + failures.slice(0, 5).join('; ') + (failures.length > 5 ? '…' : ''), + ); + if (ok === 0) setError(failures.join('; ')); + else await loadInstanceUsers(); } } finally { setIsLoading(false); } }; + const _saveMandateRoleEdit = async () => { + if (!selectedMandate || roleEditContext?.scope !== 'mandate') return; + setIsLoading(true); + setError(null); + const r = await updateUserRoles(selectedMandate.id, roleEditContext.userId, roleEditDraft); + if (r.success) { + showSuccess('Gespeichert', 'Rollen aktualisiert'); + setRoleEditContext(null); + setRoleEditDraft([]); + await loadMandateUsers(); + } else { + showError('Fehler', r.error || 'Rollen konnten nicht gespeichert werden'); + } + setIsLoading(false); + }; + + const _saveInstanceRoleEdit = async () => { + if (!selectedMandate || !selectedInstance || roleEditContext?.scope !== 'instance') return; + setIsLoading(true); + setError(null); + const r = await updateInstanceUserRoles( + selectedMandate.id, + selectedInstance.id, + roleEditContext.userId, + { roleIds: roleEditDraft }, + ); + if (r.success) { + showSuccess('Gespeichert', 'Rollen aktualisiert'); + setRoleEditContext(null); + setRoleEditDraft([]); + await loadInstanceUsers(); + } else { + showError('Fehler', r.error || 'Rollen konnten nicht gespeichert werden'); + } + setIsLoading(false); + }; + const handleRemoveInstanceUser = async (userId: string) => { if (!selectedInstance || !selectedMandate) return; const result = await removeUserFromInstance(selectedMandate.id, selectedInstance.id, userId); @@ -324,9 +422,34 @@ export const AdminMandateWizardPage: React.FC = () => { // SHARED UI // ───────────────────────────────────────────────────────────────────────── + type WizardUserRow = { + userId?: string; + id?: string; + username: string; + email?: string | null; + fullName?: string; + firstname?: string | null; + lastname?: string | null; + enabled?: boolean; + roleIds?: string[]; + roleLabels?: string[]; + }; + + const _roleTextForRow = (u: WizardUserRow, roleLookup: RoleOption[]) => { + if (u.roleLabels && u.roleLabels.length > 0) return u.roleLabels.join(', '); + if (u.roleIds && u.roleIds.length > 0) { + return u.roleIds + .map(rid => roleLookup.find(r => r.id === rid)?.roleLabel || rid) + .join(', '); + } + return '-'; + }; + const renderUserTable = ( - users: Array<{ userId?: string; id?: string; username: string; email?: string | null; fullName?: string; firstname?: string | null; lastname?: string | null; enabled?: boolean; roleLabels?: string[] }>, + users: WizardUserRow[], + roleLookup: RoleOption[], onRemove: (userId: string) => void, + options?: { onEditRoles?: (userId: string, currentRoleIds: string[]) => void }, ) => ( users.length > 0 ? ( @@ -342,12 +465,30 @@ export const AdminMandateWizardPage: React.FC = () => { {users.map(u => { const uid = u.userId || u.id || ''; + const ids = u.roleIds || []; return (
{getUserDisplayName(u as any)} {u.email || '-'} - {u.roleLabels?.join(', ') || '-'} + {_roleTextForRow(u, roleLookup)} + {options?.onEditRoles && roleLookup.length > 0 && ( + + )} { const renderAddUserForm = ( availableUsers: Array<{ id?: string; userId?: string; username: string; email?: string | null; fullName?: string; firstname?: string | null; lastname?: string | null }>, roles: RoleOption[], - formValue: { userId: string; roleIds: string[] }, - setFormValue: (fn: (prev: { userId: string; roleIds: string[] }) => { userId: string; roleIds: string[] }) => void, + formValue: { userIds: string[]; roleIds: string[] }, + setFormValue: (fn: (prev: { userIds: string[]; roleIds: string[] }) => { userIds: string[]; roleIds: string[] }) => void, onSubmit: () => void, onCancel: () => void, ) => (
- - { + setFormValue(p => ({ + ...p, + userIds: e.target.checked + ? [...p.userIds, uid] + : p.userIds.filter(id => id !== uid), + })); + }} + /> + + {u.username} {u.email ? ({u.email}) : null} + {name !== u.username ? — {name} : null} + + ); })} - +
{roles.length > 0 && (
- +
{roles.map(r => (
)}
- - -
+ )}
)} @@ -606,11 +771,64 @@ export const AdminMandateWizardPage: React.FC = () => { addMandateUserForm, setAddMandateUserForm, handleAddMandateUser, - () => { setIsAddingMandateUser(false); setAddMandateUserForm({ userId: '', roleIds: [] }); }, + () => { setIsAddingMandateUser(false); setAddMandateUserForm({ userIds: [], roleIds: [] }); }, + )} + + {roleEditContext?.scope === 'mandate' && ( +
+
Rollen bearbeiten
+
+ {mandateRoles.map(r => ( + + ))} +
+
+ + +
+
)}
- {renderUserTable(mandateUsers as any[], handleRemoveMandateUser)} + {renderUserTable(mandateUsers as WizardUserRow[], mandateRoles, handleRemoveMandateUser, { + onEditRoles: (userId, ids) => { + setError(null); + setRoleEditContext({ scope: 'mandate', userId }); + setRoleEditDraft([...ids]); + }, + })}
@@ -770,13 +988,68 @@ export const AdminMandateWizardPage: React.FC = () => { addInstanceUserForm, setAddInstanceUserForm, handleAddInstanceUser, - () => { setIsAddingInstanceUser(false); setAddInstanceUserForm({ userId: '', roleIds: [] }); }, + () => { setIsAddingInstanceUser(false); setAddInstanceUserForm({ userIds: [], roleIds: [] }); }, + )} + + {roleEditContext?.scope === 'instance' && ( +
+
Rollen bearbeiten (Feature-Instanz)
+
+ {instanceRoles.map(r => ( + + ))} +
+
+ + +
+
)}
{renderUserTable( - instanceUsers.map(u => ({ ...u, userId: u.userId || u.id })), + instanceUsers.map(u => ({ ...u, userId: u.userId || u.id })) as WizardUserRow[], + instanceRoles, handleRemoveInstanceUser, + { + onEditRoles: (userId, ids) => { + setError(null); + setRoleEditContext({ scope: 'instance', userId }); + setRoleEditDraft([...ids]); + }, + }, )}
diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index d25bfba..ce2b743 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -8,8 +8,13 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling'; -import { useAdminMandates } from '../../hooks/useMandates'; +import type { CheckoutCreateRequest } from '../../api/billingApi'; +import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates'; +import { useCurrentUser } from '../../hooks/useUsers'; +import api from '../../api'; +import { getUserDataCache } from '../../utils/userCache'; import styles from './Billing.module.css'; const _formatCurrency = (amount: number) => { @@ -19,37 +24,49 @@ const _formatCurrency = (amount: number) => { }).format(amount); }; +const _mandateDisplayLabel = (m: UserMandateRow): string => { + if (m.label) return m.label; + if (typeof m.name === 'object' && m.name) { + const n = m.name as Record; + return n.de || n.en || Object.values(n)[0] || m.id; + } + return (m.name as string) || m.id; +}; + // ============================================================================ // MANDATE SELECTOR // ============================================================================ interface MandateSelectorProps { + mandates: UserMandateRow[]; + loading: boolean; selectedMandateId: string | null; onSelect: (mandateId: string) => void; } -const MandateSelector: React.FC = ({ selectedMandateId, onSelect }) => { - const { mandates, loading } = useAdminMandates(); - - return ( -
- - -
- ); -}; +const MandateSelector: React.FC = ({ + mandates, + loading, + selectedMandateId, + onSelect, +}) => ( +
+ + +
+); // ============================================================================ // SETTINGS EDITOR @@ -63,10 +80,9 @@ interface SettingsEditorProps { const SettingsEditor: React.FC = ({ settings, onSave, loading }) => { const [formData, setFormData] = useState({ - billingModel: settings?.billingModel || 'UNLIMITED', - defaultUserCredit: settings?.defaultUserCredit || 10, - warningThresholdPercent: settings?.warningThresholdPercent || 10, - blockOnZeroBalance: settings?.blockOnZeroBalance ?? true, + billingModel: (settings?.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE') as BillingSettings['billingModel'], + defaultUserCredit: Number(settings?.defaultUserCredit ?? 0), + warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10), notifyOnWarning: settings?.notifyOnWarning ?? true, }); const [saving, setSaving] = useState(false); @@ -75,11 +91,10 @@ const SettingsEditor: React.FC = ({ settings, onSave, loadi useEffect(() => { if (settings) { setFormData({ - billingModel: settings.billingModel, - defaultUserCredit: settings.defaultUserCredit, - warningThresholdPercent: settings.warningThresholdPercent, - blockOnZeroBalance: settings.blockOnZeroBalance, - notifyOnWarning: settings.notifyOnWarning, + billingModel: settings.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE', + defaultUserCredit: Number(settings.defaultUserCredit ?? 0), + warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10), + notifyOnWarning: settings.notifyOnWarning ?? true, }); } }, [settings]); @@ -116,12 +131,10 @@ const SettingsEditor: React.FC = ({ settings, onSave, loadi
@@ -151,18 +164,6 @@ const SettingsEditor: React.FC = ({ settings, onSave, loadi step="1" /> - -
- - -
@@ -363,7 +364,6 @@ const AccountsOverview: React.FC = ({ accounts, users, lo
{account.userId && User: {_userNameMap.get(account.userId) || account.userId}} Guthaben: {formatCurrency(account.balance)} - {account.creditLimit && Limit: {formatCurrency(account.creditLimit)}} Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}
@@ -373,13 +373,113 @@ const AccountsOverview: React.FC = ({ 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 = ({ mandateId, createCheckout }) => { + const [amount, setAmount] = useState(''); + const [busy, setBusy] = useState(false); + const [localMsg, setLocalMsg] = useState(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 ( +
+

Guthaben via Stripe aufladen

+

+ Sie werden zu Stripe weitergeleitet. Nach erfolgreicher Zahlung kehren Sie hierher zurück. +

+ {localMsg &&
{localMsg}
} +
+
+
+ + setAmount(e.target.value)} + placeholder="z.B. 50" + min="0.01" + step="0.01" + required + /> +
+
+ +
+
+ ); +}; + // ============================================================================ // MAIN COMPONENT // ============================================================================ export const BillingAdmin: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const { user: currentUser } = useCurrentUser(); + const isSysAdmin = currentUser?.isSysAdmin === true; + const [selectedMandateId, setSelectedMandateId] = useState(null); - const { settings, accounts, users, loading, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); + const [mandateList, setMandateList] = useState([]); + const [mandatesLoading, setMandatesLoading] = useState(true); + + const { fetchMandates } = useUserMandates(); + const { + settings, + accounts, + users, + loading, + saveSettings, + addCredit, + loadAccounts, + createCheckout, + } = useBillingAdmin(selectedMandateId || undefined); const handleMandateSelect = (mandateId: string) => { setSelectedMandateId(mandateId || null); @@ -398,47 +498,172 @@ export const BillingAdmin: React.FC = () => { return result; }, [selectedMandateId, addCredit, loadAccounts]); + useEffect(() => { + let cancelled = false; + (async () => { + setMandatesLoading(true); + try { + const data = await fetchMandates(); + if (!cancelled) { + setMandateList(Array.isArray(data) ? data : []); + } + } finally { + if (!cancelled) setMandatesLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [fetchMandates]); + + useEffect(() => { + if (!isSysAdmin && mandateList.length === 1 && selectedMandateId === null) { + setSelectedMandateId(mandateList[0].id); + } + }, [isSysAdmin, mandateList, selectedMandateId]); + + const [stripeReturnMessage, setStripeReturnMessage] = useState<{ + type: 'success' | 'error'; + text: string; + } | null>(null); + const successParam = searchParams.get('success'); + const canceledParam = searchParams.get('canceled'); + const sessionIdParam = searchParams.get('session_id'); + + useEffect(() => { + let cancelled = false; + + const _confirmCheckoutIfNeeded = async () => { + if (successParam !== 'true') { + if (canceledParam === 'true' && !cancelled) { + setStripeReturnMessage({ type: 'error', text: 'Zahlung abgebrochen.' }); + } + return; + } + + if (!sessionIdParam) { + if (!cancelled) { + setStripeReturnMessage({ + type: 'success', + text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.', + }); + } + if (selectedMandateId) await loadAccounts(selectedMandateId); + return; + } + + try { + await api.post('/api/billing/checkout/confirm', { sessionId: sessionIdParam }); + if (!cancelled) { + setStripeReturnMessage({ + type: 'success', + text: 'Zahlung erfolgreich. Guthaben wurde verbucht.', + }); + } + } catch (err: unknown) { + const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail; + if (!cancelled) { + setStripeReturnMessage({ + type: 'error', + text: + detail || + 'Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.', + }); + } + } finally { + if (selectedMandateId) await loadAccounts(selectedMandateId); + } + }; + + _confirmCheckoutIfNeeded(); + + return () => { + cancelled = true; + }; + }, [successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]); + + const _clearStripeParams = useCallback(() => { + searchParams.delete('success'); + searchParams.delete('canceled'); + searchParams.delete('session_id'); + setSearchParams(searchParams, { replace: true }); + setStripeReturnMessage(null); + }, [searchParams, setSearchParams]); + + const showStripeForMandateAdmin = !isSysAdmin && !!selectedMandateId && !!settings; + return (

Billing Administration

-

Verwaltung von Abrechnungseinstellungen und Guthaben

+

+ {isSysAdmin + ? 'Verwaltung von Abrechnungseinstellungen und Guthaben' + : 'Guthaben und Konten für Ihre Mandanten'} +

+ {isSysAdmin && ( +

+ + Mandanten-Übersicht (Balances & Transaktionen) + +

+ )}
- + + {stripeReturnMessage && ( +
+
+ {stripeReturnMessage.text} + +
+
+ )} +
-
- + {selectedMandateId && ( <> - - - - - + {isSysAdmin && ( + + )} + + {isSysAdmin && ( + + )} + + {showStripeForMandateAdmin && ( + + )} + + )} - + {!selectedMandateId && ( -
- Bitte wählen Sie einen Mandanten aus. -
+
Bitte wählen Sie einen Mandanten aus.
)}
); diff --git a/src/pages/billing/BillingDashboard.tsx b/src/pages/billing/BillingDashboard.tsx index bc586fd..b61b536 100644 --- a/src/pages/billing/BillingDashboard.tsx +++ b/src/pages/billing/BillingDashboard.tsx @@ -27,13 +27,8 @@ const BalanceCard: React.FC = ({ balance, onClick }) => { }; const getBillingModelLabel = (model: string) => { - switch (model) { - case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; - case 'PREPAY_USER': return 'Prepaid (Benutzer)'; - case 'CREDIT_POSTPAY': return 'Kredit'; - case 'UNLIMITED': return 'Unlimited'; - default: return model; - } + if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)'; + return 'Prepaid (Mandant)'; }; return ( diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index 083fa0d..0a1dfef 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -61,18 +61,12 @@ const BalanceCard: React.FC = ({ balance, onCheckout, checkout const [showCheckout, setShowCheckout] = useState(false); const _getBillingModelLabel = (model: string) => { - switch (model) { - case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; - case 'PREPAY_USER': return 'Prepaid (Benutzer)'; - case 'CREDIT_POSTPAY': return 'Kredit'; - case 'UNLIMITED': return 'Unlimited'; - default: return model; - } + if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)'; + return 'Prepaid (Mandant)'; }; const canTopUp = balance.billingModel === 'PREPAY_USER' - || balance.billingModel === 'PREPAY_MANDATE' - || balance.billingModel === 'CREDIT_POSTPAY'; + || balance.billingModel === 'PREPAY_MANDATE'; return (
diff --git a/src/pages/billing/BillingMandateView.tsx b/src/pages/billing/BillingMandateView.tsx index f6792c6..cc84f16 100644 --- a/src/pages/billing/BillingMandateView.tsx +++ b/src/pages/billing/BillingMandateView.tsx @@ -39,13 +39,8 @@ const MandateBalanceTable: React.FC = ({ }; const getBillingModelLabel = (model: string) => { - switch (model) { - case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; - case 'PREPAY_USER': return 'Prepaid (Benutzer)'; - case 'CREDIT_POSTPAY': return 'Kredit'; - case 'UNLIMITED': return 'Unlimited'; - default: return model; - } + if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)'; + return 'Prepaid (Mandant)'; }; return ( diff --git a/src/pages/views/workspace/useWorkspace.ts b/src/pages/views/workspace/useWorkspace.ts index d974955..2df4117 100644 --- a/src/pages/views/workspace/useWorkspace.ts +++ b/src/pages/views/workspace/useWorkspace.ts @@ -351,13 +351,27 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn { onStopped: () => setIsProcessing(false), onError: (event) => { setIsProcessing(false); + const item = event.item as Record | undefined; + let msg = event.content || 'Unknown error'; + if (item && item.error === 'INSUFFICIENT_BALANCE') { + const preferDe = + typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de'); + const de = typeof item.messageDe === 'string' ? item.messageDe : ''; + const en = typeof item.messageEn === 'string' ? item.messageEn : ''; + msg = preferDe ? de || en || msg : en || de || msg; + if (item.userAction === 'TOP_UP_SELF' && typeof item.billingUiPath === 'string') { + msg += `\n\n→ ${item.billingUiPath}`; + } + } else { + msg = `Error: ${msg}`; + } setMessages(prev => [ ...prev, { id: `error-${Date.now()}`, workflowId: '', role: 'system', - message: `Error: ${event.content || 'Unknown error'}`, + message: msg, publishedAt: Date.now() / 1000, }, ]); diff --git a/src/utils/mandateBillingFormMerge.ts b/src/utils/mandateBillingFormMerge.ts new file mode 100644 index 0000000..ce06580 --- /dev/null +++ b/src/utils/mandateBillingFormMerge.ts @@ -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, + settings: BillingSettings | null +): Record { + 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 +): { mandatePayload: Record; billingUpdate: BillingSettingsUpdate } { + const mandatePayload: Record = {}; + 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 }; +} From 131c4534b55d86c60c202353f8dc639854cbd979 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 22 Mar 2026 01:20:40 +0100 Subject: [PATCH 2/2] fixed global RAG and admin consent msft --- .../TreeNavigation/TreeNavigation.module.css | 8 ++ .../TreeNavigation/TreeNavigation.tsx | 11 +- .../UiComponents/Messages/MessagesTypes.ts | 2 + src/pages/basedata/ConnectionsPage.tsx | 32 ++++- src/pages/basedata/FilesPage.tsx | 2 +- .../automation/AutomationDefinitionsView.tsx | 2 +- .../automation/AutomationTemplatesView.tsx | 2 +- src/pages/views/workspace/ChatStream.tsx | 12 ++ src/pages/views/workspace/useWorkspace.ts | 117 ++++++++++++++++-- 9 files changed, 172 insertions(+), 16 deletions(-) diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css index 01f0722..5e9f57c 100644 --- a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css +++ b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css @@ -103,6 +103,14 @@ pointer-events: none; } +/* + * Label-only row under a parent that shows an icon: indent so text lines up with the + * parent's title, not with the icon column (see .nodeIcon width + .treeNode gap). + */ +.treeNodeAlignWithParentTitle { + padding-left: calc(0.5rem + 0.875rem + 0.375rem); +} + /* ============================================ */ /* DEPTH-SPECIFIC STYLES (via data-depth) */ /* ============================================ */ diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx index 1d38b65..babceee 100644 --- a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx +++ b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx @@ -125,6 +125,8 @@ function isTreeSeparator(item: TreeItem): item is TreeSeparatorItem { interface TreeNodeProps { node: TreeNodeItem; level: number; + /** True when the parent row shows an icon — used to align label-only children with the parent's title text. */ + parentHasIcon?: boolean; autoExpandActive: boolean; currentPath: string; onNodeClick?: (node: TreeNodeItem) => void; @@ -134,6 +136,7 @@ interface TreeNodeProps { const TreeNode: React.FC = ({ node, level, + parentHasIcon = false, autoExpandActive, currentPath, onNodeClick, @@ -219,8 +222,9 @@ const TreeNode: React.FC = ({ ); - // Determine if we should render as NavLink or button - const nodeClasses = `${styles.treeNode} ${isLeafActive ? styles.active : ''} ${isGroupActive ? styles.activeGroup : ''} ${node.disabled ? styles.disabled : ''} ${node.className || ''}`; + // Unterknoten ohne Icon unter einem Knoten mit Icon: Text mit Eltern-Titel ausrichten (nicht mit Icon-Spalte) + const alignLabelWithParentTitle = parentHasIcon && !node.icon; + const nodeClasses = `${styles.treeNode} ${isLeafActive ? styles.active : ''} ${isGroupActive ? styles.activeGroup : ''} ${node.disabled ? styles.disabled : ''} ${alignLabelWithParentTitle ? styles.treeNodeAlignWithParentTitle : ''} ${node.className || ''}`; const nodeElement = node.path ? ( = ({ key={child.id || `${node.id}-child-${index}`} node={child} level={level + 1} + parentHasIcon={!!node.icon} autoExpandActive={autoExpandActive} currentPath={currentPath} onNodeClick={onNodeClick} @@ -304,6 +309,7 @@ const TreeSection: React.FC = ({ key={node.id || `section-${section.title}-${index}`} node={node} level={0} + parentHasIcon={false} autoExpandActive={autoExpandActive} currentPath={currentPath} onNodeClick={onNodeClick} @@ -355,6 +361,7 @@ export const TreeNavigation: React.FC = ({ key={item.id || `node-${index}`} node={item} level={0} + parentHasIcon={false} autoExpandActive={autoExpandActive} currentPath={currentPath} onNodeClick={onNodeClick} diff --git a/src/components/UiComponents/Messages/MessagesTypes.ts b/src/components/UiComponents/Messages/MessagesTypes.ts index 9ab4d6c..7aa81b3 100644 --- a/src/components/UiComponents/Messages/MessagesTypes.ts +++ b/src/components/UiComponents/Messages/MessagesTypes.ts @@ -32,6 +32,8 @@ export interface Message { role?: string; status?: string; sequenceNr?: number; + /** ISO or number from API; workspace may use publishedAt only */ + createdAt?: number; publishedAt?: number; success?: boolean; actionId?: string; diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 9cd0b7e..5e0b662 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -9,7 +9,8 @@ import React, { useState, useMemo, useEffect } from 'react'; import { useConnections, type Connection } from '../../hooks/useConnections'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; -import { FaSync, FaPlug, FaGoogle, FaMicrosoft, FaLink, FaRedo } from 'react-icons/fa'; +import { FaSync, FaPlug, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt } from 'react-icons/fa'; +import { getApiBaseUrl } from '../../../config/config'; import styles from '../admin/Admin.module.css'; export const ConnectionsPage: React.FC = () => { @@ -37,6 +38,7 @@ export const ConnectionsPage: React.FC = () => { const [editingConnection, setEditingConnection] = useState(null); const [deletingConnections, setDeletingConnections] = useState>(new Set()); const [refreshingConnections, setRefreshingConnections] = useState>(new Set()); + const [adminConsentPending, setAdminConsentPending] = useState(false); // Initial fetch useEffect(() => { @@ -182,6 +184,24 @@ export const ConnectionsPage: React.FC = () => { } }; + // Open Microsoft Admin Consent flow in a popup + const handleAdminConsent = () => { + setAdminConsentPending(true); + const consentUrl = `${getApiBaseUrl()}/api/msft/adminconsent`; + const popup = window.open(consentUrl, 'msft-admin-consent', 'width=600,height=700,scrollbars=yes,resizable=yes'); + if (!popup) { + setAdminConsentPending(false); + return; + } + const checkClosed = setInterval(() => { + if (popup.closed) { + clearInterval(checkClosed); + setAdminConsentPending(false); + refetch(); + } + }, 1000); + }; + // Form attributes for edit modal const formAttributes = useMemo(() => { const excludedFields = ['id', 'mandateId', 'userId', '_createdBy', '_createdAt', '_modifiedAt', 'connectedAt', 'lastChecked']; @@ -204,13 +224,21 @@ export const ConnectionsPage: React.FC = () => { } return ( -
+

Verbindungen

OAuth-Verbindungen verwalten

+