commit
9b6edec74e
81 changed files with 6456 additions and 2168 deletions
2
.github/workflows/poweron_nyla_int.yml
vendored
2
.github/workflows/poweron_nyla_int.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
||||||
|
|
||||||
- name: Copy integration environment file
|
- name: Copy integration environment file
|
||||||
run: |
|
run: |
|
||||||
cp config/.env.int .env
|
cp config/env-poweron-nyla-int.env .env
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
2
.github/workflows/poweron_nyla_main.yml
vendored
2
.github/workflows/poweron_nyla_main.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
||||||
|
|
||||||
- name: Copy production environment file
|
- name: Copy production environment file
|
||||||
run: |
|
run: |
|
||||||
cp config/.env.prod .env
|
cp config/env-poweron-nyla-prod.env .env
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -30,7 +30,5 @@ dist-ssr
|
||||||
|
|
||||||
.cursorignore
|
.cursorignore
|
||||||
|
|
||||||
# Keep environment template files in config/
|
# Keep environment files in config/ (naming: env-<workflow>.env)
|
||||||
!config/.env.dev
|
!config/env-*.env
|
||||||
!config/.env.int
|
|
||||||
!config/.env.prod
|
|
||||||
31
README.md
31
README.md
|
|
@ -5,9 +5,9 @@
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
%% Environment Files
|
%% Environment Files
|
||||||
ENV_DEV[".env.dev<br/>Development"]
|
ENV_DEV["env-poweron-nyla-dev.env<br/>Development"]
|
||||||
ENV_PROD[".env.prod<br/>Production"]
|
ENV_PROD["env-poweron-nyla-prod.env<br/>Production"]
|
||||||
ENV_INT[".env.int<br/>Integration"]
|
ENV_INT["env-poweron-nyla-int.env<br/>Integration"]
|
||||||
|
|
||||||
%% Configuration System
|
%% Configuration System
|
||||||
CONFIG_TS["config.ts<br/>TypeScript Config<br/>(React Frontend)"]
|
CONFIG_TS["config.ts<br/>TypeScript Config<br/>(React Frontend)"]
|
||||||
|
|
@ -114,30 +114,25 @@ The app uses a **dual configuration system** to handle environment variables acr
|
||||||
- **Used by:** Express servers and build scripts
|
- **Used by:** Express servers and build scripts
|
||||||
|
|
||||||
### Environment Files
|
### Environment Files
|
||||||
- **`config/.env.dev`** - Development environment variables
|
|
||||||
- **Why:** Separate config for local development with debug settings
|
|
||||||
- **How:** Copied to root `.env` by `npm run dev` command
|
|
||||||
- **Contains:** Local API URLs, debug flags, dev-specific settings
|
|
||||||
|
|
||||||
- **`config/.env.prod`** - Production environment variables
|
Naming convention: `env-<workflow-name>.env` — matches the GitHub Actions workflow that uses it.
|
||||||
- **Why:** Production-specific settings (live API URLs, optimized settings)
|
|
||||||
- **How:** Copied to root `.env` by GitHub Actions workflow
|
|
||||||
- **Contains:** Production API URLs, security settings, performance configs
|
|
||||||
|
|
||||||
- **`config/.env.int`** - Integration environment variables
|
- **`config/env-poweron-nyla-dev.env`** — Local development (localhost gateway)
|
||||||
- **Why:** Testing environment that mirrors production but with test data
|
- **`config/env-poweron-nyla-int.env`** — Integration (used by `poweron_nyla_int` workflow)
|
||||||
- **How:** Copied to root `.env` by integration deployment workflow
|
- **`config/env-poweron-nyla-prod.env`** — Production (used by `poweron_nyla_main` workflow)
|
||||||
- **Contains:** Staging API URLs, test user credentials, integration settings
|
|
||||||
|
Each env is copied to root `.env` at build time (by CI or manually for local dev).
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
```bash
|
```bash
|
||||||
# Development (loads .env.dev)
|
# Local development — copy env then start Vite
|
||||||
|
cp config/env-poweron-nyla-dev.env .env
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# Production build (loads .env.prod)
|
# Production build (CI copies env-poweron-nyla-prod.env → .env)
|
||||||
npm run build:prod
|
npm run build:prod
|
||||||
|
|
||||||
# Integration build (loads .env.int)
|
# Integration build (CI copies env-poweron-nyla-int.env → .env)
|
||||||
npm run build:int
|
npm run build:int
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# Development Environment Configuration
|
|
||||||
# Frontend Nyla - Development
|
|
||||||
|
|
||||||
# API Configuration
|
|
||||||
VITE_API_BASE_URL="http://localhost:8000/"
|
|
||||||
VITE_API_TIMEOUT=10000
|
|
||||||
|
|
||||||
# Microsoft Entra ID Configuration
|
|
||||||
VITE_MICROSOFT_CLIENT_ID=24cd6c8a-b592-4905-a5ba-d5fa9f911154
|
|
||||||
VITE_MICROSOFT_TENANT_ID=6a51aaeb-2467-4186-9504-2a05aedc591f
|
|
||||||
VITE_ENTRA_CLIENT_SECRET=2iw8Q~jwqG1iacxHopBt5pstu6R45UC1gIQabcbD
|
|
||||||
VITE_ENTRA_AUTHORITY=https://login.microsoftonline.com/6a51aaeb-2467-4186-9504-2a05aedc591f
|
|
||||||
VITE_ENTRA_REDIRECT_PATH=/auth/callback/
|
|
||||||
VITE_ENTRA_REDIRECT_URI=http://localhost:8000/api/msft/auth/callback/
|
|
||||||
|
|
||||||
# Application Configuration
|
|
||||||
VITE_APP_NAME=PowerOn Nyla dev
|
|
||||||
VITE_APP_VERSION=0.0.0
|
|
||||||
VITE_APP_ENVIRONMENT=development
|
|
||||||
|
|
||||||
# Debug Configuration
|
|
||||||
VITE_DEBUG=true
|
|
||||||
VITE_LOG_LEVEL=debug
|
|
||||||
VITE_ENABLE_CONSOLE_LOGS=true
|
|
||||||
|
|
||||||
# Feature Flags
|
|
||||||
VITE_ENABLE_ANALYTICS=false
|
|
||||||
VITE_ENABLE_ERROR_REPORTING=false
|
|
||||||
VITE_ENABLE_PERFORMANCE_MONITORING=false
|
|
||||||
|
|
||||||
# Development Server
|
|
||||||
VITE_DEV_SERVER_PORT=5176
|
|
||||||
VITE_DEV_SERVER_HOST=localhost
|
|
||||||
VITE_DEV_SERVER_HTTPS=false
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# Integration/Test Environment Configuration
|
|
||||||
# Frontend Nyla - Integration
|
|
||||||
|
|
||||||
# API Configuration
|
|
||||||
VITE_API_BASE_URL=https://gateway-int.poweron-center.net
|
|
||||||
VITE_API_TIMEOUT=12000
|
|
||||||
|
|
||||||
# Microsoft Entra ID Configuration
|
|
||||||
VITE_MICROSOFT_CLIENT_ID=24cd6c8a-b592-4905-a5ba-d5fa9f911154
|
|
||||||
VITE_MICROSOFT_TENANT_ID=6a51aaeb-2467-4186-9504-2a05aedc591f
|
|
||||||
VITE_ENTRA_CLIENT_SECRET=2iw8Q~jwqG1iacxHopBt5pstu6R45UC1gIQabcbD
|
|
||||||
VITE_ENTRA_AUTHORITY=https://login.microsoftonline.com/6a51aaeb-2467-4186-9504-2a05aedc591f
|
|
||||||
VITE_ENTRA_REDIRECT_PATH=/auth/callback/
|
|
||||||
VITE_ENTRA_REDIRECT_URI=https://gateway-int.poweron-center.net/api/msft/auth/callback/
|
|
||||||
|
|
||||||
# Application Configuration
|
|
||||||
VITE_APP_NAME=Poweron Nyla int
|
|
||||||
VITE_APP_VERSION=0.0.0
|
|
||||||
VITE_APP_ENVIRONMENT=integration
|
|
||||||
|
|
||||||
# Debug Configuration
|
|
||||||
VITE_DEBUG=true
|
|
||||||
VITE_LOG_LEVEL=info
|
|
||||||
VITE_ENABLE_CONSOLE_LOGS=true
|
|
||||||
|
|
||||||
# Feature Flags
|
|
||||||
VITE_ENABLE_ANALYTICS=true
|
|
||||||
VITE_ENABLE_ERROR_REPORTING=true
|
|
||||||
VITE_ENABLE_PERFORMANCE_MONITORING=true
|
|
||||||
|
|
||||||
# Test Configuration
|
|
||||||
VITE_ENABLE_MOCK_DATA=false
|
|
||||||
VITE_ENABLE_TEST_MODE=true
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# Production Environment Configuration
|
|
||||||
# Frontend Nyla - Production
|
|
||||||
|
|
||||||
# API Configuration
|
|
||||||
VITE_API_BASE_URL=https://gateway-prod.poweron-center.net
|
|
||||||
VITE_API_TIMEOUT=15000
|
|
||||||
|
|
||||||
# Microsoft Entra ID Configuration
|
|
||||||
VITE_MICROSOFT_CLIENT_ID=24cd6c8a-b592-4905-a5ba-d5fa9f911154
|
|
||||||
VITE_MICROSOFT_TENANT_ID=6a51aaeb-2467-4186-9504-2a05aedc591f
|
|
||||||
VITE_ENTRA_CLIENT_SECRET=2iw8Q~jwqG1iacxHopBt5pstu6R45UC1gIQabcbD
|
|
||||||
VITE_ENTRA_AUTHORITY=https://login.microsoftonline.com/6a51aaeb-2467-4186-9504-2a05aedc591f
|
|
||||||
VITE_ENTRA_REDIRECT_PATH=/auth/callback/
|
|
||||||
VITE_ENTRA_REDIRECT_URI=https://gateway-prod.poweron-center.net/api/msft/auth/callback/
|
|
||||||
|
|
||||||
# Application Configuration
|
|
||||||
VITE_APP_NAME=PowerOn Nyla
|
|
||||||
VITE_APP_VERSION=0.0.0
|
|
||||||
VITE_APP_ENVIRONMENT=production
|
|
||||||
|
|
||||||
# Debug Configuration
|
|
||||||
VITE_DEBUG=false
|
|
||||||
VITE_LOG_LEVEL=error
|
|
||||||
VITE_ENABLE_CONSOLE_LOGS=false
|
|
||||||
|
|
||||||
# Feature Flags
|
|
||||||
VITE_ENABLE_ANALYTICS=true
|
|
||||||
VITE_ENABLE_ERROR_REPORTING=true
|
|
||||||
VITE_ENABLE_PERFORMANCE_MONITORING=true
|
|
||||||
|
|
||||||
# Security Configuration
|
|
||||||
VITE_ENABLE_HTTPS=true
|
|
||||||
VITE_ENABLE_CSP=true
|
|
||||||
6
config/env-poweron-nyla-dev.env
Normal file
6
config/env-poweron-nyla-dev.env
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Environment: poweron-nyla-dev (local development)
|
||||||
|
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
|
||||||
|
# Auth and secrets live on the gateway — never in frontend env.
|
||||||
|
|
||||||
|
VITE_API_BASE_URL="http://localhost:8000/"
|
||||||
|
VITE_APP_NAME=PowerOn Nyla dev
|
||||||
6
config/env-poweron-nyla-int.env
Normal file
6
config/env-poweron-nyla-int.env
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Environment: poweron-nyla-int (integration)
|
||||||
|
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
|
||||||
|
# Auth and secrets live on the gateway — never in frontend env.
|
||||||
|
|
||||||
|
VITE_API_BASE_URL=https://gateway-int.poweron.swiss
|
||||||
|
VITE_APP_NAME=Poweron Nyla int
|
||||||
6
config/env-poweron-nyla-prod.env
Normal file
6
config/env-poweron-nyla-prod.env
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Environment: poweron-nyla-prod (production)
|
||||||
|
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
|
||||||
|
# Auth and secrets live on the gateway — never in frontend env.
|
||||||
|
|
||||||
|
VITE_API_BASE_URL=https://gateway-prod.poweron.swiss
|
||||||
|
VITE_APP_NAME=PowerOn Nyla
|
||||||
15
env.d.ts
vendored
15
env.d.ts
vendored
|
|
@ -1,15 +1,6 @@
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_URL: string
|
readonly VITE_API_BASE_URL?: string
|
||||||
readonly VITE_MICROSOFT_CLIENT_ID: string
|
readonly VITE_APP_NAME?: string
|
||||||
readonly VITE_MICROSOFT_TENANT_ID: string
|
}
|
||||||
readonly VITE_ENTRA_CLIENT_SECRET: string
|
|
||||||
readonly VITE_ENTRA_AUTHORITY: string
|
|
||||||
readonly VITE_ENTRA_REDIRECT_PATH: string
|
|
||||||
readonly VITE_ENTRA_REDIRECT_URI: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportMeta {
|
|
||||||
readonly env: ImportMetaEnv
|
|
||||||
}
|
|
||||||
|
|
|
||||||
36
package-lock.json
generated
36
package-lock.json
generated
|
|
@ -8,8 +8,6 @@
|
||||||
"name": "frontend_nyla_new",
|
"name": "frontend_nyla_new",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.12.0",
|
|
||||||
"@azure/msal-react": "^3.0.12",
|
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@xstate/react": "^5.0.0",
|
"@xstate/react": "^5.0.0",
|
||||||
|
|
@ -101,40 +99,6 @@
|
||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@azure/msal-browser": {
|
|
||||||
"version": "4.16.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.16.0.tgz",
|
|
||||||
"integrity": "sha512-yF8gqyq7tVnYftnrWaNaxWpqhGQXoXpDfwBtL7UCGlIbDMQ1PUJF/T2xCL6NyDNHoO70qp1xU8GjjYTyNIefkw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@azure/msal-common": "15.9.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@azure/msal-common": {
|
|
||||||
"version": "15.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.9.0.tgz",
|
|
||||||
"integrity": "sha512-lbz/D+C9ixUG3hiZzBLjU79a0+5ZXCorjel3mwXluisKNH0/rOS/ajm8yi4yI9RP5Uc70CAcs9Ipd0051Oh/kA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@azure/msal-react": {
|
|
||||||
"version": "3.0.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-3.0.16.tgz",
|
|
||||||
"integrity": "sha512-fIFc3z9UrHoOCG4rApNWMRr83DnQlo+CHfLSPNBQa4rndIkr+XYBpdYDqlzqtmikRf3A+CYNVOQ+lQX6jM0zdw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@azure/msal-browser": "^4.16.0",
|
|
||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,6 @@
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.12.0",
|
|
||||||
"@azure/msal-react": "^3.0.12",
|
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@xstate/react": "^5.0.0",
|
"@xstate/react": "^5.0.0",
|
||||||
|
|
|
||||||
10
src/App.tsx
10
src/App.tsx
|
|
@ -25,7 +25,6 @@ import Reset from './pages/Reset';
|
||||||
import { InvitePage } from './pages/InvitePage';
|
import { InvitePage } from './pages/InvitePage';
|
||||||
|
|
||||||
// Providers
|
// Providers
|
||||||
import { AuthProvider } from './providers/auth/AuthProvider';
|
|
||||||
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
||||||
import { LanguageProvider } from './providers/language/LanguageContext';
|
import { LanguageProvider } from './providers/language/LanguageContext';
|
||||||
import { ToastProvider } from './contexts/ToastContext';
|
import { ToastProvider } from './contexts/ToastContext';
|
||||||
|
|
@ -71,7 +70,6 @@ function App() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<AuthProvider>
|
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<VoiceCatalogProvider>
|
<VoiceCatalogProvider>
|
||||||
<WorkflowSelectionProvider>
|
<WorkflowSelectionProvider>
|
||||||
|
|
@ -179,12 +177,15 @@ function App() {
|
||||||
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
||||||
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
||||||
|
|
||||||
|
{/* Shared: assistant + modules routes (ComCoach + TeamsBot) */}
|
||||||
|
<Route path="assistant" element={<FeatureViewPage view="assistant" />} />
|
||||||
|
<Route path="modules" element={<FeatureViewPage view="modules" />} />
|
||||||
|
|
||||||
{/* Neutralization Feature Views */}
|
{/* Neutralization Feature Views */}
|
||||||
<Route path="playground" element={<FeatureViewPage view="playground" />} />
|
<Route path="playground" element={<FeatureViewPage view="playground" />} />
|
||||||
|
|
||||||
{/* CommCoach Feature Views */}
|
{/* CommCoach Feature Views */}
|
||||||
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
|
<Route path="session" element={<FeatureViewPage view="session" />} />
|
||||||
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
|
|
||||||
|
|
||||||
{/* Redmine Feature Views */}
|
{/* Redmine Feature Views */}
|
||||||
<Route path="stats" element={<FeatureViewPage view="stats" />} />
|
<Route path="stats" element={<FeatureViewPage view="stats" />} />
|
||||||
|
|
@ -237,7 +238,6 @@ function App() {
|
||||||
</WorkflowSelectionProvider>
|
</WorkflowSelectionProvider>
|
||||||
</VoiceCatalogProvider>
|
</VoiceCatalogProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</AuthProvider>
|
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,29 @@ export interface BillingTransaction {
|
||||||
userName?: string;
|
userName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pagination request for GET /api/billing/transactions with `pagination` JSON (table + grouping). */
|
||||||
|
export interface BillingTransactionsPaginationParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
search?: string;
|
||||||
|
viewKey?: string;
|
||||||
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BillingTransactionsPaginatedResponse {
|
||||||
|
items: BillingTransaction[];
|
||||||
|
pagination?: {
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
groupLayout?: import('./connectionApi').GroupLayout;
|
||||||
|
appliedView?: { viewKey?: string; displayName?: string };
|
||||||
|
}
|
||||||
|
|
||||||
export interface BillingSettings {
|
export interface BillingSettings {
|
||||||
id: string;
|
id: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
|
|
@ -135,7 +158,31 @@ export async function fetchBalanceForMandate(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch transaction history
|
* Fetch transaction history (table UI: pagination, filters, sort, saved views, grouping).
|
||||||
|
* Endpoint: GET /api/billing/transactions?pagination=...
|
||||||
|
*/
|
||||||
|
export async function fetchTransactionsPaginated(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
params?: BillingTransactionsPaginationParams
|
||||||
|
): Promise<BillingTransactionsPaginatedResponse> {
|
||||||
|
const paginationObj: Record<string, unknown> = {};
|
||||||
|
if (params?.page !== undefined) paginationObj.page = params.page;
|
||||||
|
if (params?.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||||
|
if (params?.sort?.length) paginationObj.sort = params.sort;
|
||||||
|
if (params?.filters && Object.keys(params.filters).length > 0) paginationObj.filters = params.filters;
|
||||||
|
if (params?.search) paginationObj.search = params.search;
|
||||||
|
if (params?.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
|
if (params?.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||||
|
|
||||||
|
return await request({
|
||||||
|
url: '/api/billing/transactions',
|
||||||
|
method: 'get',
|
||||||
|
params: { pagination: JSON.stringify(paginationObj) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch transaction history (legacy array window)
|
||||||
* Endpoint: GET /api/billing/transactions
|
* Endpoint: GET /api/billing/transactions
|
||||||
*/
|
*/
|
||||||
export async function fetchTransactions(
|
export async function fetchTransactions(
|
||||||
|
|
|
||||||
|
|
@ -109,8 +109,8 @@ export interface CoachingUserProfile {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
totalContexts: number;
|
totalModules: number;
|
||||||
activeContexts: number;
|
activeModules: number;
|
||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalMinutes: number;
|
totalMinutes: number;
|
||||||
streakDays: number;
|
streakDays: number;
|
||||||
|
|
@ -122,7 +122,11 @@ export interface DashboardData {
|
||||||
goalProgress?: number;
|
goalProgress?: number;
|
||||||
badges?: CoachingBadge[];
|
badges?: CoachingBadge[];
|
||||||
level?: { number: number; label: string; totalSessions: number };
|
level?: { number: number; label: string; totalSessions: number };
|
||||||
contexts: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
|
modules: Array<{ id: string; title: string; moduleType: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
|
||||||
|
/** @deprecated Use totalModules/activeModules/modules instead */
|
||||||
|
totalContexts?: number;
|
||||||
|
activeContexts?: number;
|
||||||
|
contexts?: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSEEvent {
|
export interface SSEEvent {
|
||||||
|
|
@ -133,31 +137,73 @@ export interface SSEEvent {
|
||||||
|
|
||||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||||
|
|
||||||
|
export function getApiRequest(): ApiRequestFunction {
|
||||||
|
return async (options: ApiRequestOptions<any>) => {
|
||||||
|
const response = await api(options);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Context API
|
// Module API (TrainingModule — replaces Context API)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function listModulesApi(request: ApiRequestFunction, instanceId: string): Promise<any[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'get' });
|
||||||
|
return data.modules || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createModuleApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||||
|
title: string; moduleType?: string; goals?: string; personaId?: string; kpiTargets?: string;
|
||||||
|
}): Promise<any> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'post', data: body });
|
||||||
|
return data.module;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModuleDetailApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<any> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'get' });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateModuleApi(request: ApiRequestFunction, instanceId: string, moduleId: string, body: any): Promise<any> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'put', data: body });
|
||||||
|
return data.module;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteModuleApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<void> {
|
||||||
|
await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'delete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSessionsApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<any[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/sessions`, method: 'get' });
|
||||||
|
return data.sessions || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context / Module API (uses /modules/ endpoints)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export async function getContextsApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingContext[]> {
|
export async function getContextsApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingContext[]> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'get' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'get' });
|
||||||
return data.contexts || [];
|
return data.modules || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createContextApi(request: ApiRequestFunction, instanceId: string, body: {
|
export async function createContextApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||||
title: string; description?: string; category?: string; goals?: string[];
|
title: string; description?: string; category?: string; goals?: string[];
|
||||||
}): Promise<CoachingContext> {
|
}): Promise<CoachingContext> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'post', data: body });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'post', data: body });
|
||||||
return data.context;
|
return data.module;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContextDetailApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
|
export async function getContextDetailApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
|
||||||
context: CoachingContext; tasks: CoachingTask[]; scores: CoachingScore[]; sessions: CoachingSession[];
|
context: CoachingContext; tasks: CoachingTask[]; scores: CoachingScore[]; sessions: CoachingSession[];
|
||||||
}> {
|
}> {
|
||||||
const data = await request({
|
const data = await request({
|
||||||
url: `/api/commcoach/${instanceId}/contexts/${contextId}`,
|
url: `/api/commcoach/${instanceId}/modules/${contextId}`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params: { _t: Date.now() },
|
params: { _t: Date.now() },
|
||||||
});
|
});
|
||||||
const ctx = data?.context ?? data;
|
const ctx = data?.module ?? data;
|
||||||
return {
|
return {
|
||||||
context: ctx,
|
context: ctx,
|
||||||
tasks: data?.tasks ?? [],
|
tasks: data?.tasks ?? [],
|
||||||
|
|
@ -167,22 +213,22 @@ export async function getContextDetailApi(request: ApiRequestFunction, instanceI
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: any): Promise<CoachingContext> {
|
export async function updateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: any): Promise<CoachingContext> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'put', data: body });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}`, method: 'put', data: body });
|
||||||
return data.context;
|
return data.module;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<void> {
|
export async function deleteContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<void> {
|
||||||
await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'delete' });
|
await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}`, method: 'delete' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function archiveContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
export async function archiveContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/archive`, method: 'post' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/archive`, method: 'post' });
|
||||||
return data.context;
|
return data.module;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function activateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
export async function activateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/activate`, method: 'post' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/activate`, method: 'post' });
|
||||||
return data.context;
|
return data.module;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -192,7 +238,7 @@ export async function activateContextApi(request: ApiRequestFunction, instanceId
|
||||||
export async function startSessionApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
|
export async function startSessionApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
|
||||||
session: CoachingSession; messages: CoachingMessage[]; resumed: boolean;
|
session: CoachingSession; messages: CoachingMessage[]; resumed: boolean;
|
||||||
}> {
|
}> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start`, method: 'post' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/sessions/start`, method: 'post' });
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,7 +253,7 @@ export async function startSessionStreamApi(
|
||||||
try {
|
try {
|
||||||
const baseURL = api.defaults.baseURL || '';
|
const baseURL = api.defaults.baseURL || '';
|
||||||
const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : '';
|
const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : '';
|
||||||
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start${personaParam}`;
|
const url = `${baseURL}/api/commcoach/${instanceId}/modules/${contextId}/sessions/start${personaParam}`;
|
||||||
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
const authToken = localStorage.getItem('authToken');
|
const authToken = localStorage.getItem('authToken');
|
||||||
|
|
@ -243,14 +289,11 @@ export async function startSessionStreamApi(
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
try {
|
const jsonStr = line.slice(6);
|
||||||
const jsonStr = line.slice(6);
|
if (jsonStr.trim()) {
|
||||||
if (jsonStr.trim()) {
|
let event: SSEEvent;
|
||||||
const event: SSEEvent = JSON.parse(jsonStr);
|
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||||
onEvent(event);
|
onEvent(event);
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// skip malformed lines
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -348,14 +391,11 @@ export async function sendMessageStreamApi(
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
try {
|
const jsonStr = line.slice(6);
|
||||||
const jsonStr = line.slice(6);
|
if (jsonStr.trim()) {
|
||||||
if (jsonStr.trim()) {
|
let event: SSEEvent;
|
||||||
const event: SSEEvent = JSON.parse(jsonStr);
|
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||||
onEvent(event);
|
onEvent(event);
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// skip malformed lines
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -424,10 +464,12 @@ export async function sendAudioStreamApi(
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
try {
|
const jsonStr = line.slice(6);
|
||||||
const jsonStr = line.slice(6);
|
if (jsonStr.trim()) {
|
||||||
if (jsonStr.trim()) onEvent(JSON.parse(jsonStr));
|
let event: SSEEvent;
|
||||||
} catch { /* skip */ }
|
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||||
|
onEvent(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -446,14 +488,14 @@ export async function sendAudioStreamApi(
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export async function getTasksApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingTask[]> {
|
export async function getTasksApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingTask[]> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/tasks`, method: 'get' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/tasks`, method: 'get' });
|
||||||
return data.tasks || [];
|
return data.tasks || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTaskApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: {
|
export async function createTaskApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: {
|
||||||
title: string; description?: string; priority?: string; dueDate?: string;
|
title: string; description?: string; priority?: string; dueDate?: string;
|
||||||
}): Promise<CoachingTask> {
|
}): Promise<CoachingTask> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/tasks`, method: 'post', data: body });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/tasks`, method: 'post', data: body });
|
||||||
return data.task;
|
return data.task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -500,7 +542,14 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId:
|
||||||
|
|
||||||
export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingPersona[]> {
|
export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingPersona[]> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get' });
|
||||||
return data.personas || [];
|
return data.items || data.personas || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPersonasPaginated(request: ApiRequestFunction, instanceId: string, params?: any): Promise<any> {
|
||||||
|
const queryParams: Record<string, string> = {};
|
||||||
|
if (params) queryParams.pagination = JSON.stringify(params);
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get', params: queryParams });
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: {
|
export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||||
|
|
@ -510,10 +559,31 @@ export async function createPersonaApi(request: ApiRequestFunction, instanceId:
|
||||||
return data.persona;
|
return data.persona;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updatePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string, body: {
|
||||||
|
label?: string; description?: string; gender?: string; systemPromptOverride?: string; isActive?: boolean;
|
||||||
|
}): Promise<CoachingPersona> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'put', data: body });
|
||||||
|
return data.persona;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deletePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string): Promise<void> {
|
export async function deletePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string): Promise<void> {
|
||||||
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
|
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Module-Persona Mapping API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getModulePersonasApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<string[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/personas`, method: 'get' });
|
||||||
|
return data.personaIds || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setModulePersonasApi(request: ApiRequestFunction, instanceId: string, moduleId: string, personaIds: string[]): Promise<string[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/personas`, method: 'put', data: { personaIds } });
|
||||||
|
return data.personaIds || [];
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Badge API (Iteration 2)
|
// Badge API (Iteration 2)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -529,7 +599,7 @@ export async function getBadgesApi(request: ApiRequestFunction, instanceId: stri
|
||||||
|
|
||||||
export function getDossierExportUrl(instanceId: string, contextId: string, format: string = 'md'): string {
|
export function getDossierExportUrl(instanceId: string, contextId: string, format: string = 'md'): string {
|
||||||
const baseURL = api.defaults.baseURL || '';
|
const baseURL = api.defaults.baseURL || '';
|
||||||
return `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/export?format=${format}`;
|
return `${baseURL}/api/commcoach/${instanceId}/modules/${contextId}/export?format=${format}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string {
|
export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string {
|
||||||
|
|
@ -544,6 +614,6 @@ export function getSessionExportUrl(instanceId: string, sessionId: string, forma
|
||||||
export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<Record<string, Array<{
|
export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<Record<string, Array<{
|
||||||
score: number; trend: string; evidence?: string; createdAt?: string; sessionId?: string;
|
score: number; trend: string; evidence?: string; createdAt?: string; sessionId?: string;
|
||||||
}>>> {
|
}>>> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/scores/history`, method: 'get' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/scores/history`, method: 'get' });
|
||||||
return data.history || {};
|
return data.history || {};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,19 +55,22 @@ export interface PaginationParams {
|
||||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
search?: string;
|
search?: string;
|
||||||
/** Scope request to items of this group (resolved server-side to itemIds IN-filter). */
|
/** Key of a saved view to apply (server loads groupByLevels, filters, sort from DB). */
|
||||||
groupId?: string;
|
viewKey?: string;
|
||||||
/** If set, persist this group tree on the backend before fetching (optimistic save). */
|
/** Explicit grouping levels; when sent (incl. []), overrides the view for this request. */
|
||||||
saveGroupTree?: TableGroupNode[];
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TableGroupNode {
|
export interface GroupBand {
|
||||||
id: string;
|
path: string[];
|
||||||
name: string;
|
label: string;
|
||||||
itemIds: string[];
|
startRowIndex: number;
|
||||||
subGroups: TableGroupNode[];
|
rowCount: number;
|
||||||
order: number;
|
}
|
||||||
isExpanded: boolean;
|
|
||||||
|
export interface GroupLayout {
|
||||||
|
levels: string[];
|
||||||
|
bands: GroupBand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -78,8 +81,8 @@ export interface PaginatedResponse<T> {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
/** Current group tree for this (user, contextKey) pair — undefined if no grouping configured. */
|
groupLayout?: GroupLayout;
|
||||||
groupTree?: TableGroupNode[];
|
appliedView?: { viewKey?: string; displayName?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateConnectionData {
|
export interface CreateConnectionData {
|
||||||
|
|
@ -138,8 +141,8 @@ export async function fetchConnections(
|
||||||
if (params.sort) paginationObj.sort = params.sort;
|
if (params.sort) paginationObj.sort = params.sort;
|
||||||
if (params.filters) paginationObj.filters = params.filters;
|
if (params.filters) paginationObj.filters = params.filters;
|
||||||
if (params.search) paginationObj.search = params.search;
|
if (params.search) paginationObj.search = params.search;
|
||||||
if (params.groupId) paginationObj.groupId = params.groupId;
|
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree;
|
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ export interface PaginationParams {
|
||||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
search?: string;
|
search?: string;
|
||||||
groupId?: string;
|
viewKey?: string;
|
||||||
saveGroupTree?: any[];
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -46,6 +46,8 @@ export interface PaginatedResponse<T> {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
groupLayout?: import('./connectionApi').GroupLayout;
|
||||||
|
appliedView?: { viewKey?: string; displayName?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type for the request function passed to API functions
|
// Type for the request function passed to API functions
|
||||||
|
|
@ -105,8 +107,8 @@ export async function fetchFiles(
|
||||||
if (params.sort) paginationObj.sort = params.sort;
|
if (params.sort) paginationObj.sort = params.sort;
|
||||||
if (params.filters) paginationObj.filters = params.filters;
|
if (params.filters) paginationObj.filters = params.filters;
|
||||||
if (params.search) paginationObj.search = params.search;
|
if (params.search) paginationObj.search = params.search;
|
||||||
if (params.groupId) paginationObj.groupId = params.groupId;
|
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree;
|
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
@ -249,28 +251,13 @@ export async function deleteGroup(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect all file IDs belonging to a group recursively (client-side, from known groupTree) */
|
/** @deprecated Group tree removed — use view-based grouping (viewKey). Returns empty array. */
|
||||||
export function collectGroupItemIds(
|
export function collectGroupItemIds(
|
||||||
groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>,
|
_groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>,
|
||||||
groupId: string
|
_groupId: string
|
||||||
): string[] {
|
): string[] {
|
||||||
const collect = (nodes: Array<{ id: string; itemIds: string[]; subGroups: any[] }>): string[] | null => {
|
const collect = (): string[] | null => null;
|
||||||
for (const node of nodes) {
|
return collect() ?? [];
|
||||||
if (node.id === groupId) {
|
|
||||||
const ids: string[] = [...node.itemIds];
|
|
||||||
const sub = (n: { id: string; itemIds: string[]; subGroups: any[] }) => {
|
|
||||||
ids.push(...n.itemIds);
|
|
||||||
n.subGroups.forEach(sub);
|
|
||||||
};
|
|
||||||
node.subGroups.forEach(sub);
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
const found = collect(node.subGroups);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
return collect(groupTree) ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: The following operations require special handling (FormData, blob responses)
|
// Note: The following operations require special handling (FormData, blob responses)
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,7 @@ export interface PaginationParams {
|
||||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
search?: string;
|
search?: string;
|
||||||
groupId?: string;
|
viewKey?: string;
|
||||||
saveGroupTree?: any[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -86,8 +85,7 @@ export async function fetchMandates(
|
||||||
if (params.sort) paginationObj.sort = params.sort;
|
if (params.sort) paginationObj.sort = params.sort;
|
||||||
if (params.filters) paginationObj.filters = params.filters;
|
if (params.filters) paginationObj.filters = params.filters;
|
||||||
if (params.search) paginationObj.search = params.search;
|
if (params.search) paginationObj.search = params.search;
|
||||||
if (params.groupId) paginationObj.groupId = params.groupId;
|
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree;
|
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,8 @@ export interface PaginationParams {
|
||||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
search?: string;
|
search?: string;
|
||||||
groupId?: string;
|
viewKey?: string;
|
||||||
saveGroupTree?: any[];
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -61,6 +61,8 @@ export interface PaginatedResponse<T> {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
groupLayout?: import('./connectionApi').GroupLayout;
|
||||||
|
appliedView?: { viewKey?: string; displayName?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePromptData {
|
export interface CreatePromptData {
|
||||||
|
|
@ -112,8 +114,8 @@ export async function fetchPrompts(
|
||||||
if (params.sort) paginationObj.sort = params.sort;
|
if (params.sort) paginationObj.sort = params.sort;
|
||||||
if (params.filters) paginationObj.filters = params.filters;
|
if (params.filters) paginationObj.filters = params.filters;
|
||||||
if (params.search) paginationObj.search = params.search;
|
if (params.search) paginationObj.search = params.search;
|
||||||
if (params.groupId) paginationObj.groupId = params.groupId;
|
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree;
|
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,53 @@ export interface MandateSubscription {
|
||||||
snapshotPricePerUserCHF: number;
|
snapshotPricePerUserCHF: number;
|
||||||
snapshotPricePerInstanceCHF: number;
|
snapshotPricePerInstanceCHF: number;
|
||||||
stripeSubscriptionId: string | null;
|
stripeSubscriptionId: string | null;
|
||||||
|
isEnterprise?: boolean;
|
||||||
|
enterpriseFlatPriceCHF?: number | null;
|
||||||
|
enterpriseMaxUsers?: number | null;
|
||||||
|
enterpriseMaxFeatureInstances?: number | null;
|
||||||
|
enterpriseMaxDataVolumeMB?: number | null;
|
||||||
|
enterpriseBudgetAiCHF?: number | null;
|
||||||
|
enterpriseNote?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Enterprise Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface EnterpriseCreateParams {
|
||||||
|
mandateId: string;
|
||||||
|
startDate: number;
|
||||||
|
endDate: number;
|
||||||
|
autoRenew: boolean;
|
||||||
|
flatPriceCHF: number;
|
||||||
|
maxUsers?: number | null;
|
||||||
|
maxFeatureInstances?: number | null;
|
||||||
|
maxDataVolumeMB?: number | null;
|
||||||
|
budgetAiCHF?: number | null;
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnterpriseRenewParams {
|
||||||
|
subscriptionId: string;
|
||||||
|
newEndDate: number;
|
||||||
|
autoRenew?: boolean;
|
||||||
|
flatPriceCHF?: number;
|
||||||
|
maxUsers?: number | null;
|
||||||
|
maxFeatureInstances?: number | null;
|
||||||
|
maxDataVolumeMB?: number | null;
|
||||||
|
budgetAiCHF?: number | null;
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnterpriseUpdateParams {
|
||||||
|
subscriptionId: string;
|
||||||
|
enterpriseFlatPriceCHF?: number;
|
||||||
|
enterpriseMaxUsers?: number | null;
|
||||||
|
enterpriseMaxFeatureInstances?: number | null;
|
||||||
|
enterpriseMaxDataVolumeMB?: number | null;
|
||||||
|
enterpriseBudgetAiCHF?: number | null;
|
||||||
|
enterpriseNote?: string | null;
|
||||||
|
recurring?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionUsage {
|
export interface SubscriptionUsage {
|
||||||
|
|
@ -154,3 +201,40 @@ export async function verifyCheckout(
|
||||||
additionalConfig: _mandateConfig(mandateId),
|
additionalConfig: _mandateConfig(mandateId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Enterprise API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function createEnterprise(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
params: EnterpriseCreateParams,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/subscription/enterprise/create',
|
||||||
|
method: 'post',
|
||||||
|
data: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renewEnterprise(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
params: EnterpriseRenewParams,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/subscription/enterprise/renew',
|
||||||
|
method: 'post',
|
||||||
|
data: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEnterprise(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
params: EnterpriseUpdateParams,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/subscription/enterprise/update',
|
||||||
|
method: 'put',
|
||||||
|
data: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
59
src/api/tableViewApi.ts
Normal file
59
src/api/tableViewApi.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export interface TableListViewRow {
|
||||||
|
id: string;
|
||||||
|
userId?: string;
|
||||||
|
mandateId?: string | null;
|
||||||
|
contextKey: string;
|
||||||
|
viewKey: string;
|
||||||
|
displayName: string;
|
||||||
|
config: TableViewConfig;
|
||||||
|
updatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableViewConfig {
|
||||||
|
schemaVersion?: number;
|
||||||
|
filters?: Record<string, unknown>;
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
|
groupByLevels?: Array<{ field: string; nullLabel?: string }>;
|
||||||
|
/** Section mode (`tableGroupLayoutMode="sections"`): stable keys (`sk`) of collapsed sections. */
|
||||||
|
collapsedSectionKeys?: string[];
|
||||||
|
/** Inline `groupLayout` bands: keys are `band.path.join('///')`. */
|
||||||
|
collapsedGroupKeys?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTableViews(contextKey: string): Promise<TableListViewRow[]> {
|
||||||
|
const { data } = await api.get<TableListViewRow[]>('/api/table-views', {
|
||||||
|
params: { contextKey },
|
||||||
|
});
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTableView(contextKey: string, viewKey: string): Promise<TableListViewRow> {
|
||||||
|
const { data } = await api.get<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewKey)}`, {
|
||||||
|
params: { contextKey },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTableView(payload: {
|
||||||
|
contextKey: string;
|
||||||
|
viewKey: string;
|
||||||
|
displayName: string;
|
||||||
|
config: TableViewConfig;
|
||||||
|
}): Promise<TableListViewRow> {
|
||||||
|
const { data } = await api.post<TableListViewRow>('/api/table-views', payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTableView(
|
||||||
|
viewId: string,
|
||||||
|
updates: { displayName?: string; viewKey?: string; config?: TableViewConfig },
|
||||||
|
): Promise<TableListViewRow> {
|
||||||
|
const { data } = await api.put<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewId)}`, updates);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTableView(viewId: string): Promise<void> {
|
||||||
|
await api.delete(`/api/table-views/${encodeURIComponent(viewId)}`);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ export interface TeamsbotSession {
|
||||||
id: string;
|
id: string;
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
|
moduleId?: string;
|
||||||
meetingLink: string;
|
meetingLink: string;
|
||||||
botName: string;
|
botName: string;
|
||||||
status: 'pending' | 'joining' | 'active' | 'leaving' | 'ended' | 'error';
|
status: 'pending' | 'joining' | 'active' | 'leaving' | 'ended' | 'error';
|
||||||
|
|
@ -574,3 +575,48 @@ export async function deleteDirectorPrompt(
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Meeting Module API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MeetingModule {
|
||||||
|
id: string;
|
||||||
|
instanceId: string;
|
||||||
|
mandateId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
title: string;
|
||||||
|
seriesType: string;
|
||||||
|
defaultBotId?: string;
|
||||||
|
defaultDirectorPrompts?: string;
|
||||||
|
goals?: string;
|
||||||
|
kpiTargets?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listModules(instanceId: string): Promise<MeetingModule[]> {
|
||||||
|
const response = await api.get(`/api/teamsbot/${instanceId}/modules`);
|
||||||
|
return response.data?.modules || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createModule(instanceId: string, body: {
|
||||||
|
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
|
||||||
|
}): Promise<MeetingModule> {
|
||||||
|
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
|
||||||
|
return response.data?.module;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModuleDetail(instanceId: string, moduleId: string): Promise<{ module: MeetingModule; sessions: TeamsbotSession[] }> {
|
||||||
|
const response = await api.get(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateModule(instanceId: string, moduleId: string, body: Partial<MeetingModule>): Promise<MeetingModule> {
|
||||||
|
const response = await api.put(`/api/teamsbot/${instanceId}/modules/${moduleId}`, body);
|
||||||
|
return response.data?.module;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteModule(instanceId: string, moduleId: string): Promise<void> {
|
||||||
|
await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,7 @@ export interface PaginationParams {
|
||||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
search?: string;
|
search?: string;
|
||||||
groupId?: string;
|
viewKey?: string;
|
||||||
saveGroupTree?: any[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -154,8 +153,7 @@ export async function fetchUsers(
|
||||||
if (params.sort) paginationObj.sort = params.sort;
|
if (params.sort) paginationObj.sort = params.sort;
|
||||||
if (params.filters) paginationObj.filters = params.filters;
|
if (params.filters) paginationObj.filters = params.filters;
|
||||||
if (params.search) paginationObj.search = params.search;
|
if (params.search) paginationObj.search = params.search;
|
||||||
if (params.groupId) paginationObj.groupId = params.groupId;
|
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree;
|
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from './FormGeneratorControls.module.css';
|
import styles from './FormGeneratorControls.module.css';
|
||||||
import { Button } from '../../UiComponents/Button';
|
import { Button } from '../../UiComponents/Button';
|
||||||
import { IoIosRefresh } from "react-icons/io";
|
import { IoIosRefresh } from "react-icons/io";
|
||||||
import { FaTrash, FaDownload, FaLayerGroup } from "react-icons/fa";
|
import { FaTrash, FaDownload } from "react-icons/fa";
|
||||||
import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
||||||
|
|
||||||
// Generic field/column config interface
|
// Generic field/column config interface
|
||||||
|
|
@ -77,10 +77,6 @@ export interface FormGeneratorControlsProps {
|
||||||
onSelectAllFiltered?: () => void;
|
onSelectAllFiltered?: () => void;
|
||||||
selectAllFilteredActive?: boolean;
|
selectAllFilteredActive?: boolean;
|
||||||
selectAllFilteredLoading?: boolean;
|
selectAllFilteredLoading?: boolean;
|
||||||
// Grouping
|
|
||||||
groupingEnabled?: boolean;
|
|
||||||
onCreateGroup?: () => void;
|
|
||||||
activeGroupId?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormGeneratorControls({
|
export function FormGeneratorControls({
|
||||||
|
|
@ -114,9 +110,6 @@ export function FormGeneratorControls({
|
||||||
onSelectAllFiltered,
|
onSelectAllFiltered,
|
||||||
selectAllFilteredActive = false,
|
selectAllFilteredActive = false,
|
||||||
selectAllFilteredLoading = false,
|
selectAllFilteredLoading = false,
|
||||||
groupingEnabled = false,
|
|
||||||
onCreateGroup,
|
|
||||||
activeGroupId,
|
|
||||||
}: FormGeneratorControlsProps) {
|
}: FormGeneratorControlsProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
|
@ -186,9 +179,15 @@ export function FormGeneratorControls({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search Controls with Pagination - Hide when items are selected */}
|
{/* Toolbar: optional search + filters badge + CSV + pagination (search is optional) */}
|
||||||
{searchable && selectedCount === 0 && (
|
{selectedCount === 0 &&
|
||||||
|
(searchable ||
|
||||||
|
(pagination && supportsBackendPagination) ||
|
||||||
|
!!onCsvExport ||
|
||||||
|
!!onRefresh ||
|
||||||
|
activeFiltersCount > 0) && (
|
||||||
<div className={styles.searchContainer}>
|
<div className={styles.searchContainer}>
|
||||||
|
{searchable && (
|
||||||
<div className={styles.floatingLabelInput}>
|
<div className={styles.floatingLabelInput}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -203,6 +202,7 @@ export function FormGeneratorControls({
|
||||||
{t('Suchen...')}
|
{t('Suchen...')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{activeFiltersCount > 0 && (
|
{activeFiltersCount > 0 && (
|
||||||
<span className={styles.activeFiltersCount}>
|
<span className={styles.activeFiltersCount}>
|
||||||
{activeFiltersCount} {t('Filter')}
|
{activeFiltersCount} {t('Filter')}
|
||||||
|
|
@ -219,16 +219,6 @@ export function FormGeneratorControls({
|
||||||
{csvExporting ? t('Exportiere...') : 'CSV'}
|
{csvExporting ? t('Exportiere...') : 'CSV'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{groupingEnabled && onCreateGroup && (
|
|
||||||
<button
|
|
||||||
onClick={onCreateGroup}
|
|
||||||
className={styles.refreshButton}
|
|
||||||
title={t('Neue Gruppe erstellen')}
|
|
||||||
style={{ color: activeGroupId ? 'var(--color-primary, #4a6fa5)' : undefined }}
|
|
||||||
>
|
|
||||||
<span className={styles.refreshIcon}><FaLayerGroup /></span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onRefresh && (
|
{onRefresh && (
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Outer table in “sections” mode: fill flex parent (e.g. billing transactions tab) */
|
||||||
|
.formGeneratorTableSectionsRoot {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
@ -79,6 +85,93 @@
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Group sections layout (one table per category) ───────────────────── */
|
||||||
|
.groupSections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
/* Share remaining viewport among expanded groups; scroll when many groups */
|
||||||
|
flex: 1 1 280px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionCollapsed {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 4px 4px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionHeader:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-bg, #fff) 92%, var(--color-border, #e2e8f0) 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionHeaderLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionCaret {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.65;
|
||||||
|
width: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionMeta {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionsLoading {
|
||||||
|
padding: 12px 4px;
|
||||||
|
color: var(--text-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionTableWrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.emptyMessage {
|
.emptyMessage {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|
@ -1237,3 +1330,69 @@ tbody .actionsColumn {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Group bands (server-side view grouping — ClickUp-style) */
|
||||||
|
.groupBandHeaderRow {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
background: color-mix(in srgb, var(--color-bg, #fff) 88%, var(--color-border, #e2e8f0) 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandHeaderCell {
|
||||||
|
padding: 8px 14px !important;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandInner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #0f172a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandCaret {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.65;
|
||||||
|
width: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandPill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: min(420px, 72%);
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.25;
|
||||||
|
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 16%, transparent);
|
||||||
|
color: color-mix(in srgb, var(--color-primary, #2f4364) 95%, #fff);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 32%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandPath {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandPathSep {
|
||||||
|
opacity: 0.45;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandCount {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -352,10 +352,18 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* File size + hover actions group (overlapping layout to save width) */
|
||||||
|
.nodeSizeGroup {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
/* File size column */
|
/* File size column */
|
||||||
.nodeSize {
|
.nodeSize {
|
||||||
width: 52px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--color-text-muted, #94a3b8);
|
color: var(--color-text-muted, #94a3b8);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|
@ -388,20 +396,29 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover action icons (download, delete) -- only visible on hover, left of persistent */
|
/* Hover action icons -- overlay on top of file size to save width */
|
||||||
.nodeActionsHover {
|
.nodeActionsHover {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
flex-shrink: 0;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodeRow:hover .nodeActionsHover {
|
.nodeRow:hover .nodeActionsHover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodeRow:hover .nodeSize {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* Persistent action icons (scope, neutralize) -- always visible, right-aligned */
|
/* Persistent action icons (scope, neutralize) -- always visible, right-aligned */
|
||||||
.nodeActionsPersistent {
|
.nodeActionsPersistent {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -626,6 +643,10 @@
|
||||||
.nodeActionsHover {
|
.nodeActionsHover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodeSize {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Accessibility */
|
/* Accessibility */
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import {
|
||||||
FaChevronRight,
|
FaChevronRight,
|
||||||
FaUnlink,
|
FaUnlink,
|
||||||
FaSyncAlt,
|
FaSyncAlt,
|
||||||
|
FaFolderPlus,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
import type {
|
import type {
|
||||||
TreeNode,
|
TreeNode,
|
||||||
TreeNodeProvider,
|
TreeNodeProvider,
|
||||||
|
|
@ -12,6 +14,8 @@ import type {
|
||||||
ScopeValue,
|
ScopeValue,
|
||||||
TreeBatchAction,
|
TreeBatchAction,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { useConfirm } from '../../../hooks/useConfirm';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from './FormGeneratorTree.module.css';
|
import styles from './FormGeneratorTree.module.css';
|
||||||
|
|
||||||
const INDENT_PX = 24;
|
const INDENT_PX = 24;
|
||||||
|
|
@ -81,6 +85,15 @@ function _flatten<T>(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _resolveNewFolderParentId<T>(selectedIds: Set<string>, nodes: TreeNode<T>[]): string | null {
|
||||||
|
if (selectedIds.size !== 1) return null;
|
||||||
|
const id = [...selectedIds][0];
|
||||||
|
const node = nodes.find((n) => n.id === id);
|
||||||
|
if (!node) return null;
|
||||||
|
if (node.type === 'folder') return node.id;
|
||||||
|
return node.parentId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function _collectDescendantIds<T>(nodeId: string, nodes: TreeNode<T>[]): string[] {
|
function _collectDescendantIds<T>(nodeId: string, nodes: TreeNode<T>[]): string[] {
|
||||||
const childMap = _buildChildMap(nodes);
|
const childMap = _buildChildMap(nodes);
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
|
|
@ -290,43 +303,45 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className={styles.nodeSize}>
|
<div className={styles.nodeSizeGroup}>
|
||||||
{node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''}
|
<span className={styles.nodeSize}>
|
||||||
</span>
|
{node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className={styles.nodeActionsHover}>
|
<div className={styles.nodeActionsHover}>
|
||||||
{canRename && (
|
{canRename && (
|
||||||
<button
|
<button
|
||||||
className={styles.emojiBtn}
|
className={styles.emojiBtn}
|
||||||
onClick={(e) => { e.stopPropagation(); onStartRename(node.id); }}
|
onClick={(e) => { e.stopPropagation(); onStartRename(node.id); }}
|
||||||
title="Umbenennen"
|
title="Umbenennen"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{'\u270F\uFE0F'}
|
{'\u270F\uFE0F'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{node.type !== 'folder' && (
|
{node.type !== 'folder' && (
|
||||||
<button
|
<button
|
||||||
className={styles.emojiBtn}
|
className={styles.emojiBtn}
|
||||||
onClick={(e) => { e.stopPropagation(); onDownload(node); }}
|
onClick={(e) => { e.stopPropagation(); onDownload(node); }}
|
||||||
title="Datei herunterladen"
|
title="Datei herunterladen"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{'\u{1F4E5}'}
|
{'\u{1F4E5}'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<button
|
<button
|
||||||
className={styles.emojiBtn}
|
className={styles.emojiBtn}
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete(node.id); }}
|
onClick={(e) => { e.stopPropagation(); onDelete(node.id); }}
|
||||||
title="Loeschen"
|
title="Loeschen"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{'\u{1F5D1}\uFE0F'}
|
{'\u{1F5D1}\uFE0F'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.nodeActionsPersistent}>
|
<div className={styles.nodeActionsPersistent}>
|
||||||
|
|
@ -390,8 +405,12 @@ export function FormGeneratorTree<T = any>({
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onSendToChat,
|
onSendToChat,
|
||||||
|
allowCreateFolder = true,
|
||||||
className,
|
className,
|
||||||
}: FormGeneratorTreeProps<T>) {
|
}: FormGeneratorTreeProps<T>) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { confirm } = useConfirm();
|
||||||
|
const { prompt, PromptDialog } = usePrompt();
|
||||||
const [nodes, setNodes] = useState<TreeNode<T>[]>([]);
|
const [nodes, setNodes] = useState<TreeNode<T>[]>([]);
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
@ -577,11 +596,41 @@ export function FormGeneratorTree<T = any>({
|
||||||
onRefresh?.();
|
onRefresh?.();
|
||||||
}, [_loadRoot, _updateSelection, onRefresh]);
|
}, [_loadRoot, _updateSelection, onRefresh]);
|
||||||
|
|
||||||
|
const _handleNewFolder = useCallback(async () => {
|
||||||
|
if (ownership !== 'own' || !provider.createChild || !allowCreateFolder) return;
|
||||||
|
const parentId = _resolveNewFolderParentId(selectedIds, nodes);
|
||||||
|
if (provider.canCreate && !provider.canCreate(parentId)) return;
|
||||||
|
const name = await prompt('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||||
|
const trimmed = name?.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
try {
|
||||||
|
const newNode = await provider.createChild(parentId, trimmed);
|
||||||
|
setNodes((prev) => [...prev, newNode]);
|
||||||
|
if (parentId) {
|
||||||
|
setExpandedIds((prev) => new Set(prev).add(parentId));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await _handleRefresh();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
ownership,
|
||||||
|
provider,
|
||||||
|
allowCreateFolder,
|
||||||
|
selectedIds,
|
||||||
|
nodes,
|
||||||
|
prompt,
|
||||||
|
_handleRefresh,
|
||||||
|
]);
|
||||||
|
|
||||||
const _handleDelete = useCallback(
|
const _handleDelete = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const node = nodes.find((n) => n.id === id);
|
const node = nodes.find((n) => n.id === id);
|
||||||
const label = node?.name ?? id;
|
const label = node?.name ?? id;
|
||||||
if (!window.confirm(`"${label}" wirklich loeschen?`)) return;
|
const ok = await confirm(
|
||||||
|
t('"{label}" wirklich loeschen?', { label }),
|
||||||
|
{ confirmLabel: t('Loeschen'), variant: 'danger' },
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
await provider.deleteNodes?.([id]);
|
await provider.deleteNodes?.([id]);
|
||||||
setNodes((prev) => {
|
setNodes((prev) => {
|
||||||
const toRemove = new Set([id, ..._collectDescendantIds(id, prev)]);
|
const toRemove = new Set([id, ..._collectDescendantIds(id, prev)]);
|
||||||
|
|
@ -648,6 +697,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
if (ownership === 'shared') return;
|
if (ownership === 'shared') return;
|
||||||
if (draggingIds.size === 0) return;
|
if (draggingIds.size === 0) return;
|
||||||
if (draggingIds.has(node.id)) return;
|
if (draggingIds.has(node.id)) return;
|
||||||
|
if (node.type !== 'folder') return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
setDragOverId(node.id);
|
setDragOverId(node.id);
|
||||||
|
|
@ -801,6 +851,13 @@ export function FormGeneratorTree<T = any>({
|
||||||
|
|
||||||
const totalNodeCount = nodes.filter((n) => n.parentId === null).length;
|
const totalNodeCount = nodes.filter((n) => n.parentId === null).length;
|
||||||
|
|
||||||
|
const showNewFolderButton =
|
||||||
|
Boolean(title) &&
|
||||||
|
ownership === 'own' &&
|
||||||
|
allowCreateFolder &&
|
||||||
|
Boolean(provider.createChild) &&
|
||||||
|
(provider.canCreate?.(_resolveNewFolderParentId(selectedIds, nodes)) ?? true);
|
||||||
|
|
||||||
const wrapperClasses = [
|
const wrapperClasses = [
|
||||||
styles.formGeneratorTree,
|
styles.formGeneratorTree,
|
||||||
compact && styles.compactMode,
|
compact && styles.compactMode,
|
||||||
|
|
@ -825,6 +882,20 @@ export function FormGeneratorTree<T = any>({
|
||||||
)}
|
)}
|
||||||
<span className={styles.sectionTitle}>{title}</span>
|
<span className={styles.sectionTitle}>{title}</span>
|
||||||
<span className={styles.sectionCount}>{totalNodeCount}</span>
|
<span className={styles.sectionCount}>{totalNodeCount}</span>
|
||||||
|
{showNewFolderButton && (
|
||||||
|
<button
|
||||||
|
className={styles.refreshBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
_handleNewFolder();
|
||||||
|
}}
|
||||||
|
title="Neuer Ordner"
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<FaFolderPlus />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={styles.refreshBtn}
|
className={styles.refreshBtn}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -832,6 +903,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
_handleRefresh();
|
_handleRefresh();
|
||||||
}}
|
}}
|
||||||
title="Aktualisieren"
|
title="Aktualisieren"
|
||||||
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<FaSyncAlt />
|
<FaSyncAlt />
|
||||||
|
|
@ -874,7 +946,11 @@ export function FormGeneratorTree<T = any>({
|
||||||
className={`${styles.batchButton} ${action.danger ? styles.batchButtonDanger : ''}`}
|
className={`${styles.batchButton} ${action.danger ? styles.batchButtonDanger : ''}`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (action.danger) {
|
if (action.danger) {
|
||||||
if (!window.confirm(`${ids.length} ${action.label} wirklich loeschen?`)) return;
|
const ok = await confirm(
|
||||||
|
t('{count} {label} wirklich loeschen?', { count: String(ids.length), label: action.label }),
|
||||||
|
{ confirmLabel: t('Loeschen'), variant: 'danger' },
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
await action.onClick(ids);
|
await action.onClick(ids);
|
||||||
await _handleRefresh();
|
await _handleRefresh();
|
||||||
|
|
@ -941,6 +1017,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<PromptDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,21 @@
|
||||||
// All rights reserved.
|
// All rights reserved.
|
||||||
|
|
||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { FormGeneratorTree } from '../FormGeneratorTree';
|
import { FormGeneratorTree } from '../FormGeneratorTree';
|
||||||
import type { TreeNode, TreeNodeProvider, TreeBatchAction } from '../types';
|
import type { TreeNode, TreeNodeProvider, TreeBatchAction } from '../types';
|
||||||
|
|
||||||
|
const { mockPrompt } = vi.hoisted(() => ({
|
||||||
|
mockPrompt: vi.fn(() => Promise.resolve('NeuOrdner')),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../hooks/usePrompt', () => ({
|
||||||
|
usePrompt: () => ({
|
||||||
|
prompt: mockPrompt,
|
||||||
|
PromptDialog: () => null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fixtures
|
// Fixtures
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -90,6 +100,11 @@ function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
|
||||||
|
|
||||||
describe('FormGeneratorTree', () => {
|
describe('FormGeneratorTree', () => {
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPrompt.mockClear();
|
||||||
|
mockPrompt.mockResolvedValue('NeuOrdner');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders tree with title and node count', async () => {
|
it('renders tree with title and node count', async () => {
|
||||||
const provider = _createMockProvider([_ownFolder]);
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
render(
|
render(
|
||||||
|
|
@ -174,6 +189,85 @@ describe('FormGeneratorTree', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// New folder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('New folder', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPrompt.mockClear();
|
||||||
|
mockPrompt.mockResolvedValue('NeuOrdner');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows header button when titled own tree has createChild', async () => {
|
||||||
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" title="Documents" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByTitle('Neuer Ordner')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show new folder for shared tree', async () => {
|
||||||
|
const provider = _createMockProvider([_sharedFolder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="shared" title="Shared" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByTitle('Neuer Ordner')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls createChild at root when nothing selected', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" title="Docs" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await user.click(screen.getByTitle('Neuer Ordner'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(provider.createChild).toHaveBeenCalledWith(null, 'NeuOrdner');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls createChild under selected folder', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" title="Docs" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
||||||
|
await user.click(screen.getByTitle('Neuer Ordner'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(provider.createChild).toHaveBeenCalledWith('f1', 'NeuOrdner');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides button when allowCreateFolder is false', async () => {
|
||||||
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
render(
|
||||||
|
<FormGeneratorTree
|
||||||
|
provider={provider}
|
||||||
|
ownership="own"
|
||||||
|
title="Docs"
|
||||||
|
allowCreateFolder={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByTitle('Neuer Ordner')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Selection
|
// Selection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -228,8 +322,8 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
fireEvent.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
||||||
await user.click(screen.getByRole('treeitem', { name: /Other Folder/i }), {
|
fireEvent.click(screen.getByRole('treeitem', { name: /Other Folder/i }), {
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -238,7 +332,7 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(lastCall.has('f2')).toBe(true);
|
expect(lastCall.has('f2')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('click on selected folder cascades deselect of descendants (own)', async () => {
|
it('second click on folder with cascaded child selection keeps cascaded selection (own)', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSelectionChange = vi.fn();
|
const onSelectionChange = vi.fn();
|
||||||
const provider = _createMockProvider([_ownFolder, _ownFile]);
|
const provider = _createMockProvider([_ownFolder, _ownFile]);
|
||||||
|
|
@ -270,12 +364,11 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(lastCall.has('f1')).toBe(true);
|
expect(lastCall.has('f1')).toBe(true);
|
||||||
expect(lastCall.has('file1')).toBe(true);
|
expect(lastCall.has('file1')).toBe(true);
|
||||||
|
|
||||||
// Click again to deselect
|
|
||||||
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
||||||
|
|
||||||
lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
|
lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
|
||||||
expect(lastCall.has('f1')).toBe(false);
|
expect(lastCall.has('f1')).toBe(true);
|
||||||
expect(lastCall.has('file1')).toBe(false);
|
expect(lastCall.has('file1')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selection in shared tree does NOT cascade to children', async () => {
|
it('selection in shared tree does NOT cascade to children', async () => {
|
||||||
|
|
@ -455,6 +548,13 @@ describe('FormGeneratorTree', () => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('Delete', () => {
|
describe('Delete', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('delete button calls provider.deleteNodes', async () => {
|
it('delete button calls provider.deleteNodes', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const provider = _createMockProvider([_ownFolder]);
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
|
@ -465,7 +565,7 @@ describe('FormGeneratorTree', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const row = screen.getByRole('treeitem', { name: /My Folder/i });
|
const row = screen.getByRole('treeitem', { name: /My Folder/i });
|
||||||
const deleteBtn = within(row).getByTitle('Delete');
|
const deleteBtn = within(row).getByTitle('Loeschen');
|
||||||
await user.click(deleteBtn);
|
await user.click(deleteBtn);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
@ -482,7 +582,7 @@ describe('FormGeneratorTree', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const row = screen.getByRole('treeitem', { name: /Shared Folder/i });
|
const row = screen.getByRole('treeitem', { name: /Shared Folder/i });
|
||||||
expect(within(row).queryByTitle('Delete')).not.toBeInTheDocument();
|
expect(within(row).queryByTitle('Loeschen')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -541,7 +641,7 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const neutralizeBtn = screen.getByTitle('Not neutralized');
|
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
|
||||||
await user.click(neutralizeBtn);
|
await user.click(neutralizeBtn);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
@ -562,7 +662,7 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
|
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const neutralizeBtn = screen.getByTitle('Not neutralized');
|
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
|
||||||
await user.click(neutralizeBtn);
|
await user.click(neutralizeBtn);
|
||||||
|
|
||||||
expect(provider.patchNeutralize).not.toHaveBeenCalled();
|
expect(provider.patchNeutralize).not.toHaveBeenCalled();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
import { FormGeneratorTree } from '../FormGeneratorTree';
|
import { FormGeneratorTree } from '../FormGeneratorTree';
|
||||||
import type { TreeNode, TreeNodeProvider } from '../types';
|
import type { TreeNode, TreeNodeProvider } from '../types';
|
||||||
|
|
||||||
|
vi.mock('../../../../hooks/usePrompt', () => ({
|
||||||
|
usePrompt: () => ({
|
||||||
|
prompt: vi.fn(() => Promise.resolve('x')),
|
||||||
|
PromptDialog: () => null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fixtures
|
// Fixtures
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
||||||
return nodes;
|
return nodes;
|
||||||
},
|
},
|
||||||
|
|
||||||
canCreate() {
|
canCreate(_parentId: string | null) {
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,5 +60,7 @@ export interface FormGeneratorTreeProps<T = any> {
|
||||||
onSelectionChange?: (selectedIds: Set<string>) => void;
|
onSelectionChange?: (selectedIds: Set<string>) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onSendToChat?: (node: TreeNode<T>) => void;
|
onSendToChat?: (node: TreeNode<T>) => void;
|
||||||
|
/** When false, hides "Neuer Ordner" (e.g. map from table file permissions). Default true. */
|
||||||
|
allowCreateFolder?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,12 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { useConfirm } from '../../../hooks/useConfirm';
|
import { useConfirm } from '../../../hooks/useConfirm';
|
||||||
import styles from './GroupRow.module.css';
|
import styles from './GroupRow.module.css';
|
||||||
import fgTableCss from '../FormGeneratorTable/FormGeneratorTable.module.css';
|
import fgTableCss from '../FormGeneratorTable/FormGeneratorTable.module.css';
|
||||||
import type { TableGroupNode } from '../FormGeneratorTable/FormGeneratorTable';
|
|
||||||
|
/** Legacy folder-tree row model (client-side group tree); kept for GroupFolderRow typings. */
|
||||||
|
export interface TableGroupNode {
|
||||||
|
name: string;
|
||||||
|
itemIds: string[];
|
||||||
|
}
|
||||||
import { FaFolder, FaFolderOpen, FaList, FaPen, FaPlus } from 'react-icons/fa';
|
import { FaFolder, FaFolderOpen, FaList, FaPen, FaPlus } from 'react-icons/fa';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px 14px;
|
||||||
|
padding: 8px 0 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popoverAnchor {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupTrigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border, #cbd5e1);
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text, #0f172a);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupIcon {
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupTrigger:hover {
|
||||||
|
background: var(--bg-hover, rgba(15, 23, 42, 0.04));
|
||||||
|
border-color: var(--color-primary, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupTriggerOpen {
|
||||||
|
border-color: var(--color-primary, #4a6fa5);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary, #4a6fa5) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 4200;
|
||||||
|
min-width: min(360px, calc(100vw - 24px));
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
background: var(--color-bg, #ffffff);
|
||||||
|
color: var(--color-text, #0f172a);
|
||||||
|
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popoverTitle {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popoverHint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 118px 36px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select,
|
||||||
|
.selectOrder {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border, #cbd5e1);
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text, #0f172a);
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:disabled,
|
||||||
|
.selectOrder:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover:not(:disabled) {
|
||||||
|
color: #fecaca;
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:disabled {
|
||||||
|
opacity: 0.25;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addLevelBtn {
|
||||||
|
margin-top: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px dashed var(--color-border, #475569);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addLevelBtn:hover {
|
||||||
|
border-color: var(--color-primary, #4a6fa5);
|
||||||
|
color: var(--color-primary, #7dd3fc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeSummary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewSelect {
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border, #cbd5e1);
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text, #0f172a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnGhost {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border, #cbd5e1);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text, #334155);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnGhost:hover {
|
||||||
|
background: var(--bg-hover, #f1f5f9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnDangerGhost {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
background: transparent;
|
||||||
|
color: #b91c1c;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnDangerGhost:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--color-primary, #4a6fa5);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalBackdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
z-index: 4500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text, #0f172a);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHint {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalField {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalField label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalField input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid var(--color-border, #cbd5e1);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
337
src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx
Normal file
337
src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { FaLayerGroup, FaTrash } from 'react-icons/fa';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import styles from './TableViewsBar.module.css';
|
||||||
|
|
||||||
|
export interface TableViewOption {
|
||||||
|
id: string;
|
||||||
|
viewKey: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One grouping level (ClickUp-style): column + band order for that level. */
|
||||||
|
export interface GroupByLevelSpec {
|
||||||
|
field: string;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableViewsBarProps {
|
||||||
|
views: TableViewOption[];
|
||||||
|
loadingViews: boolean;
|
||||||
|
activeViewKey: string | null;
|
||||||
|
activeViewId: string | null;
|
||||||
|
groupByLevels: GroupByLevelSpec[];
|
||||||
|
onGroupByLevelsChange: (levels: GroupByLevelSpec[]) => void;
|
||||||
|
onSelectView: (viewKey: string | null) => void;
|
||||||
|
columnOptions: Array<{ key: string; label: string }>;
|
||||||
|
onCreateView: (displayName: string, viewKey: string) => void | Promise<void>;
|
||||||
|
/** When a saved view is active, overwrite its config (filters, sort, grouping, folds). Optional. */
|
||||||
|
onSaveActiveView?: () => void | Promise<void>;
|
||||||
|
onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise<void>;
|
||||||
|
onDeleteView?: (viewId: string) => void | Promise<void>;
|
||||||
|
onReloadViews: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^a-z0-9-_]/g, '')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '') || 'view';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupLevelsToApiPayload(levels: GroupByLevelSpec[]) {
|
||||||
|
return levels
|
||||||
|
.filter((l) => l.field)
|
||||||
|
.map((l) => ({ field: l.field, nullLabel: '—', direction: l.direction }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitLevels(
|
||||||
|
next: GroupByLevelSpec[],
|
||||||
|
activeViewId: string | null,
|
||||||
|
onGroupByLevelsChange: (l: GroupByLevelSpec[]) => void,
|
||||||
|
onUpdateViewGrouping: (id: string, l: GroupByLevelSpec[]) => void | Promise<void>,
|
||||||
|
) {
|
||||||
|
onGroupByLevelsChange(next);
|
||||||
|
if (activeViewId) {
|
||||||
|
void Promise.resolve(onUpdateViewGrouping(activeViewId, next));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableViewsBar({
|
||||||
|
views,
|
||||||
|
loadingViews,
|
||||||
|
activeViewKey,
|
||||||
|
activeViewId,
|
||||||
|
groupByLevels,
|
||||||
|
onGroupByLevelsChange,
|
||||||
|
onSelectView,
|
||||||
|
columnOptions,
|
||||||
|
onCreateView,
|
||||||
|
onSaveActiveView,
|
||||||
|
onUpdateViewGrouping,
|
||||||
|
onDeleteView,
|
||||||
|
onReloadViews,
|
||||||
|
}: TableViewsBarProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [saveOpen, setSaveOpen] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupMenuOpen) return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
const el = wrapRef.current;
|
||||||
|
if (el && !el.contains(e.target as Node)) setGroupMenuOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setGroupMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDoc);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDoc);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [groupMenuOpen]);
|
||||||
|
|
||||||
|
const levelsForUi = useMemo(
|
||||||
|
() => (groupByLevels.length > 0 ? groupByLevels : [{ field: '', direction: 'asc' as const }]),
|
||||||
|
[groupByLevels],
|
||||||
|
);
|
||||||
|
|
||||||
|
const usedFields = useMemo(
|
||||||
|
() => new Set(groupByLevels.map((l) => l.field).filter(Boolean)),
|
||||||
|
[groupByLevels],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnsForRow = useCallback(
|
||||||
|
(_rowIdx: number, currentField: string) =>
|
||||||
|
columnOptions.filter((c) => c.key === currentField || !usedFields.has(c.key) || !c.key),
|
||||||
|
[columnOptions, usedFields],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [overwriteSaving, setOverwriteSaving] = useState(false);
|
||||||
|
|
||||||
|
const _onClickSave = useCallback(async () => {
|
||||||
|
if (activeViewId && onSaveActiveView) {
|
||||||
|
setOverwriteSaving(true);
|
||||||
|
try {
|
||||||
|
await onSaveActiveView();
|
||||||
|
await onReloadViews();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Save active view failed', e);
|
||||||
|
} finally {
|
||||||
|
setOverwriteSaving(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaveOpen(true);
|
||||||
|
setNewName('');
|
||||||
|
}, [activeViewId, onSaveActiveView, onReloadViews]);
|
||||||
|
|
||||||
|
const _saveNew = async () => {
|
||||||
|
const name = newName.trim();
|
||||||
|
const slug = slugify(name);
|
||||||
|
if (!name || !slug) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onCreateView(name, slug);
|
||||||
|
setSaveOpen(false);
|
||||||
|
setNewName('');
|
||||||
|
await onReloadViews();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLevel = (idx: number, patch: Partial<GroupByLevelSpec>) => {
|
||||||
|
const working = levelsForUi.map((l, i) => (i === idx ? { ...l, ...patch } : l));
|
||||||
|
const normalized = working.filter((l) => l.field);
|
||||||
|
commitLevels(normalized, activeViewId, onGroupByLevelsChange, onUpdateViewGrouping);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addLevelRow = () => {
|
||||||
|
commitLevels(
|
||||||
|
[...groupByLevels, { field: '', direction: 'asc' }],
|
||||||
|
activeViewId,
|
||||||
|
onGroupByLevelsChange,
|
||||||
|
onUpdateViewGrouping,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLevel = (idx: number) => {
|
||||||
|
const working = levelsForUi.filter((_, i) => i !== idx);
|
||||||
|
const normalized = working.filter((l) => l.field);
|
||||||
|
commitLevels(normalized, activeViewId, onGroupByLevelsChange, onUpdateViewGrouping);
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
groupByLevels.length === 0
|
||||||
|
? t('Keine')
|
||||||
|
: groupByLevels
|
||||||
|
.filter((l) => l.field)
|
||||||
|
.map((l) => columnOptions.find((c) => c.key === l.field)?.label ?? l.field)
|
||||||
|
.join(' › ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<div ref={wrapRef} className={styles.popoverAnchor}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.groupTrigger} ${groupMenuOpen ? styles.groupTriggerOpen : ''}`}
|
||||||
|
onClick={() => setGroupMenuOpen((o) => !o)}
|
||||||
|
aria-expanded={groupMenuOpen}
|
||||||
|
aria-label={t('Gruppieren')}
|
||||||
|
title={t('Gruppieren')}
|
||||||
|
>
|
||||||
|
<FaLayerGroup className={styles.groupIcon} aria-hidden />
|
||||||
|
</button>
|
||||||
|
{groupMenuOpen && (
|
||||||
|
<div className={styles.popover} role="dialog" aria-label={t('Gruppieren nach')}>
|
||||||
|
<div className={styles.popoverTitle}>{t('Gruppieren nach')}</div>
|
||||||
|
<p className={styles.popoverHint}>{t('Wählen Sie eine Spalte und die Reihenfolge der Gruppen.')}</p>
|
||||||
|
<div className={styles.levelList}>
|
||||||
|
{levelsForUi.map((level, idx) => (
|
||||||
|
<div key={idx} className={styles.levelRow}>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
aria-label={t('Spalte')}
|
||||||
|
value={level.field}
|
||||||
|
onChange={(e) => updateLevel(idx, { field: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">{t('Spalte wählen')}</option>
|
||||||
|
{columnsForRow(idx, level.field).map((c) => (
|
||||||
|
<option key={c.key} value={c.key}>
|
||||||
|
{c.label || c.key}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className={styles.selectOrder}
|
||||||
|
aria-label={t('Sortierung')}
|
||||||
|
value={level.direction}
|
||||||
|
disabled={!level.field}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLevel(idx, { direction: e.target.value === 'desc' ? 'desc' : 'asc' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="asc">{t('Aufsteigend')}</option>
|
||||||
|
<option value="desc">{t('Absteigend')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
title={t('Ebene entfernen')}
|
||||||
|
aria-label={t('Ebene entfernen')}
|
||||||
|
disabled={levelsForUi.length <= 1 && !level.field}
|
||||||
|
onClick={() => removeLevel(idx)}
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" className={styles.addLevelBtn} onClick={addLevelRow}>
|
||||||
|
{t('+ Weitere Ebene')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.activeSummary} title={summary}>
|
||||||
|
{groupByLevels.filter((l) => l.field).length === 0
|
||||||
|
? t('Nicht gruppiert')
|
||||||
|
: `${t('Aktiv')}: ${summary}`}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className={styles.viewBlock}>
|
||||||
|
<span className={styles.viewLabel}>{t('Ansicht')}</span>
|
||||||
|
<select
|
||||||
|
className={styles.viewSelect}
|
||||||
|
value={activeViewKey ?? ''}
|
||||||
|
disabled={loadingViews}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
onSelectView(v === '' ? null : v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t('Standard')}</option>
|
||||||
|
{views.map((v) => (
|
||||||
|
<option key={v.id} value={v.viewKey}>
|
||||||
|
{v.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.btnGhost}
|
||||||
|
disabled={loadingViews || overwriteSaving}
|
||||||
|
title={
|
||||||
|
activeViewId
|
||||||
|
? t('Aktuelle Ansicht mit Filter, Sortierung und Gruppierung überschreiben')
|
||||||
|
: t('Neue Ansicht speichern')
|
||||||
|
}
|
||||||
|
onClick={() => void _onClickSave()}
|
||||||
|
>
|
||||||
|
{overwriteSaving ? t('Wird gespeichert…') : t('Speichern…')}
|
||||||
|
</button>
|
||||||
|
{activeViewId && onDeleteView && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.btnDangerGhost}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t('Diese Ansicht wirklich löschen?'))) {
|
||||||
|
void Promise.resolve(onDeleteView(activeViewId)).then(() => onReloadViews());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Löschen')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveOpen && (
|
||||||
|
<div
|
||||||
|
className={styles.modalBackdrop}
|
||||||
|
role="presentation"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setSaveOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.modal} role="dialog" aria-labelledby="new-view-title" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 id="new-view-title">{t('Neue Ansicht')}</h3>
|
||||||
|
<p className={styles.modalHint}>{t('Übernimmt Filter, Sortierung und Gruppierung.')}</p>
|
||||||
|
<div className={styles.modalField}>
|
||||||
|
<label htmlFor="nv-name">{t('Anzeigename')}</label>
|
||||||
|
<input
|
||||||
|
id="nv-name"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder={t('z. B. Nach Status')}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button type="button" className={styles.btnGhost} onClick={() => setSaveOpen(false)}>
|
||||||
|
{t('Abbrechen')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.btnPrimary}
|
||||||
|
disabled={saving || !newName.trim()}
|
||||||
|
onClick={() => void _saveNew()}
|
||||||
|
>
|
||||||
|
{saving ? t('Speichern…') : t('Erstellen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/FormGenerator/TableViewsBar/index.ts
Normal file
1
src/components/FormGenerator/TableViewsBar/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { TableViewsBar, groupLevelsToApiPayload, type TableViewsBarProps, type TableViewOption, type GroupByLevelSpec } from './TableViewsBar';
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useRef, useMemo, useState } from 'react';
|
import React, { useCallback, useRef, useMemo, useState, useEffect } from 'react';
|
||||||
import type { UdbContext } from './UnifiedDataBar';
|
import type { UdbContext } from './UnifiedDataBar';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
|
|
@ -45,6 +45,12 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
setSharedTreeKey(k => k + 1);
|
setSharedTreeKey(k => k + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const _onFileUploaded = () => _handleRefresh();
|
||||||
|
window.addEventListener('fileUploaded', _onFileUploaded);
|
||||||
|
return () => window.removeEventListener('fileUploaded', _onFileUploaded);
|
||||||
|
}, [_handleRefresh]);
|
||||||
|
|
||||||
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
||||||
if (!context.instanceId || uploading) return;
|
if (!context.instanceId || uploading) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
|
@ -76,7 +82,9 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
const _handleDragLeave = useCallback((e: React.DragEvent) => {
|
const _handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDragOver(false);
|
if (!e.relatedTarget || !(e.currentTarget as Node).contains(e.relatedTarget as Node)) {
|
||||||
|
setIsDragOver(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const _handleDrop = useCallback((e: React.DragEvent) => {
|
const _handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ interface UnifiedDataBarProps {
|
||||||
|
|
||||||
function _tabLabel(tab: UdbTab, t: (k: string) => string): string {
|
function _tabLabel(tab: UdbTab, t: (k: string) => string): string {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case 'chats': return t('Chatverläufe');
|
case 'chats': return t('Dossiers');
|
||||||
case 'files': return t('Dateien');
|
case 'files': return t('Dateien');
|
||||||
case 'sources': return t('Quellen');
|
case 'sources': return t('Quellen');
|
||||||
default: return tab;
|
default: return tab;
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
// Feature pages - CommCoach
|
// Feature pages - CommCoach
|
||||||
'page.feature.commcoach.dashboard': <FaChartLine />,
|
'page.feature.commcoach.dashboard': <FaChartLine />,
|
||||||
'page.feature.commcoach.coaching': <FaComments />,
|
'page.feature.commcoach.coaching': <FaComments />,
|
||||||
'page.feature.commcoach.dossier': <FaClipboardList />,
|
|
||||||
'page.feature.commcoach.settings': <FaCog />,
|
'page.feature.commcoach.settings': <FaCog />,
|
||||||
|
|
||||||
// Feature icons (for feature grouping in navigation)
|
// Feature icons (for feature grouping in navigation)
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useMsal } from '@azure/msal-react';
|
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useApiRequest } from './useApi';
|
|
||||||
import { getApiBaseUrl } from '../../config/config';
|
import { getApiBaseUrl } from '../../config/config';
|
||||||
import { setUserDataCache, clearUserDataCache, type CachedUserData } from '../utils/userCache';
|
import { setUserDataCache, clearUserDataCache, type CachedUserData } from '../utils/userCache';
|
||||||
import {
|
import {
|
||||||
loginApi,
|
loginApi,
|
||||||
fetchCurrentUserApi,
|
fetchCurrentUserApi,
|
||||||
registerApi,
|
registerApi,
|
||||||
registerWithMsalApi,
|
|
||||||
checkUsernameAvailabilityApi,
|
checkUsernameAvailabilityApi,
|
||||||
logoutApi,
|
logoutApi,
|
||||||
requestPasswordResetApi,
|
requestPasswordResetApi,
|
||||||
|
|
@ -18,7 +15,6 @@ import {
|
||||||
type RegisterResponse,
|
type RegisterResponse,
|
||||||
type UsernameAvailabilityResponse,
|
type UsernameAvailabilityResponse,
|
||||||
type RegisterData,
|
type RegisterData,
|
||||||
type MsalRegisterData,
|
|
||||||
type PasswordResetRequestResponse,
|
type PasswordResetRequestResponse,
|
||||||
type PasswordResetResponse
|
type PasswordResetResponse
|
||||||
} from '../api/authApi';
|
} from '../api/authApi';
|
||||||
|
|
@ -408,48 +404,6 @@ export function useGoogleAuth() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Microsoft Registration
|
|
||||||
export function useMsalRegister() {
|
|
||||||
const { instance, accounts } = useMsal();
|
|
||||||
const { request, isLoading, error } = useApiRequest<MsalRegisterData, any>();
|
|
||||||
|
|
||||||
const registerWithMsal = async (): Promise<RegisterResponse> => {
|
|
||||||
try {
|
|
||||||
if (!accounts || accounts.length === 0) {
|
|
||||||
// If not signed in with Microsoft, sign in first
|
|
||||||
await instance.loginPopup({
|
|
||||||
scopes: ['user.read']
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the current account
|
|
||||||
const currentAccount = instance.getAllAccounts()[0];
|
|
||||||
if (!currentAccount) {
|
|
||||||
throw new Error('No Microsoft account found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare user data from Microsoft account
|
|
||||||
const userData: MsalRegisterData = {
|
|
||||||
username: currentAccount.username,
|
|
||||||
email: currentAccount.username,
|
|
||||||
fullName: currentAccount.name || currentAccount.username,
|
|
||||||
language: 'de'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Register the user through our backend
|
|
||||||
return await registerWithMsalApi(request, userData);
|
|
||||||
} catch (error: any) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
registerWithMsal,
|
|
||||||
error,
|
|
||||||
isLoading
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Username availability check
|
// Username availability check
|
||||||
export function useUsernameAvailability() {
|
export function useUsernameAvailability() {
|
||||||
const [isChecking, setIsChecking] = useState(false);
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
|
@ -568,145 +522,32 @@ export function useLogout() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const _clearLocalState = () => {
|
||||||
|
clearUserDataCache();
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
sessionStorage.clear();
|
||||||
|
document.cookie.split(";").forEach((c) => {
|
||||||
|
const name = c.split("=")[0].trim();
|
||||||
|
if (name) {
|
||||||
|
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const logout = async (): Promise<void> => {
|
const logout = async (): Promise<void> => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Call logout endpoint to clear JWT tokens on server
|
|
||||||
await logoutApi();
|
await logoutApi();
|
||||||
|
// Give browser time to process Set-Cookie headers from logout response
|
||||||
|
|
||||||
|
|
||||||
// CRITICAL: Wait for browser to process Set-Cookie headers from logout response
|
|
||||||
// This gives the browser time to clear httpOnly cookies before redirect
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
} catch (err: any) {
|
||||||
// Clear user data cache from sessionStorage
|
setError(err.response?.data?.detail || 'Logout failed');
|
||||||
clearUserDataCache();
|
|
||||||
|
|
||||||
// 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 => {
|
|
||||||
|
|
||||||
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);
|
|
||||||
if (key && key.startsWith('msal.')) {
|
|
||||||
msalKeysToRemove.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msalKeysToRemove.forEach(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
|
|
||||||
|
|
||||||
|
|
||||||
const cookies = document.cookie.split(";");
|
|
||||||
|
|
||||||
|
|
||||||
cookies.forEach(function(c) {
|
|
||||||
const cookieName = c.split("=")[0].trim();
|
|
||||||
|
|
||||||
|
|
||||||
if (cookieName === 'auth_token' || cookieName === 'refresh_token' || cookieName.includes('token') || cookieName.includes('msal')) {
|
|
||||||
|
|
||||||
document.cookie = cookieName + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Redirect to login page
|
|
||||||
window.location.href = '/login?logout=true';
|
|
||||||
} catch (error: any) {
|
|
||||||
let errorMessage = 'Logout failed';
|
|
||||||
|
|
||||||
if (error.response) {
|
|
||||||
errorMessage = error.response.data?.detail || errorMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(errorMessage);
|
|
||||||
|
|
||||||
// Even if logout fails on server, clear local data and redirect
|
|
||||||
clearUserDataCache();
|
|
||||||
sessionStorage.removeItem('auth_authority');
|
|
||||||
|
|
||||||
// Clear MSAL cache tokens from localStorage
|
|
||||||
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 => {
|
|
||||||
|
|
||||||
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);
|
|
||||||
if (key && key.startsWith('msal.')) {
|
|
||||||
msalKeysToRemove.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msalKeysToRemove.forEach(key => {
|
|
||||||
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear sessionStorage as well
|
|
||||||
sessionStorage.clear();
|
|
||||||
|
|
||||||
// Clear cookies as backup (in case backend doesn't clear them properly)
|
|
||||||
document.cookie.split(";").forEach(function(c) {
|
|
||||||
const cookieName = c.split("=")[0].trim();
|
|
||||||
if (cookieName === 'auth_token' || cookieName === 'refresh_token' || cookieName.includes('token') || cookieName.includes('msal')) {
|
|
||||||
|
|
||||||
document.cookie = cookieName + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.location.href = '/login?logout=true';
|
|
||||||
} finally {
|
} finally {
|
||||||
|
_clearLocalState();
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
window.location.href = '/login?logout=true';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
fetchBalances,
|
fetchBalances,
|
||||||
fetchBalanceForMandate,
|
fetchBalanceForMandate,
|
||||||
fetchTransactions,
|
fetchTransactions,
|
||||||
|
fetchTransactionsPaginated,
|
||||||
fetchStatistics,
|
fetchStatistics,
|
||||||
fetchAllowedProviders,
|
fetchAllowedProviders,
|
||||||
fetchSettingsAdmin,
|
fetchSettingsAdmin,
|
||||||
|
|
@ -31,7 +32,9 @@ import {
|
||||||
type MandateUserSummary,
|
type MandateUserSummary,
|
||||||
type StatisticsRangeRequest,
|
type StatisticsRangeRequest,
|
||||||
type BillingBucketSize,
|
type BillingBucketSize,
|
||||||
|
type BillingTransactionsPaginationParams,
|
||||||
} from '../api/billingApi';
|
} from '../api/billingApi';
|
||||||
|
import type { GroupLayout } from '../api/connectionApi';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type {
|
export type {
|
||||||
|
|
@ -47,7 +50,7 @@ export type {
|
||||||
BillingBucketSize,
|
BillingBucketSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { TransactionType, ReferenceType } from '../api/billingApi';
|
export type { TransactionType, ReferenceType, BillingTransactionsPaginationParams } from '../api/billingApi';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for user billing operations
|
* Hook for user billing operations
|
||||||
|
|
@ -55,6 +58,17 @@ export type { TransactionType, ReferenceType } from '../api/billingApi';
|
||||||
export function useBilling() {
|
export function useBilling() {
|
||||||
const [balances, setBalances] = useState<BillingBalance[]>([]);
|
const [balances, setBalances] = useState<BillingBalance[]>([]);
|
||||||
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
|
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
|
||||||
|
const [transactionsPagination, setTransactionsPagination] = useState<{
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [transactionsGroupLayout, setTransactionsGroupLayout] = useState<GroupLayout | null>(null);
|
||||||
|
const [transactionsAppliedView, setTransactionsAppliedView] = useState<{
|
||||||
|
viewKey?: string;
|
||||||
|
displayName?: string;
|
||||||
|
} | null>(null);
|
||||||
const [statistics, setStatistics] = useState<UsageReport | null>(null);
|
const [statistics, setStatistics] = useState<UsageReport | null>(null);
|
||||||
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
|
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
|
||||||
const { request, isLoading: loading, error } = useApiRequest();
|
const { request, isLoading: loading, error } = useApiRequest();
|
||||||
|
|
@ -87,14 +101,38 @@ export function useBilling() {
|
||||||
try {
|
try {
|
||||||
const data = await fetchTransactions(request, limit, offset);
|
const data = await fetchTransactions(request, limit, offset);
|
||||||
setTransactions(Array.isArray(data) ? data : []);
|
setTransactions(Array.isArray(data) ? data : []);
|
||||||
|
setTransactionsPagination(null);
|
||||||
|
setTransactionsGroupLayout(null);
|
||||||
|
setTransactionsAppliedView(null);
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading transactions:', err);
|
console.error('Error loading transactions:', err);
|
||||||
setTransactions([]);
|
setTransactions([]);
|
||||||
|
setTransactionsPagination(null);
|
||||||
|
setTransactionsGroupLayout(null);
|
||||||
|
setTransactionsAppliedView(null);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
|
const refetchTransactions = useCallback(async (params?: BillingTransactionsPaginationParams) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchTransactionsPaginated(request, params);
|
||||||
|
setTransactions(Array.isArray(data.items) ? data.items : []);
|
||||||
|
setTransactionsPagination(data.pagination ?? null);
|
||||||
|
setTransactionsGroupLayout(data.groupLayout ?? null);
|
||||||
|
setTransactionsAppliedView(data.appliedView ?? null);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading transactions:', err);
|
||||||
|
setTransactions([]);
|
||||||
|
setTransactionsPagination(null);
|
||||||
|
setTransactionsGroupLayout(null);
|
||||||
|
setTransactionsAppliedView(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => {
|
const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchStatistics(request, range);
|
const data = await fetchStatistics(request, range);
|
||||||
|
|
@ -129,6 +167,9 @@ export function useBilling() {
|
||||||
return {
|
return {
|
||||||
balances,
|
balances,
|
||||||
transactions,
|
transactions,
|
||||||
|
transactionsPagination,
|
||||||
|
transactionsGroupLayout,
|
||||||
|
transactionsAppliedView,
|
||||||
statistics,
|
statistics,
|
||||||
allowedProviders,
|
allowedProviders,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -136,6 +177,7 @@ export function useBilling() {
|
||||||
loadBalances,
|
loadBalances,
|
||||||
loadBalanceForMandate,
|
loadBalanceForMandate,
|
||||||
loadTransactions,
|
loadTransactions,
|
||||||
|
refetchTransactions,
|
||||||
loadStatistics,
|
loadStatistics,
|
||||||
loadAllowedProviders,
|
loadAllowedProviders,
|
||||||
refetch: loadBalances,
|
refetch: loadBalances,
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,16 @@ export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn {
|
||||||
setTasks(prev => [eventData, ...prev]);
|
setTasks(prev => [eventData, ...prev]);
|
||||||
} else if (eventType === 'documentCreated' && eventData) {
|
} else if (eventType === 'documentCreated' && eventData) {
|
||||||
onDocumentCreatedRef.current?.(eventData);
|
onDocumentCreatedRef.current?.(eventData);
|
||||||
|
const docMsg: CoachingMessage = {
|
||||||
|
id: `doc-${Date.now()}`,
|
||||||
|
sessionId: session?.id ?? '',
|
||||||
|
contextId: session?.contextId ?? '',
|
||||||
|
role: 'assistant',
|
||||||
|
content: `📄 **Dokument erstellt:** ${eventData.fileName || 'Dokument'}\n\n_Das Dokument ist in der Seitenleiste unter "Dateien" verfuegbar._`,
|
||||||
|
contentType: 'systemNote',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages(prev => [...prev, docMsg]);
|
||||||
} else if (eventType === 'error' && eventData) {
|
} else if (eventType === 'error' && eventData) {
|
||||||
setError(eventData.message || 'Stream-Fehler');
|
setError(eventData.message || 'Stream-Fehler');
|
||||||
}
|
}
|
||||||
|
|
@ -397,6 +407,16 @@ export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn {
|
||||||
setTasks(prev => [eventData, ...prev]);
|
setTasks(prev => [eventData, ...prev]);
|
||||||
} else if (eventType === 'documentCreated' && eventData) {
|
} else if (eventType === 'documentCreated' && eventData) {
|
||||||
onDocumentCreatedRef.current?.(eventData);
|
onDocumentCreatedRef.current?.(eventData);
|
||||||
|
const docMsg: CoachingMessage = {
|
||||||
|
id: `doc-${Date.now()}`,
|
||||||
|
sessionId: session.id,
|
||||||
|
contextId: session.contextId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: `📄 **Dokument erstellt:** ${eventData.fileName || 'Dokument'}\n\n_Das Dokument ist in der Seitenleiste unter "Dateien" verfuegbar._`,
|
||||||
|
contentType: 'systemNote',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages(prev => [...prev, docMsg]);
|
||||||
} else if (eventType === 'scoreUpdate') {
|
} else if (eventType === 'scoreUpdate') {
|
||||||
// Will refresh on complete
|
// Will refresh on complete
|
||||||
} else if (eventType === 'error' && eventData) {
|
} else if (eventType === 'error' && eventData) {
|
||||||
|
|
@ -474,6 +494,16 @@ export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn {
|
||||||
setTasks(prev => [eventData, ...prev]);
|
setTasks(prev => [eventData, ...prev]);
|
||||||
} else if (eventType === 'documentCreated' && eventData) {
|
} else if (eventType === 'documentCreated' && eventData) {
|
||||||
onDocumentCreatedRef.current?.(eventData);
|
onDocumentCreatedRef.current?.(eventData);
|
||||||
|
const docMsg: CoachingMessage = {
|
||||||
|
id: `doc-${Date.now()}`,
|
||||||
|
sessionId: session.id,
|
||||||
|
contextId: session.contextId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: `📄 **Dokument erstellt:** ${eventData.fileName || 'Dokument'}\n\n_Das Dokument ist in der Seitenleiste unter "Dateien" verfuegbar._`,
|
||||||
|
contentType: 'systemNote',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages(prev => [...prev, docMsg]);
|
||||||
} else if (eventType === 'error' && eventData) {
|
} else if (eventType === 'error' && eventData) {
|
||||||
setError(eventData.message || 'Audio-Fehler');
|
setError(eventData.message || 'Audio-Fehler');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,13 @@ import {
|
||||||
type AttributeDefinition,
|
type AttributeDefinition,
|
||||||
type PaginationParams,
|
type PaginationParams,
|
||||||
type CreateConnectionData,
|
type CreateConnectionData,
|
||||||
type ConnectResponse
|
type ConnectResponse,
|
||||||
|
type PaginatedResponse,
|
||||||
|
type GroupLayout,
|
||||||
} from '../api/connectionApi';
|
} from '../api/connectionApi';
|
||||||
|
|
||||||
// Re-export types for backward compatibility
|
// Re-export types for backward compatibility
|
||||||
export type { Connection, AttributeDefinition, PaginationParams, CreateConnectionData, ConnectResponse };
|
export type { Connection, AttributeDefinition, PaginationParams, CreateConnectionData, ConnectResponse };
|
||||||
export type { TableGroupNode } from '../api/connectionApi';
|
|
||||||
|
|
||||||
// Hook for managing connections
|
// Hook for managing connections
|
||||||
export function useConnections() {
|
export function useConnections() {
|
||||||
|
|
@ -35,7 +36,8 @@ export function useConnections() {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [groupTree, setGroupTree] = useState<import('../api/connectionApi').TableGroupNode[]>([]);
|
const [groupLayout, setGroupLayout] = useState<GroupLayout | null>(null);
|
||||||
|
const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null);
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
const [connectError, setConnectError] = useState<string | null>(null);
|
const [connectError, setConnectError] = useState<string | null>(null);
|
||||||
const { request, isLoading, error } = useApiRequest<any, any>();
|
const { request, isLoading, error } = useApiRequest<any, any>();
|
||||||
|
|
@ -91,6 +93,69 @@ export function useConnections() {
|
||||||
}
|
}
|
||||||
}, [checkPermission]);
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchGroupSectionSummaries = useCallback(
|
||||||
|
async (base: {
|
||||||
|
search?: string;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
sort?: Array<{ field: string; direction: string }>;
|
||||||
|
viewKey?: string | null;
|
||||||
|
groupField: string;
|
||||||
|
groupDirection?: 'asc' | 'desc';
|
||||||
|
}) => {
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 25,
|
||||||
|
groupByLevels: [
|
||||||
|
{
|
||||||
|
field: base.groupField,
|
||||||
|
nullLabel: '—',
|
||||||
|
direction: base.groupDirection || 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (base.search) (pObj as { search?: string }).search = base.search;
|
||||||
|
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||||||
|
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
||||||
|
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
||||||
|
const { data } = await api.get('/api/connections/', {
|
||||||
|
params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
|
||||||
|
});
|
||||||
|
return Array.isArray(data?.groups) ? data.groups : [];
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchForSection = useCallback(
|
||||||
|
async (
|
||||||
|
paginationParams: any,
|
||||||
|
sectionFilter: Record<string, unknown>,
|
||||||
|
parentColumnFilters?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
const mergedFilters = {
|
||||||
|
...(parentColumnFilters || {}),
|
||||||
|
...(paginationParams.filters || {}),
|
||||||
|
...sectionFilter,
|
||||||
|
};
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: paginationParams.page,
|
||||||
|
pageSize: paginationParams.pageSize,
|
||||||
|
filters: mergedFilters,
|
||||||
|
groupByLevels: [],
|
||||||
|
};
|
||||||
|
if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort;
|
||||||
|
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
||||||
|
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
||||||
|
const { data } = await api.get('/api/connections/', {
|
||||||
|
params: { pagination: JSON.stringify(pObj) },
|
||||||
|
});
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
return { items: data.items, pagination: data.pagination };
|
||||||
|
}
|
||||||
|
return { items: [], pagination: null };
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch connections with pagination support
|
// Fetch connections with pagination support
|
||||||
const fetchConnections = useCallback(async (params?: PaginationParams): Promise<Connection[]> => {
|
const fetchConnections = useCallback(async (params?: PaginationParams): Promise<Connection[]> => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -103,14 +168,15 @@ export function useConnections() {
|
||||||
if (data.pagination) {
|
if (data.pagination) {
|
||||||
setPagination(data.pagination);
|
setPagination(data.pagination);
|
||||||
}
|
}
|
||||||
if (Array.isArray(data.groupTree)) {
|
setGroupLayout((data as PaginatedResponse<Connection>).groupLayout ?? null);
|
||||||
setGroupTree(data.groupTree);
|
setAppliedView((data as PaginatedResponse<Connection>).appliedView ?? null);
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Handle non-paginated response (backward compatibility)
|
// Handle non-paginated response (backward compatibility)
|
||||||
const items = Array.isArray(data) ? data : [];
|
const items = Array.isArray(data) ? data : [];
|
||||||
setConnections(items);
|
setConnections(items);
|
||||||
setPagination(null);
|
setPagination(null);
|
||||||
|
setGroupLayout(null);
|
||||||
|
setAppliedView(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.isArray(data) ? data : (data?.items || []);
|
return Array.isArray(data) ? data : (data?.items || []);
|
||||||
|
|
@ -118,6 +184,8 @@ export function useConnections() {
|
||||||
console.error('Error fetching connections:', error);
|
console.error('Error fetching connections:', error);
|
||||||
setConnections([]);
|
setConnections([]);
|
||||||
setPagination(null);
|
setPagination(null);
|
||||||
|
setGroupLayout(null);
|
||||||
|
setAppliedView(null);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
@ -824,6 +892,8 @@ export function useConnections() {
|
||||||
attributes,
|
attributes,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
generateEditFieldsFromAttributes,
|
generateEditFieldsFromAttributes,
|
||||||
ensureAttributesLoaded,
|
ensureAttributesLoaded,
|
||||||
fetchAttributes,
|
fetchAttributes,
|
||||||
|
|
@ -832,7 +902,8 @@ export function useConnections() {
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
fetchConnectionById,
|
fetchConnectionById,
|
||||||
groupTree,
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import {
|
||||||
moveFiles as moveFilesApi,
|
moveFiles as moveFilesApi,
|
||||||
type FolderInfo,
|
type FolderInfo,
|
||||||
} from '../api/fileApi';
|
} from '../api/fileApi';
|
||||||
import type { TableGroupNode } from '../api/connectionApi';
|
|
||||||
|
|
||||||
export interface FilePreviewResult {
|
export interface FilePreviewResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -69,6 +68,7 @@ export interface PaginationParams {
|
||||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
viewKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files list hook
|
// Files list hook
|
||||||
|
|
@ -82,7 +82,8 @@ export function useUserFiles() {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [groupTree, setGroupTree] = useState<TableGroupNode[]>([]);
|
const [groupLayout, setGroupLayout] = useState<import('../api/connectionApi').GroupLayout | null>(null);
|
||||||
|
const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null);
|
||||||
const { request, isLoading: loading, error } = useApiRequest<null, UserFile[]>();
|
const { request, isLoading: loading, error } = useApiRequest<null, UserFile[]>();
|
||||||
const { checkPermission } = usePermissions();
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
|
@ -140,6 +141,69 @@ export function useUserFiles() {
|
||||||
}
|
}
|
||||||
}, [checkPermission]);
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchGroupSectionSummaries = useCallback(
|
||||||
|
async (base: {
|
||||||
|
search?: string;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
sort?: Array<{ field: string; direction: string }>;
|
||||||
|
viewKey?: string | null;
|
||||||
|
groupField: string;
|
||||||
|
groupDirection?: 'asc' | 'desc';
|
||||||
|
}) => {
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 25,
|
||||||
|
groupByLevels: [
|
||||||
|
{
|
||||||
|
field: base.groupField,
|
||||||
|
nullLabel: '—',
|
||||||
|
direction: base.groupDirection || 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (base.search) (pObj as { search?: string }).search = base.search;
|
||||||
|
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||||||
|
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
||||||
|
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
||||||
|
const { data } = await api.get('/api/files/list', {
|
||||||
|
params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
|
||||||
|
});
|
||||||
|
return Array.isArray(data?.groups) ? data.groups : [];
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchForSection = useCallback(
|
||||||
|
async (
|
||||||
|
paginationParams: PaginationParams & { page: number; pageSize: number },
|
||||||
|
sectionFilter: Record<string, unknown>,
|
||||||
|
parentColumnFilters?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
const mergedFilters = {
|
||||||
|
...(parentColumnFilters || {}),
|
||||||
|
...(paginationParams.filters || {}),
|
||||||
|
...sectionFilter,
|
||||||
|
};
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: paginationParams.page,
|
||||||
|
pageSize: paginationParams.pageSize,
|
||||||
|
filters: mergedFilters,
|
||||||
|
groupByLevels: [],
|
||||||
|
};
|
||||||
|
if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort;
|
||||||
|
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
||||||
|
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
||||||
|
const { data } = await api.get('/api/files/list', {
|
||||||
|
params: { pagination: JSON.stringify(pObj) },
|
||||||
|
});
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
return { items: data.items, pagination: data.pagination };
|
||||||
|
}
|
||||||
|
return { items: [], pagination: null };
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const fetchFiles = useCallback(async (params?: PaginationParams) => {
|
const fetchFiles = useCallback(async (params?: PaginationParams) => {
|
||||||
// Check if user is authenticated before fetching files
|
// Check if user is authenticated before fetching files
|
||||||
const cachedUser = getUserDataCache();
|
const cachedUser = getUserDataCache();
|
||||||
|
|
@ -182,28 +246,20 @@ export function useUserFiles() {
|
||||||
if (data.pagination) {
|
if (data.pagination) {
|
||||||
setPagination(data.pagination);
|
setPagination(data.pagination);
|
||||||
}
|
}
|
||||||
if (Array.isArray((data as any).groupTree)) {
|
setGroupLayout((data as any).groupLayout ?? null);
|
||||||
setGroupTree((data as any).groupTree);
|
setAppliedView((data as any).appliedView ?? null);
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Handle non-paginated response (backward compatibility)
|
|
||||||
console.log('📋 Processing non-paginated response:', {
|
|
||||||
isArray: Array.isArray(data),
|
|
||||||
dataLength: Array.isArray(data) ? data.length : 'not an array',
|
|
||||||
firstItemRaw: Array.isArray(data) && data.length > 0 ? data[0] : null,
|
|
||||||
allDataRaw: data
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use backend data directly - no mapping needed, just like prompts
|
|
||||||
const items = Array.isArray(data) ? data : [];
|
const items = Array.isArray(data) ? data : [];
|
||||||
console.log('📊 Final files array (non-paginated, using backend data directly):', items);
|
|
||||||
setFiles(items);
|
setFiles(items);
|
||||||
setPagination(null);
|
setPagination(null);
|
||||||
|
setGroupLayout(null);
|
||||||
|
setAppliedView(null);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Error is already handled by useApiRequest
|
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setPagination(null);
|
setPagination(null);
|
||||||
|
setGroupLayout(null);
|
||||||
|
setAppliedView(null);
|
||||||
}
|
}
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
|
|
@ -338,10 +394,13 @@ export function useUserFiles() {
|
||||||
attributes,
|
attributes,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
groupTree,
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
fetchFileById,
|
fetchFileById,
|
||||||
generateEditFieldsFromAttributes,
|
generateEditFieldsFromAttributes,
|
||||||
ensureAttributesLoaded
|
ensureAttributesLoaded,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
type AttributeDefinition,
|
type AttributeDefinition,
|
||||||
type PaginationParams
|
type PaginationParams
|
||||||
} from '../api/promptApi';
|
} from '../api/promptApi';
|
||||||
import type { TableGroupNode } from '../api/connectionApi';
|
|
||||||
|
|
||||||
// Re-export types for backward compatibility
|
// Re-export types for backward compatibility
|
||||||
export type { Prompt, AttributeDefinition, PaginationParams };
|
export type { Prompt, AttributeDefinition, PaginationParams };
|
||||||
|
|
@ -35,7 +34,8 @@ export function usePrompts() {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [groupTree, setGroupTree] = useState<TableGroupNode[]>([]);
|
const [groupLayout, setGroupLayout] = useState<import('../api/connectionApi').GroupLayout | null>(null);
|
||||||
|
const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null);
|
||||||
const { request, isLoading: loading, error } = useApiRequest<null, Prompt[]>();
|
const { request, isLoading: loading, error } = useApiRequest<null, Prompt[]>();
|
||||||
const { checkPermission } = usePermissions();
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
|
@ -90,6 +90,69 @@ export function usePrompts() {
|
||||||
}
|
}
|
||||||
}, [checkPermission]);
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchGroupSectionSummaries = useCallback(
|
||||||
|
async (base: {
|
||||||
|
search?: string;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
sort?: Array<{ field: string; direction: string }>;
|
||||||
|
viewKey?: string | null;
|
||||||
|
groupField: string;
|
||||||
|
groupDirection?: 'asc' | 'desc';
|
||||||
|
}) => {
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 25,
|
||||||
|
groupByLevels: [
|
||||||
|
{
|
||||||
|
field: base.groupField,
|
||||||
|
nullLabel: '—',
|
||||||
|
direction: base.groupDirection || 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (base.search) (pObj as { search?: string }).search = base.search;
|
||||||
|
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||||||
|
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
||||||
|
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
||||||
|
const { data } = await api.get('/api/prompts', {
|
||||||
|
params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
|
||||||
|
});
|
||||||
|
return Array.isArray(data?.groups) ? data.groups : [];
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchForSection = useCallback(
|
||||||
|
async (
|
||||||
|
paginationParams: any,
|
||||||
|
sectionFilter: Record<string, unknown>,
|
||||||
|
parentColumnFilters?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
const mergedFilters = {
|
||||||
|
...(parentColumnFilters || {}),
|
||||||
|
...(paginationParams.filters || {}),
|
||||||
|
...sectionFilter,
|
||||||
|
};
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: paginationParams.page,
|
||||||
|
pageSize: paginationParams.pageSize,
|
||||||
|
filters: mergedFilters,
|
||||||
|
groupByLevels: [],
|
||||||
|
};
|
||||||
|
if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort;
|
||||||
|
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
||||||
|
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
||||||
|
const { data } = await api.get('/api/prompts', {
|
||||||
|
params: { pagination: JSON.stringify(pObj) },
|
||||||
|
});
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
return { items: data.items, pagination: data.pagination };
|
||||||
|
}
|
||||||
|
return { items: [], pagination: null };
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const fetchPrompts = useCallback(async (params?: PaginationParams) => {
|
const fetchPrompts = useCallback(async (params?: PaginationParams) => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchPromptsApi(request, params);
|
const data = await fetchPromptsApi(request, params);
|
||||||
|
|
@ -101,19 +164,22 @@ export function usePrompts() {
|
||||||
if (data.pagination) {
|
if (data.pagination) {
|
||||||
setPagination(data.pagination);
|
setPagination(data.pagination);
|
||||||
}
|
}
|
||||||
if (Array.isArray((data as any).groupTree)) {
|
setGroupLayout(data.groupLayout ?? null);
|
||||||
setGroupTree((data as any).groupTree);
|
setAppliedView(data.appliedView ?? null);
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Handle non-paginated response (backward compatibility)
|
// Handle non-paginated response (backward compatibility)
|
||||||
const items = Array.isArray(data) ? data : [];
|
const items = Array.isArray(data) ? data : [];
|
||||||
setPrompts(items);
|
setPrompts(items);
|
||||||
setPagination(null);
|
setPagination(null);
|
||||||
|
setGroupLayout(null);
|
||||||
|
setAppliedView(null);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Error is already handled by useApiRequest
|
// Error is already handled by useApiRequest
|
||||||
setPrompts([]);
|
setPrompts([]);
|
||||||
setPagination(null);
|
setPagination(null);
|
||||||
|
setGroupLayout(null);
|
||||||
|
setAppliedView(null);
|
||||||
}
|
}
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
|
|
@ -459,11 +525,14 @@ export function usePrompts() {
|
||||||
attributes,
|
attributes,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
groupTree,
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
fetchPromptById,
|
fetchPromptById,
|
||||||
generateEditFieldsFromAttributes,
|
generateEditFieldsFromAttributes,
|
||||||
generateCreateFieldsFromAttributes,
|
generateCreateFieldsFromAttributes,
|
||||||
ensureAttributesLoaded
|
ensureAttributesLoaded,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,11 @@ export function useTtsPlayback(callbacks?: TtsPlaybackCallbacks): TtsPlaybackApi
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.pause();
|
const audio = audioRef.current;
|
||||||
|
audio.onpause = null;
|
||||||
|
audio.onended = null;
|
||||||
audioRef.current = null;
|
audioRef.current = null;
|
||||||
|
audio.pause();
|
||||||
}
|
}
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
|
|
|
||||||
|
|
@ -172,37 +172,15 @@ export function useCurrentUser() {
|
||||||
// Clear auth authority from sessionStorage
|
// Clear auth authority from sessionStorage
|
||||||
sessionStorage.removeItem('auth_authority');
|
sessionStorage.removeItem('auth_authority');
|
||||||
|
|
||||||
// Optional: clear MSAL browser cache only (PowerOn JWT lives in httpOnly cookies + backend).
|
localStorage.removeItem('authToken');
|
||||||
// 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.')) {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear cookies as backup (in case backend doesn't clear them properly)
|
document.cookie.split(";").forEach((c) => {
|
||||||
// Note: This only works for cookies that are accessible to JavaScript
|
const name = c.split("=")[0].trim();
|
||||||
console.log('🍪 Checking cookies for cleanup...');
|
if (name) {
|
||||||
console.log('🍪 All cookies:', document.cookie);
|
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||||
|
|
||||||
const cookies = document.cookie.split(";");
|
|
||||||
console.log('🍪 Cookie count:', cookies.length);
|
|
||||||
|
|
||||||
cookies.forEach(function(c) {
|
|
||||||
const cookieName = c.split("=")[0].trim();
|
|
||||||
console.log('🍪 Checking cookie:', cookieName);
|
|
||||||
|
|
||||||
if (cookieName === 'auth_token' || cookieName === 'refresh_token' || cookieName.includes('token') || cookieName.includes('msal')) {
|
|
||||||
console.log('🗑️ Clearing cookie:', cookieName);
|
|
||||||
document.cookie = cookieName + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('🍪 Cookies after cleanup attempt:', document.cookie);
|
|
||||||
|
|
||||||
console.log('✅ Cleanup completed');
|
|
||||||
|
|
||||||
// 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';
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import styles from './MainLayout.module.css';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
||||||
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
||||||
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
|
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/session/;
|
||||||
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
|
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
|
||||||
const _ADMIN_LANGUAGES_RE = /\/admin\/languages(?:$|\/)/;
|
const _ADMIN_LANGUAGES_RE = /\/admin\/languages(?:$|\/)/;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ import { WorkspaceRagInsightsPage } from './views/workspace/WorkspaceRagInsights
|
||||||
|
|
||||||
// Teamsbot Views
|
// Teamsbot Views
|
||||||
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
||||||
|
import { TeamsbotAssistantView } from './views/teamsbot/TeamsbotAssistantView';
|
||||||
|
import { TeamsbotModulesView } from './views/teamsbot/TeamsbotModulesView';
|
||||||
import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView';
|
import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView';
|
||||||
import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView';
|
import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView';
|
||||||
|
|
||||||
|
|
@ -46,7 +48,7 @@ import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView';
|
||||||
import { NeutralizationView } from './views/neutralization';
|
import { NeutralizationView } from './views/neutralization';
|
||||||
|
|
||||||
// CommCoach Views
|
// CommCoach Views
|
||||||
import { CommcoachDashboardView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach';
|
import { CommcoachDashboardView, CommcoachAssistantView, CommcoachModulesView, CommcoachSessionView, CommcoachSettingsView } from './views/commcoach';
|
||||||
|
|
||||||
// Redmine Views
|
// Redmine Views
|
||||||
import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine';
|
import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine';
|
||||||
|
|
@ -158,6 +160,8 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
},
|
},
|
||||||
teamsbot: {
|
teamsbot: {
|
||||||
dashboard: TeamsbotDashboardView,
|
dashboard: TeamsbotDashboardView,
|
||||||
|
assistant: TeamsbotAssistantView,
|
||||||
|
modules: TeamsbotModulesView,
|
||||||
sessions: TeamsbotSessionView,
|
sessions: TeamsbotSessionView,
|
||||||
settings: TeamsbotSettingsView,
|
settings: TeamsbotSettingsView,
|
||||||
},
|
},
|
||||||
|
|
@ -167,8 +171,9 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
},
|
},
|
||||||
commcoach: {
|
commcoach: {
|
||||||
dashboard: CommcoachDashboardView,
|
dashboard: CommcoachDashboardView,
|
||||||
coaching: CommcoachDossierView,
|
assistant: CommcoachAssistantView,
|
||||||
dossier: CommcoachDossierView,
|
modules: CommcoachModulesView,
|
||||||
|
session: CommcoachSessionView,
|
||||||
settings: CommcoachSettingsView,
|
settings: CommcoachSettingsView,
|
||||||
},
|
},
|
||||||
redmine: {
|
redmine: {
|
||||||
|
|
@ -228,8 +233,8 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommCoach coaching/dossier is rendered persistently by CommcoachKeepAlive at MainLayout level.
|
// CommCoach session is rendered persistently by CommcoachKeepAlive at MainLayout level.
|
||||||
if (featureCode === 'commcoach' && (view === 'coaching' || view === 'dossier')) {
|
if (featureCode === 'commcoach' && view === 'session') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { FaEnvelopeOpenText } from 'react-icons/fa';
|
import { FaEnvelopeOpenText } from 'react-icons/fa';
|
||||||
|
|
||||||
import styles from './Register.module.css';
|
import styles from './Register.module.css';
|
||||||
import { useRegister, useMsalRegister, useUsernameAvailability } from '../hooks/useAuthentication';
|
import { useRegister, useUsernameAvailability } from '../hooks/useAuthentication';
|
||||||
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||||
import { PENDING_INVITATION_KEY } from './InvitePage';
|
import { PENDING_INVITATION_KEY } from './InvitePage';
|
||||||
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
||||||
|
|
@ -21,7 +21,6 @@ function Register() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { register, error: registerError, isLoading } = useRegister();
|
const { register, error: registerError, isLoading } = useRegister();
|
||||||
const { error: msalError } = useMsalRegister();
|
|
||||||
const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability();
|
const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability();
|
||||||
const invitationUsername = (location.state as any)?.invitationUsername || '';
|
const invitationUsername = (location.state as any)?.invitationUsername || '';
|
||||||
const invitationEmail = (location.state as any)?.invitationEmail || '';
|
const invitationEmail = (location.state as any)?.invitationEmail || '';
|
||||||
|
|
@ -118,7 +117,6 @@ function Register() {
|
||||||
const _getErrorMessage = () => {
|
const _getErrorMessage = () => {
|
||||||
if (validationError) return validationError;
|
if (validationError) return validationError;
|
||||||
if (registerError) return typeof registerError === 'string' ? registerError : t('Registrierung fehlgeschlagen');
|
if (registerError) return typeof registerError === 'string' ? registerError : t('Registrierung fehlgeschlagen');
|
||||||
if (msalError) return typeof msalError === 'string' ? msalError : t('Microsoft-Registrierung fehlgeschlagen');
|
|
||||||
if (availabilityError) return typeof availabilityError === 'string' ? availabilityError : t('Benutzernamen-Prüfung fehlgeschlagen');
|
if (availabilityError) return typeof availabilityError === 'string' ? availabilityError : t('Benutzernamen-Prüfung fehlgeschlagen');
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,15 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
attributes,
|
attributes,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
fetchConnectionById,
|
fetchConnectionById,
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
deleteConnection,
|
deleteConnection,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
createConnectionAndAuth,
|
createConnectionAndAuth,
|
||||||
|
|
@ -44,7 +48,6 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
refreshMicrosoftToken,
|
refreshMicrosoftToken,
|
||||||
refreshGoogleToken,
|
refreshGoogleToken,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
groupTree,
|
|
||||||
} = useConnections();
|
} = useConnections();
|
||||||
|
|
||||||
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
|
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
|
||||||
|
|
@ -415,6 +418,8 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
data={connections}
|
data={connections}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/connections/"
|
apiEndpoint="/api/connections/"
|
||||||
|
tableContextKey="connections"
|
||||||
|
tableGroupLayoutMode="sections"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -467,12 +472,14 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
refetch,
|
refetch,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
handleDelete: deleteConnection,
|
handleDelete: deleteConnection,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
groupTree,
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
}}
|
}}
|
||||||
groupingConfig={{ contextKey: 'connections', enabled: true }}
|
|
||||||
emptyMessage={t('Keine Verbindungen gefunden')}
|
emptyMessage={t('Keine Verbindungen gefunden')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo, useEffect, useRef, useCallback, type PointerEvent as RPointerEvent } from 'react';
|
import React, { useState, useMemo, useEffect, useRef, useCallback, type PointerEvent as RPointerEvent } from 'react';
|
||||||
import { useUserFiles, useFileOperations } from '../../hooks/useFiles';
|
import { useUserFiles, useFileOperations, type PaginationParams } from '../../hooks/useFiles';
|
||||||
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 { FormGeneratorTree } from '../../components/FormGenerator/FormGeneratorTree';
|
import { FormGeneratorTree } from '../../components/FormGenerator/FormGeneratorTree';
|
||||||
|
|
@ -50,8 +50,12 @@ export const FilesPage: React.FC = () => {
|
||||||
error,
|
error,
|
||||||
refetch: tableRefetch,
|
refetch: tableRefetch,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
fetchFileById,
|
fetchFileById,
|
||||||
updateFileOptimistically,
|
updateFileOptimistically,
|
||||||
|
fetchGroupSectionSummaries: fetchGroupSectionSummariesFromHook,
|
||||||
|
refetchForSection: refetchForSectionFromHook,
|
||||||
} = useUserFiles();
|
} = useUserFiles();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -108,6 +112,39 @@ export const FilesPage: React.FC = () => {
|
||||||
await tableRefetch(nextParams);
|
await tableRefetch(nextParams);
|
||||||
}, [tableRefetch, selectedFolderId, viewMode]);
|
}, [tableRefetch, selectedFolderId, viewMode]);
|
||||||
|
|
||||||
|
const fetchGroupSectionSummaries = useCallback(
|
||||||
|
async (base: {
|
||||||
|
search?: string;
|
||||||
|
filters?: Record<string, unknown>;
|
||||||
|
sort?: Array<{ field: string; direction: string }>;
|
||||||
|
viewKey?: string | null;
|
||||||
|
groupField: string;
|
||||||
|
groupDirection?: 'asc' | 'desc';
|
||||||
|
}) => {
|
||||||
|
const filters = { ...(base.filters || {}) };
|
||||||
|
if (viewMode === 'folder' && selectedFolderId) {
|
||||||
|
filters.folderId = selectedFolderId;
|
||||||
|
}
|
||||||
|
return fetchGroupSectionSummariesFromHook({ ...base, filters });
|
||||||
|
},
|
||||||
|
[fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchForSection = useCallback(
|
||||||
|
async (
|
||||||
|
paginationParams: PaginationParams & { page: number; pageSize: number },
|
||||||
|
sectionFilter: Record<string, unknown>,
|
||||||
|
parentColumnFilters?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
const merged = { ...(parentColumnFilters || {}) };
|
||||||
|
if (viewMode === 'folder' && selectedFolderId) {
|
||||||
|
merged.folderId = selectedFolderId;
|
||||||
|
}
|
||||||
|
return refetchForSectionFromHook(paginationParams, sectionFilter, merged);
|
||||||
|
},
|
||||||
|
[refetchForSectionFromHook, viewMode, selectedFolderId],
|
||||||
|
);
|
||||||
|
|
||||||
const _refreshAll = useCallback(async () => {
|
const _refreshAll = useCallback(async () => {
|
||||||
await _tableRefetch({ page: 1, pageSize: 25 });
|
await _tableRefetch({ page: 1, pageSize: 25 });
|
||||||
setTreeKey(k => k + 1);
|
setTreeKey(k => k + 1);
|
||||||
|
|
@ -333,6 +370,7 @@ export const FilesPage: React.FC = () => {
|
||||||
ownership="own"
|
ownership="own"
|
||||||
title={t('Eigene')}
|
title={t('Eigene')}
|
||||||
showFilter={true}
|
showFilter={true}
|
||||||
|
allowCreateFolder={canCreate}
|
||||||
onNodeClick={_handleTreeNodeClick}
|
onNodeClick={_handleTreeNodeClick}
|
||||||
onRefresh={() => _tableRefetch()}
|
onRefresh={() => _tableRefetch()}
|
||||||
/>
|
/>
|
||||||
|
|
@ -409,6 +447,8 @@ export const FilesPage: React.FC = () => {
|
||||||
data={tableFiles || []}
|
data={tableFiles || []}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/files/list"
|
apiEndpoint="/api/files/list"
|
||||||
|
tableContextKey="files/list"
|
||||||
|
tableGroupLayoutMode="sections"
|
||||||
loading={tableLoading}
|
loading={tableLoading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -459,11 +499,15 @@ export const FilesPage: React.FC = () => {
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch: _tableRefetch,
|
refetch: _tableRefetch,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
permissions,
|
permissions,
|
||||||
handleDelete: handleFileDelete,
|
handleDelete: handleFileDelete,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
updateOptimistically: updateFileOptimistically,
|
updateOptimistically: updateFileOptimistically,
|
||||||
previewingFiles,
|
previewingFiles,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
}}
|
}}
|
||||||
emptyMessage={t('Keine Dateien gefunden')}
|
emptyMessage={t('Keine Dateien gefunden')}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,15 @@ export const PromptsPage: React.FC = () => {
|
||||||
attributes,
|
attributes,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
groupTree,
|
|
||||||
fetchPromptById,
|
fetchPromptById,
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
} = usePrompts();
|
} = usePrompts();
|
||||||
|
|
||||||
// Operations hook
|
// Operations hook
|
||||||
|
|
@ -205,6 +208,8 @@ export const PromptsPage: React.FC = () => {
|
||||||
data={prompts}
|
data={prompts}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/prompts"
|
apiEndpoint="/api/prompts"
|
||||||
|
tableContextKey="prompts"
|
||||||
|
tableGroupLayoutMode="sections"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -234,12 +239,14 @@ export const PromptsPage: React.FC = () => {
|
||||||
refetch: _tableRefetch,
|
refetch: _tableRefetch,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
handleDelete: handlePromptDelete,
|
handleDelete: handlePromptDelete,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
groupTree,
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
}}
|
}}
|
||||||
groupingConfig={{ contextKey: 'prompts', enabled: true }}
|
|
||||||
emptyMessage={t('Keine Prompts gefunden')}
|
emptyMessage={t('Keine Prompts gefunden')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,16 @@ import { useApiRequest } from '../../hooks/useApi';
|
||||||
import { fetchAttributes } from '../../api/attributesApi';
|
import { fetchAttributes } from '../../api/attributesApi';
|
||||||
import type { AttributeDefinition } from '../../api/attributesApi';
|
import type { AttributeDefinition } from '../../api/attributesApi';
|
||||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||||
|
import {
|
||||||
|
createEnterprise,
|
||||||
|
renewEnterprise,
|
||||||
|
updateEnterprise,
|
||||||
|
} from '../../api/subscriptionApi';
|
||||||
|
import { fetchMandates } from '../../api/mandateApi';
|
||||||
|
import type { Mandate } from '../../api/mandateApi';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
import EnterpriseDialog, { type EnterpriseDialogMode, type EnterpriseDialogData } from './EnterpriseDialog';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -21,15 +29,101 @@ const AdminSubscriptionsPage: React.FC = () => {
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions();
|
const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions();
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [dialogMode, setDialogMode] = useState<EnterpriseDialogMode>('create');
|
||||||
|
const [dialogData, setDialogData] = useState<EnterpriseDialogData>({});
|
||||||
|
const [mandateOptions, setMandateOptions] = useState<{ id: string; label: string }[]>([]);
|
||||||
|
const [mandatesLoading, setMandatesLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAttributes(request, 'MandateSubscriptionView')
|
fetchAttributes(request, 'MandateSubscriptionView')
|
||||||
.then(setBackendAttributes)
|
.then(setBackendAttributes)
|
||||||
.catch(() => setBackendAttributes([]));
|
.catch(() => setBackendAttributes([]));
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
|
const _loadMandates = useCallback(async () => {
|
||||||
|
setMandatesLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchMandates(request);
|
||||||
|
const items: Mandate[] = Array.isArray(data) ? data : (data as any).items ?? [];
|
||||||
|
setMandateOptions(items.map((m) => ({ id: m.id, label: m.label || m.name })));
|
||||||
|
} catch {
|
||||||
|
setMandateOptions([]);
|
||||||
|
} finally {
|
||||||
|
setMandatesLoading(false);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
const _openCreate = useCallback(() => {
|
||||||
|
setDialogMode('create');
|
||||||
|
setDialogData({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
_loadMandates();
|
||||||
|
}, [_loadMandates]);
|
||||||
|
|
||||||
|
const _openRenew = useCallback((row: any) => {
|
||||||
|
setDialogMode('renew');
|
||||||
|
setDialogData({
|
||||||
|
subscriptionId: row.id,
|
||||||
|
mandateName: row.mandateName,
|
||||||
|
endDate: row.currentPeriodEnd,
|
||||||
|
autoRenew: row.recurring === true || row.recurring === 'Ja',
|
||||||
|
flatPriceCHF: row.enterpriseFlatPriceCHF,
|
||||||
|
maxUsers: row.enterpriseMaxUsers,
|
||||||
|
maxFeatureInstances: row.enterpriseMaxFeatureInstances,
|
||||||
|
maxDataVolumeMB: row.enterpriseMaxDataVolumeMB,
|
||||||
|
budgetAiCHF: row.enterpriseBudgetAiCHF,
|
||||||
|
note: row.enterpriseNote,
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _openUpdate = useCallback((row: any) => {
|
||||||
|
setDialogMode('update');
|
||||||
|
setDialogData({
|
||||||
|
subscriptionId: row.id,
|
||||||
|
mandateName: row.mandateName,
|
||||||
|
autoRenew: row.recurring === true || row.recurring === 'Ja',
|
||||||
|
flatPriceCHF: row.enterpriseFlatPriceCHF,
|
||||||
|
maxUsers: row.enterpriseMaxUsers,
|
||||||
|
maxFeatureInstances: row.enterpriseMaxFeatureInstances,
|
||||||
|
maxDataVolumeMB: row.enterpriseMaxDataVolumeMB,
|
||||||
|
budgetAiCHF: row.enterpriseBudgetAiCHF,
|
||||||
|
note: row.enterpriseNote,
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleDialogSubmit = useCallback(async (mode: EnterpriseDialogMode, values: Record<string, any>) => {
|
||||||
|
if (mode === 'create') {
|
||||||
|
await createEnterprise(request, values as any);
|
||||||
|
} else if (mode === 'renew') {
|
||||||
|
await renewEnterprise(request, values as any);
|
||||||
|
} else if (mode === 'update') {
|
||||||
|
await updateEnterprise(request, values as any);
|
||||||
|
}
|
||||||
|
await refetch();
|
||||||
|
}, [request, refetch]);
|
||||||
|
|
||||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||||
{ key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, width: 180 },
|
{ key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, width: 180 },
|
||||||
{ key: 'planTitle', label: t('Plan'), sortable: true, filterable: true, width: 180 },
|
{ key: 'planTitle', label: t('Plan'), sortable: true, filterable: true, width: 180,
|
||||||
|
render: (value: any, row: any) => {
|
||||||
|
const isEnt = row.isEnterprise || row.planKey === 'ENTERPRISE';
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{value}
|
||||||
|
{isEnt && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.65rem', padding: '1px 7px', borderRadius: '8px',
|
||||||
|
background: 'rgba(139,92,246,0.15)', color: '#8b5cf6',
|
||||||
|
fontWeight: 600, letterSpacing: '0.03em', whiteSpace: 'nowrap',
|
||||||
|
}}>Enterprise</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{ key: 'status', label: t('Status'), sortable: true, filterable: true, width: 110 },
|
{ key: 'status', label: t('Status'), sortable: true, filterable: true, width: 110 },
|
||||||
{ key: 'recurring', label: t('Wiederkehrend'), sortable: true, filterable: true, width: 120 },
|
{ key: 'recurring', label: t('Wiederkehrend'), sortable: true, filterable: true, width: 120 },
|
||||||
{ key: 'activeUsers', label: t('Benutzer'), sortable: true, width: 70 },
|
{ key: 'activeUsers', label: t('Benutzer'), sortable: true, width: 70 },
|
||||||
|
|
@ -61,11 +155,48 @@ const AdminSubscriptionsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [confirm, refetch, t]);
|
}, [confirm, refetch, t]);
|
||||||
|
|
||||||
|
const _isEnterprise = (row: any) => row.isEnterprise || row.planKey === 'ENTERPRISE';
|
||||||
|
|
||||||
|
const customActions = useMemo(() => [
|
||||||
|
{
|
||||||
|
id: 'enterpriseUpdate',
|
||||||
|
title: t('Enterprise anpassen'),
|
||||||
|
icon: '✎',
|
||||||
|
onClick: (row: any) => _openUpdate(row),
|
||||||
|
visible: (row: any) => _isEnterprise(row) && !_TERMINAL_STATUSES.has(row._rawStatus),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enterpriseRenew',
|
||||||
|
title: t('Enterprise erneuern'),
|
||||||
|
icon: '↻',
|
||||||
|
onClick: (row: any) => _openRenew(row),
|
||||||
|
visible: (row: any) => _isEnterprise(row) && !_TERMINAL_STATUSES.has(row._rawStatus),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'forceCancel',
|
||||||
|
title: t('Sofort stornieren'),
|
||||||
|
icon: '✕',
|
||||||
|
onClick: (row: any) => _handleForceCancel(row),
|
||||||
|
visible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus),
|
||||||
|
},
|
||||||
|
], [_openUpdate, _openRenew, _handleForceCancel, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.billingDashboard} style={{ minHeight: 0 }}>
|
<div className={styles.billingDashboard} style={{ minHeight: 0 }}>
|
||||||
<header className={styles.pageHeader} style={{ flexShrink: 0 }}>
|
<header className={styles.pageHeader} style={{ flexShrink: 0 }}>
|
||||||
<h1>{t('Abonnementübersicht')}</h1>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<p className={styles.subtitle}>{t('Alle Abonnements aller Mandanten')}</p>
|
<div>
|
||||||
|
<h1>{t('Abonnementübersicht')}</h1>
|
||||||
|
<p className={styles.subtitle}>{t('Alle Abonnements aller Mandanten')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
onClick={_openCreate}
|
||||||
|
style={{ height: 'fit-content' }}
|
||||||
|
>
|
||||||
|
{t('Enterprise-Abo erstellen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||||
|
|
@ -78,19 +209,21 @@ const AdminSubscriptionsPage: React.FC = () => {
|
||||||
pageSize={50}
|
pageSize={50}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
hookData={{ refetch, pagination }}
|
hookData={{ refetch, pagination }}
|
||||||
customActions={[
|
customActions={customActions}
|
||||||
{
|
|
||||||
id: 'forceCancel',
|
|
||||||
title: t('Sofort stornieren'),
|
|
||||||
icon: '✕',
|
|
||||||
onClick: (row: any) => _handleForceCancel(row),
|
|
||||||
visible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
emptyMessage={t('Keine Abonnements vorhanden')}
|
emptyMessage={t('Keine Abonnements vorhanden')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EnterpriseDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
mode={dialogMode}
|
||||||
|
data={dialogData}
|
||||||
|
mandates={mandateOptions}
|
||||||
|
loading={mandatesLoading}
|
||||||
|
onClose={() => setDialogOpen(false)}
|
||||||
|
onSubmit={_handleDialogSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,36 @@
|
||||||
|
|
||||||
.billingDashboard {
|
.billingDashboard {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
height: 100%;
|
/* Fill MainLayout outletShell (flex column); height:100% alone does not grow the flex item */
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flex host for tab panels so the transactions tab can grow to the viewport */
|
||||||
|
.billingTabBody {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* Overview/Diagramme: scroll; Transaktionen: single flex child fills height */
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transactions tab: consume remaining viewport so nested tables can flex */
|
||||||
|
.transactionsTabLayout {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.pageHeader {
|
.pageHeader {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import type { AttributeDefinition } from '../../api/attributesApi';
|
||||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||||
import { useBilling, type BillingBucketSize } from '../../hooks/useBilling';
|
import { useBilling, type BillingBucketSize } from '../../hooks/useBilling';
|
||||||
import { UserTransaction } from '../../api/billingApi';
|
import { UserTransaction } from '../../api/billingApi';
|
||||||
|
import type { GroupLayout } from '../../api/connectionApi';
|
||||||
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import {
|
import {
|
||||||
|
|
@ -343,6 +344,11 @@ export const BillingDataView: React.FC = () => {
|
||||||
const [transactionsLoading, setTransactionsLoading] = useState(false);
|
const [transactionsLoading, setTransactionsLoading] = useState(false);
|
||||||
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
||||||
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
||||||
|
const [transactionsGroupLayout, setTransactionsGroupLayout] = useState<GroupLayout | null>(null);
|
||||||
|
const [transactionsAppliedView, setTransactionsAppliedView] = useState<{
|
||||||
|
viewKey?: string;
|
||||||
|
displayName?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAttributes(request, 'BillingTransactionView')
|
fetchAttributes(request, 'BillingTransactionView')
|
||||||
|
|
@ -479,6 +485,8 @@ export const BillingDataView: React.FC = () => {
|
||||||
if (paginationParams.sort) pObj.sort = paginationParams.sort;
|
if (paginationParams.sort) pObj.sort = paginationParams.sort;
|
||||||
if (paginationParams.filters) pObj.filters = paginationParams.filters;
|
if (paginationParams.filters) pObj.filters = paginationParams.filters;
|
||||||
if (paginationParams.search) pObj.search = paginationParams.search;
|
if (paginationParams.search) pObj.search = paginationParams.search;
|
||||||
|
if (paginationParams.viewKey) pObj.viewKey = paginationParams.viewKey;
|
||||||
|
if (paginationParams.groupByLevels !== undefined) pObj.groupByLevels = paginationParams.groupByLevels;
|
||||||
if (Object.keys(pObj).length > 0) {
|
if (Object.keys(pObj).length > 0) {
|
||||||
params.pagination = JSON.stringify(pObj);
|
params.pagination = JSON.stringify(pObj);
|
||||||
}
|
}
|
||||||
|
|
@ -489,20 +497,96 @@ export const BillingDataView: React.FC = () => {
|
||||||
|
|
||||||
if (data && typeof data === 'object' && 'items' in data) {
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
setTransactions(Array.isArray(data.items) ? data.items : []);
|
setTransactions(Array.isArray(data.items) ? data.items : []);
|
||||||
if (data.pagination) {
|
setTransactionsPagination(data.pagination ?? null);
|
||||||
setTransactionsPagination(data.pagination);
|
setTransactionsGroupLayout(data.groupLayout ?? null);
|
||||||
}
|
setTransactionsAppliedView(data.appliedView ?? null);
|
||||||
|
return data;
|
||||||
} else {
|
} else {
|
||||||
setTransactions(Array.isArray(data) ? data : []);
|
setTransactions(Array.isArray(data) ? data : []);
|
||||||
|
setTransactionsPagination(null);
|
||||||
|
setTransactionsGroupLayout(null);
|
||||||
|
setTransactionsAppliedView(null);
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to load transactions:', err);
|
console.error('Failed to load transactions:', err);
|
||||||
setTransactionsError(err.response?.data?.detail || err.message || t('Fehler beim Laden der Transaktionen'));
|
setTransactionsError(err.response?.data?.detail || err.message || t('Fehler beim Laden der Transaktionen'));
|
||||||
|
setTransactionsGroupLayout(null);
|
||||||
|
setTransactionsAppliedView(null);
|
||||||
} finally {
|
} finally {
|
||||||
setTransactionsLoading(false);
|
setTransactionsLoading(false);
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}, [_scopeParams, t]);
|
}, [_scopeParams, t]);
|
||||||
|
|
||||||
|
const fetchGroupSectionSummaries = useCallback(
|
||||||
|
async (base: {
|
||||||
|
search?: string;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
sort?: Array<{ field: string; direction: string }>;
|
||||||
|
viewKey?: string | null;
|
||||||
|
groupField: string;
|
||||||
|
groupDirection?: 'asc' | 'desc';
|
||||||
|
}) => {
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 25,
|
||||||
|
groupByLevels: [
|
||||||
|
{
|
||||||
|
field: base.groupField,
|
||||||
|
nullLabel: '—',
|
||||||
|
direction: base.groupDirection || 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (base.search) (pObj as { search?: string }).search = base.search;
|
||||||
|
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||||||
|
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
||||||
|
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
..._scopeParams,
|
||||||
|
mode: 'groupSummary',
|
||||||
|
pagination: JSON.stringify(pObj),
|
||||||
|
};
|
||||||
|
const { data } = await api.get('/api/billing/view/users/transactions', { params });
|
||||||
|
return Array.isArray(data?.groups) ? data.groups : [];
|
||||||
|
},
|
||||||
|
[_scopeParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchForSection = useCallback(
|
||||||
|
async (
|
||||||
|
paginationParams: any,
|
||||||
|
sectionFilter: Record<string, unknown>,
|
||||||
|
parentColumnFilters?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
const mergedFilters = {
|
||||||
|
...(parentColumnFilters || {}),
|
||||||
|
...(paginationParams.filters || {}),
|
||||||
|
...sectionFilter,
|
||||||
|
};
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: paginationParams.page,
|
||||||
|
pageSize: paginationParams.pageSize,
|
||||||
|
filters: mergedFilters,
|
||||||
|
groupByLevels: [],
|
||||||
|
};
|
||||||
|
if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort;
|
||||||
|
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
||||||
|
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
..._scopeParams,
|
||||||
|
pagination: JSON.stringify(pObj),
|
||||||
|
};
|
||||||
|
const { data } = await api.get('/api/billing/view/users/transactions', { params });
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
return { items: data.items, pagination: data.pagination };
|
||||||
|
}
|
||||||
|
return { items: [], pagination: null };
|
||||||
|
},
|
||||||
|
[_scopeParams],
|
||||||
|
);
|
||||||
|
|
||||||
const _fetchTransactionFilterValues = useCallback(async (
|
const _fetchTransactionFilterValues = useCallback(async (
|
||||||
columnKey: string,
|
columnKey: string,
|
||||||
crossFilters?: Record<string, any>,
|
crossFilters?: Record<string, any>,
|
||||||
|
|
@ -518,11 +602,28 @@ export const BillingDataView: React.FC = () => {
|
||||||
return Array.isArray(resp.data) ? resp.data : [];
|
return Array.isArray(resp.data) ? resp.data : [];
|
||||||
}, [_scopeParams]);
|
}, [_scopeParams]);
|
||||||
|
|
||||||
const transactionsHookData = useMemo(() => ({
|
const transactionsHookData = useMemo(
|
||||||
refetch: _loadTransactions,
|
() => ({
|
||||||
pagination: transactionsPagination || undefined,
|
refetch: _loadTransactions,
|
||||||
fetchFilterValues: _fetchTransactionFilterValues,
|
pagination: transactionsPagination || undefined,
|
||||||
}), [_loadTransactions, transactionsPagination, _fetchTransactionFilterValues]);
|
groupLayout: transactionsGroupLayout ?? undefined,
|
||||||
|
appliedView: transactionsAppliedView ?? undefined,
|
||||||
|
fetchFilterValues: _fetchTransactionFilterValues,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
|
csvExportQueryParams: _scopeParams,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
_loadTransactions,
|
||||||
|
transactionsPagination,
|
||||||
|
transactionsGroupLayout,
|
||||||
|
transactionsAppliedView,
|
||||||
|
_fetchTransactionFilterValues,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
|
_scopeParams,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const _rawTransactionColumns: ColumnConfig[] = useMemo(() => [
|
const _rawTransactionColumns: ColumnConfig[] = useMemo(() => [
|
||||||
{ key: 'sysCreatedAt', label: t('Datum'), sortable: true, width: 160 },
|
{ key: 'sysCreatedAt', label: t('Datum'), sortable: true, width: 160 },
|
||||||
|
|
@ -635,6 +736,7 @@ export const BillingDataView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className={styles.billingTabBody}>
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{/* Tab: Übersicht (KPI overview) */}
|
{/* Tab: Übersicht (KPI overview) */}
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
|
|
@ -722,7 +824,7 @@ export const BillingDataView: React.FC = () => {
|
||||||
{/* Tab: Transaktionen */}
|
{/* Tab: Transaktionen */}
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{activeTab === 'transactions' && (
|
{activeTab === 'transactions' && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: '500px' }}>
|
<div className={styles.transactionsTabLayout}>
|
||||||
{transactionsError && (
|
{transactionsError && (
|
||||||
<div className={styles.errorMessage}>
|
<div className={styles.errorMessage}>
|
||||||
{transactionsError}
|
{transactionsError}
|
||||||
|
|
@ -734,6 +836,8 @@ export const BillingDataView: React.FC = () => {
|
||||||
data={transactions}
|
data={transactions}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/billing/view/users/transactions"
|
apiEndpoint="/api/billing/view/users/transactions"
|
||||||
|
tableContextKey="billing/view/users/transactions"
|
||||||
|
tableGroupLayoutMode="sections"
|
||||||
loading={transactionsLoading}
|
loading={transactionsLoading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -742,12 +846,13 @@ export const BillingDataView: React.FC = () => {
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
emptyMessage={t('Keine Transaktionen vorhanden')}
|
emptyMessage={t('Keine Transaktionen vorhanden')}
|
||||||
onRefresh={_loadTransactions}
|
|
||||||
hookData={transactionsHookData}
|
hookData={transactionsHookData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,97 +1,147 @@
|
||||||
/**
|
/**
|
||||||
* Billing Transactions Page
|
* Billing Transactions Page
|
||||||
*
|
*
|
||||||
* Zeigt die Transaktionshistorie für den Benutzer.
|
* Transaktionshistorie mit FormGeneratorTable (Suche, Filter, Sortierung, Ansichten, Gruppierung).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useBilling, type BillingTransaction } from '../../hooks/useBilling';
|
import { useBilling, type BillingTransaction } from '../../hooks/useBilling';
|
||||||
import { BillingNav } from './BillingNav';
|
import { BillingNav } from './BillingNav';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
// ============================================================================
|
function typePillClass(type: string): string {
|
||||||
// TRANSACTION ROW COMPONENT
|
switch (type) {
|
||||||
// ============================================================================
|
case 'CREDIT':
|
||||||
|
return styles.credit;
|
||||||
interface TransactionRowProps {
|
case 'DEBIT':
|
||||||
transaction: BillingTransaction;
|
return styles.debit;
|
||||||
|
case 'ADJUSTMENT':
|
||||||
|
return styles.adjustment;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
|
function typeLabel(type: string, t: (k: string) => string): string {
|
||||||
const formatCurrency = (amount: number) => {
|
switch (type) {
|
||||||
return new Intl.NumberFormat('de-CH', {
|
case 'CREDIT':
|
||||||
style: 'currency',
|
return t('Gutschrift');
|
||||||
currency: 'CHF'
|
case 'DEBIT':
|
||||||
}).format(amount);
|
return t('Belastung');
|
||||||
};
|
case 'ADJUSTMENT':
|
||||||
|
return t('Korrektur');
|
||||||
const formatDate = (dateString?: string) => {
|
default:
|
||||||
if (!dateString) return '-';
|
return type;
|
||||||
return new Date(dateString).toLocaleString('de-CH', {
|
}
|
||||||
year: 'numeric',
|
}
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeClass = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'CREDIT': return styles.credit;
|
|
||||||
case 'DEBIT': return styles.debit;
|
|
||||||
case 'ADJUSTMENT': return styles.adjustment;
|
|
||||||
default: return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeLabel = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'CREDIT': return 'Gutschrift';
|
|
||||||
case 'DEBIT': return 'Belastung';
|
|
||||||
case 'ADJUSTMENT': return 'Korrektur';
|
|
||||||
default: return type;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<td>{formatDate(transaction.sysCreatedAt)}</td>
|
|
||||||
<td>{transaction.mandateName || '-'}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>
|
|
||||||
{getTypeLabel(transaction.transactionType)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{transaction.description}</td>
|
|
||||||
<td>{transaction.aicoreProvider || '-'}</td>
|
|
||||||
<td>{transaction.aicoreModel || '-'}</td>
|
|
||||||
<td>{transaction.featureCode || '-'}</td>
|
|
||||||
<td style={{ textAlign: 'right' }}>
|
|
||||||
{transaction.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(transaction.amount)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// MAIN COMPONENT
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const BillingTransactions: React.FC = () => {
|
export const BillingTransactions: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { transactions, loading, loadTransactions } = useBilling();
|
const {
|
||||||
const [limit, setLimit] = useState(50);
|
transactions,
|
||||||
|
loading,
|
||||||
|
refetchTransactions,
|
||||||
|
transactionsPagination,
|
||||||
|
transactionsGroupLayout,
|
||||||
|
transactionsAppliedView,
|
||||||
|
} = useBilling();
|
||||||
|
|
||||||
useEffect(() => {
|
const columns = useMemo((): ColumnConfig[] => {
|
||||||
loadTransactions(limit);
|
const fmtChf = (amount: number) =>
|
||||||
}, [limit, loadTransactions]);
|
new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount);
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
return [
|
||||||
setLimit(prev => prev + 50);
|
{
|
||||||
};
|
key: 'sysCreatedAt',
|
||||||
|
label: t('Datum'),
|
||||||
|
type: 'date',
|
||||||
|
sortable: true,
|
||||||
|
filterable: false,
|
||||||
|
searchable: true,
|
||||||
|
width: 170,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mandateName',
|
||||||
|
label: t('Mandant'),
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'transactionType',
|
||||||
|
label: t('Typ'),
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 130,
|
||||||
|
formatter: (_v, row: BillingTransaction) => (
|
||||||
|
<span className={`${styles.transactionType} ${typePillClass(row.transactionType)}`}>
|
||||||
|
{typeLabel(row.transactionType, t)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
label: t('Beschreibung'),
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
minWidth: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'aicoreProvider',
|
||||||
|
label: t('Anbieter'),
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'aicoreModel',
|
||||||
|
label: t('Modell'),
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'featureCode',
|
||||||
|
label: t('Feature'),
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 110,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
label: t('Betrag'),
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
width: 120,
|
||||||
|
formatter: (v, row: BillingTransaction) => {
|
||||||
|
const n = Number(v);
|
||||||
|
const abs = fmtChf(Math.abs(n));
|
||||||
|
const prefix = row.transactionType === 'DEBIT' ? '-' : '+';
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'block', textAlign: 'right' }}>
|
||||||
|
{prefix}
|
||||||
|
{abs}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.billingDashboard}>
|
<div className={styles.billingDashboard}>
|
||||||
|
|
@ -103,47 +153,26 @@ export const BillingTransactions: React.FC = () => {
|
||||||
<BillingNav />
|
<BillingNav />
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
{loading && transactions.length === 0 ? (
|
<FormGeneratorTable<BillingTransaction>
|
||||||
<div className={styles.loadingPlaceholder}>{t('Transaktionen laden')}</div>
|
data={transactions}
|
||||||
) : transactions.length === 0 ? (
|
columns={columns}
|
||||||
<div className={styles.noData}>{t('Keine Transaktionen vorhanden')}</div>
|
apiEndpoint="/api/billing/transactions"
|
||||||
) : (
|
tableContextKey="billing/transactions"
|
||||||
<>
|
loading={loading}
|
||||||
<div style={{ overflowX: 'auto' }}>
|
pagination={true}
|
||||||
<table className={styles.transactionsTable}>
|
pageSize={25}
|
||||||
<thead>
|
searchable={true}
|
||||||
<tr>
|
filterable={true}
|
||||||
<th>Datum</th>
|
sortable={true}
|
||||||
<th>{t('Mandant')}</th>
|
selectable={false}
|
||||||
<th>Typ</th>
|
hookData={{
|
||||||
<th>{t('Beschreibung')}</th>
|
refetch: refetchTransactions,
|
||||||
<th>Anbieter</th>
|
pagination: transactionsPagination ?? undefined,
|
||||||
<th>Modell</th>
|
groupLayout: transactionsGroupLayout ?? undefined,
|
||||||
<th>Feature</th>
|
appliedView: transactionsAppliedView ?? undefined,
|
||||||
<th style={{ textAlign: 'right' }}>{t('Betrag')}</th>
|
}}
|
||||||
</tr>
|
emptyMessage={t('Keine Transaktionen vorhanden')}
|
||||||
</thead>
|
/>
|
||||||
<tbody>
|
|
||||||
{transactions.map((transaction) => (
|
|
||||||
<TransactionRow key={transaction.id} transaction={transaction} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{transactions.length >= limit && (
|
|
||||||
<div style={{ textAlign: 'center', marginTop: 'var(--spacing-md)' }}>
|
|
||||||
<button
|
|
||||||
className={`${styles.button} ${styles.buttonSecondary}`}
|
|
||||||
onClick={handleLoadMore}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? t('Laden') : t('Mehr laden')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
271
src/pages/billing/EnterpriseDialog.tsx
Normal file
271
src/pages/billing/EnterpriseDialog.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Modal } from '../../components/UiComponents/Modal';
|
||||||
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
|
export type EnterpriseDialogMode = 'create' | 'renew' | 'update';
|
||||||
|
|
||||||
|
export interface EnterpriseDialogData {
|
||||||
|
mandateId?: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
mandateName?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
autoRenew?: boolean;
|
||||||
|
flatPriceCHF?: number;
|
||||||
|
maxUsers?: number | null;
|
||||||
|
maxFeatureInstances?: number | null;
|
||||||
|
maxDataVolumeMB?: number | null;
|
||||||
|
budgetAiCHF?: number | null;
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MandateOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnterpriseDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
mode: EnterpriseDialogMode;
|
||||||
|
data: EnterpriseDialogData;
|
||||||
|
mandates?: MandateOption[];
|
||||||
|
loading?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (mode: EnterpriseDialogMode, values: Record<string, any>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _formatDateForInput = (iso?: string): string => {
|
||||||
|
if (!iso) return '';
|
||||||
|
try {
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _todayStr = (): string => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const _oneYearLaterStr = (): string => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setFullYear(d.getFullYear() + 1);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EnterpriseDialog: React.FC<EnterpriseDialogProps> = ({
|
||||||
|
open, mode, data, mandates, loading, onClose, onSubmit,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const [mandateId, setMandateId] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState(_todayStr());
|
||||||
|
const [endDate, setEndDate] = useState(_oneYearLaterStr());
|
||||||
|
const [autoRenew, setAutoRenew] = useState(true);
|
||||||
|
const [flatPrice, setFlatPrice] = useState<string>('');
|
||||||
|
const [maxUsers, setMaxUsers] = useState<string>('');
|
||||||
|
const [maxFeatureInstances, setMaxFeatureInstances] = useState<string>('');
|
||||||
|
const [maxDataVolumeMB, setMaxDataVolumeMB] = useState<string>('');
|
||||||
|
const [budgetAiCHF, setBudgetAiCHF] = useState<string>('');
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setMandateId(data.mandateId || '');
|
||||||
|
setStartDate(data.startDate ? _formatDateForInput(data.startDate) : _todayStr());
|
||||||
|
setEndDate(data.endDate ? _formatDateForInput(data.endDate) : _oneYearLaterStr());
|
||||||
|
setAutoRenew(data.autoRenew ?? true);
|
||||||
|
setFlatPrice(data.flatPriceCHF != null ? String(data.flatPriceCHF) : '');
|
||||||
|
setMaxUsers(data.maxUsers != null ? String(data.maxUsers) : '');
|
||||||
|
setMaxFeatureInstances(data.maxFeatureInstances != null ? String(data.maxFeatureInstances) : '');
|
||||||
|
setMaxDataVolumeMB(data.maxDataVolumeMB != null ? String(data.maxDataVolumeMB) : '');
|
||||||
|
setBudgetAiCHF(data.budgetAiCHF != null ? String(data.budgetAiCHF) : '');
|
||||||
|
setNote(data.note ?? '');
|
||||||
|
setErrorMsg(null);
|
||||||
|
}, [open, data]);
|
||||||
|
|
||||||
|
const _handleSubmit = useCallback(async () => {
|
||||||
|
setErrorMsg(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const values: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (mode === 'create') {
|
||||||
|
if (!mandateId) { setErrorMsg(t('Mandant ist erforderlich')); setSubmitting(false); return; }
|
||||||
|
if (!flatPrice) { setErrorMsg(t('Pauschalpreis ist erforderlich')); setSubmitting(false); return; }
|
||||||
|
values.mandateId = mandateId;
|
||||||
|
values.startDate = Math.floor(new Date(startDate).getTime() / 1000);
|
||||||
|
values.endDate = Math.floor(new Date(endDate).getTime() / 1000);
|
||||||
|
values.autoRenew = autoRenew;
|
||||||
|
values.flatPriceCHF = parseFloat(flatPrice);
|
||||||
|
if (maxUsers) values.maxUsers = parseInt(maxUsers, 10);
|
||||||
|
if (maxFeatureInstances) values.maxFeatureInstances = parseInt(maxFeatureInstances, 10);
|
||||||
|
if (maxDataVolumeMB) values.maxDataVolumeMB = parseInt(maxDataVolumeMB, 10);
|
||||||
|
if (budgetAiCHF) values.budgetAiCHF = parseFloat(budgetAiCHF);
|
||||||
|
if (note.trim()) values.note = note.trim();
|
||||||
|
} else if (mode === 'renew') {
|
||||||
|
if (!data.subscriptionId) return;
|
||||||
|
values.subscriptionId = data.subscriptionId;
|
||||||
|
values.newEndDate = Math.floor(new Date(endDate).getTime() / 1000);
|
||||||
|
values.autoRenew = autoRenew;
|
||||||
|
if (flatPrice) values.flatPriceCHF = parseFloat(flatPrice);
|
||||||
|
if (maxUsers) values.maxUsers = parseInt(maxUsers, 10);
|
||||||
|
if (maxFeatureInstances) values.maxFeatureInstances = parseInt(maxFeatureInstances, 10);
|
||||||
|
if (maxDataVolumeMB) values.maxDataVolumeMB = parseInt(maxDataVolumeMB, 10);
|
||||||
|
if (budgetAiCHF) values.budgetAiCHF = parseFloat(budgetAiCHF);
|
||||||
|
if (note.trim()) values.note = note.trim();
|
||||||
|
} else if (mode === 'update') {
|
||||||
|
if (!data.subscriptionId) return;
|
||||||
|
values.subscriptionId = data.subscriptionId;
|
||||||
|
if (flatPrice) values.enterpriseFlatPriceCHF = parseFloat(flatPrice);
|
||||||
|
if (maxUsers) values.enterpriseMaxUsers = parseInt(maxUsers, 10);
|
||||||
|
if (maxFeatureInstances) values.enterpriseMaxFeatureInstances = parseInt(maxFeatureInstances, 10);
|
||||||
|
if (maxDataVolumeMB) values.enterpriseMaxDataVolumeMB = parseInt(maxDataVolumeMB, 10);
|
||||||
|
if (budgetAiCHF) values.enterpriseBudgetAiCHF = parseFloat(budgetAiCHF);
|
||||||
|
if (note.trim()) values.enterpriseNote = note.trim();
|
||||||
|
values.recurring = autoRenew;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubmit(mode, values);
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setErrorMsg(err?.response?.data?.detail || err?.message || t('Fehler'));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [mode, mandateId, startDate, endDate, autoRenew, flatPrice, maxUsers, maxFeatureInstances, maxDataVolumeMB, budgetAiCHF, note, data, onSubmit, onClose, t]);
|
||||||
|
|
||||||
|
const _title: Record<EnterpriseDialogMode, string> = {
|
||||||
|
create: t('Enterprise-Abo erstellen'),
|
||||||
|
renew: t('Enterprise-Abo erneuern'),
|
||||||
|
update: t('Enterprise-Abo anpassen'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const _submitLabel: Record<EnterpriseDialogMode, string> = {
|
||||||
|
create: t('Erstellen'),
|
||||||
|
renew: t('Erneuern'),
|
||||||
|
update: t('Speichern'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCreate = mode === 'create';
|
||||||
|
const isUpdate = mode === 'update';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={_title[mode]} size="lg" closeOnEscape>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
{errorMsg && <div className={styles.errorMessage}>{errorMsg}</div>}
|
||||||
|
|
||||||
|
{data.mandateName && !isCreate && (
|
||||||
|
<div style={{ fontSize: '0.9rem', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('Mandant:')} <strong>{data.mandateName}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCreate && (
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>{t('Mandant')}</label>
|
||||||
|
<select
|
||||||
|
className={styles.input}
|
||||||
|
value={mandateId}
|
||||||
|
onChange={(e) => setMandateId(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<option value="">{t('— Mandant wählen —')}</option>
|
||||||
|
{(mandates || []).map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isUpdate && (
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
{isCreate && (
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>{t('Startdatum')}</label>
|
||||||
|
<input type="date" className={styles.input} value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>{isCreate ? t('Enddatum') : t('Neues Enddatum')}</label>
|
||||||
|
<input type="date" className={styles.input} value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>{t('Pauschalpreis (CHF)')}</label>
|
||||||
|
<input type="number" step="0.01" min="0" className={styles.input}
|
||||||
|
value={flatPrice} onChange={(e) => setFlatPrice(e.target.value)}
|
||||||
|
placeholder={isUpdate ? t('Unverändert') : ''} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup} style={{ maxWidth: 160 }}>
|
||||||
|
<label>{t('Automatisch erneuern')}</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', height: '2.25rem' }}>
|
||||||
|
<input type="checkbox" checked={autoRenew} onChange={(e) => setAutoRenew(e.target.checked)}
|
||||||
|
style={{ width: 18, height: 18, accentColor: 'var(--primary-color, #F25843)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>{t('Max. Benutzer')}</label>
|
||||||
|
<input type="number" min="1" className={styles.input}
|
||||||
|
value={maxUsers} onChange={(e) => setMaxUsers(e.target.value)}
|
||||||
|
placeholder={isUpdate ? t('Unverändert') : t('Unbegrenzt')} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>{t('Max. Module')}</label>
|
||||||
|
<input type="number" min="0" className={styles.input}
|
||||||
|
value={maxFeatureInstances} onChange={(e) => setMaxFeatureInstances(e.target.value)}
|
||||||
|
placeholder={isUpdate ? t('Unverändert') : t('Unbegrenzt')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>{t('Speicher (MB)')}</label>
|
||||||
|
<input type="number" min="0" className={styles.input}
|
||||||
|
value={maxDataVolumeMB} onChange={(e) => setMaxDataVolumeMB(e.target.value)}
|
||||||
|
placeholder={isUpdate ? t('Unverändert') : t('Unbegrenzt')} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>{t('AI-Budget (CHF)')}</label>
|
||||||
|
<input type="number" step="0.01" min="0" className={styles.input}
|
||||||
|
value={budgetAiCHF} onChange={(e) => setBudgetAiCHF(e.target.value)}
|
||||||
|
placeholder={isUpdate ? t('Unverändert') : t('Kein Budget')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>{t('Notiz')}</label>
|
||||||
|
<textarea className={styles.input} rows={2}
|
||||||
|
value={note} onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder={t('Optionale interne Notiz')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1.25rem' }}>
|
||||||
|
<button
|
||||||
|
className={`${styles.button} ${styles.buttonSecondary}`}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{t('Abbrechen')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
onClick={_handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? t('Wird gespeichert…') : _submitLabel[mode]}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnterpriseDialog;
|
||||||
|
|
@ -53,6 +53,9 @@ function _getPeriodMap(t: (k: string) => string): Record<string, string> {
|
||||||
|
|
||||||
const _storageOveragePerGbMonth = 0.5;
|
const _storageOveragePerGbMonth = 0.5;
|
||||||
|
|
||||||
|
const _isEnterpriseSub = (sub: MandateSubscription | null): boolean =>
|
||||||
|
!!sub && (sub.isEnterprise === true || sub.planKey === 'ENTERPRISE');
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Plan Card
|
// Plan Card
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -266,6 +269,12 @@ const _SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, usage, label, onCance
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<strong style={{ fontSize: '1.15rem' }}>{plan ? plan.title : sub.planKey}</strong>
|
<strong style={{ fontSize: '1.15rem' }}>{plan ? plan.title : sub.planKey}</strong>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
{_isEnterpriseSub(sub) && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.65rem', padding: '2px 10px', borderRadius: '12px',
|
||||||
|
background: 'rgba(139,92,246,0.15)', color: '#8b5cf6', fontWeight: 600,
|
||||||
|
}}>Enterprise</span>
|
||||||
|
)}
|
||||||
{isActive && !sub.recurring && (
|
{isActive && !sub.recurring && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '0.7rem', padding: '2px 10px', borderRadius: '12px',
|
fontSize: '0.7rem', padding: '2px 10px', borderRadius: '12px',
|
||||||
|
|
@ -320,8 +329,31 @@ const _SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, usage, label, onCance
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Plan details */}
|
{/* Plan details — enterprise vs. standard */}
|
||||||
{plan && !isPending && !isScheduled && (
|
{!isPending && !isScheduled && _isEnterpriseSub(sub) && (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.3rem 1.5rem',
|
||||||
|
fontSize: '0.85rem', color: 'var(--text-secondary)',
|
||||||
|
paddingTop: '0.5rem', borderTop: '1px solid var(--color-border, rgba(255,255,255,0.06))',
|
||||||
|
}}>
|
||||||
|
<span>{t('Pauschalpreis:')} {_formatCurrency(sub.enterpriseFlatPriceCHF ?? 0)}</span>
|
||||||
|
<span>{t('Max. Benutzer:')} {sub.enterpriseMaxUsers != null ? sub.enterpriseMaxUsers : t('unbegrenzt')}</span>
|
||||||
|
<span>{t('Max. Module:')} {sub.enterpriseMaxFeatureInstances != null ? sub.enterpriseMaxFeatureInstances : t('unbegrenzt')}</span>
|
||||||
|
<span>
|
||||||
|
{t('Speicher:')}{' '}
|
||||||
|
{sub.enterpriseMaxDataVolumeMB != null
|
||||||
|
? formatBinaryDataSizeFromMebibytes(sub.enterpriseMaxDataVolumeMB)
|
||||||
|
: t('unbegrenzt')}
|
||||||
|
</span>
|
||||||
|
{sub.enterpriseBudgetAiCHF != null && (
|
||||||
|
<span>{t('AI-Budget:')} {_formatCurrency(sub.enterpriseBudgetAiCHF)}</span>
|
||||||
|
)}
|
||||||
|
{sub.enterpriseNote && (
|
||||||
|
<span style={{ gridColumn: '1 / -1', fontStyle: 'italic' }}>{sub.enterpriseNote}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isPending && !isScheduled && !_isEnterpriseSub(sub) && plan && (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.3rem 1.5rem',
|
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.3rem 1.5rem',
|
||||||
fontSize: '0.85rem', color: 'var(--text-secondary)',
|
fontSize: '0.85rem', color: 'var(--text-secondary)',
|
||||||
|
|
@ -356,43 +388,60 @@ const _SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, usage, label, onCance
|
||||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
}}>
|
}}>
|
||||||
<_UsageMetric label={t('User')} value={usage.activeUsers} max={plan?.maxUsers ?? undefined} />
|
<_UsageMetric
|
||||||
<_UsageMetric label={t('Module')} value={usage.activeInstances} max={plan?.includedModules ?? undefined} />
|
label={t('User')}
|
||||||
|
value={usage.activeUsers}
|
||||||
|
max={_isEnterpriseSub(sub) ? (sub.enterpriseMaxUsers ?? undefined) : (plan?.maxUsers ?? undefined)}
|
||||||
|
/>
|
||||||
|
<_UsageMetric
|
||||||
|
label={t('Module')}
|
||||||
|
value={usage.activeInstances}
|
||||||
|
max={_isEnterpriseSub(sub) ? (sub.enterpriseMaxFeatureInstances ?? undefined) : (plan?.includedModules ?? undefined)}
|
||||||
|
/>
|
||||||
<_UsageMetric label={t('Speicher')} value={usage.usedStorageMB} max={usage.maxStorageMB ?? undefined} formatValue={(v) => formatBinaryDataSizeFromMebibytes(v)} />
|
<_UsageMetric label={t('Speicher')} value={usage.usedStorageMB} max={usage.maxStorageMB ?? undefined} formatValue={(v) => formatBinaryDataSizeFromMebibytes(v)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions — enterprise subscriptions are sysadmin-managed, no self-service */}
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.25rem' }}>
|
{_isEnterpriseSub(sub) ? (
|
||||||
{isActive && !sub.recurring && onReactivate && (
|
<div style={{
|
||||||
<button onClick={() => onReactivate(sub.id)} disabled={reactivating} style={{
|
fontSize: '0.8rem', color: 'var(--text-secondary)',
|
||||||
padding: '8px 16px', borderRadius: '8px', border: 'none',
|
fontStyle: 'italic', marginTop: '0.25rem',
|
||||||
background: 'var(--primary-color, #F25843)', color: '#fff',
|
}}>
|
||||||
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
|
{t('Dieses Abonnement wird vom Plattform-Administrator verwaltet.')}
|
||||||
}}>
|
</div>
|
||||||
{reactivating ? t('Wird reaktiviert…') : t('Reaktivieren')}
|
) : (
|
||||||
</button>
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.25rem' }}>
|
||||||
)}
|
{isActive && !sub.recurring && onReactivate && (
|
||||||
{isActive && sub.recurring && onCancel && (
|
<button onClick={() => onReactivate(sub.id)} disabled={reactivating} style={{
|
||||||
<button onClick={() => onCancel(sub.id)} disabled={cancelling} style={{
|
padding: '8px 16px', borderRadius: '8px', border: 'none',
|
||||||
padding: '8px 16px', borderRadius: '8px',
|
background: 'var(--primary-color, #F25843)', color: '#fff',
|
||||||
border: '1px solid #ef4444', background: 'transparent',
|
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
|
||||||
color: '#ef4444', fontWeight: 500, cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
|
}}>
|
||||||
}}>
|
{reactivating ? t('Wird reaktiviert…') : t('Reaktivieren')}
|
||||||
{cancelling ? t('Wird gekündigt…') : t('Kündigen')}
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
{isActive && sub.recurring && onCancel && (
|
||||||
{(isPending || isScheduled) && onCancel && (
|
<button onClick={() => onCancel(sub.id)} disabled={cancelling} style={{
|
||||||
<button onClick={() => onCancel(sub.id)} disabled={cancelling} style={{
|
padding: '8px 16px', borderRadius: '8px',
|
||||||
padding: '8px 16px', borderRadius: '8px',
|
border: '1px solid #ef4444', background: 'transparent',
|
||||||
border: '1px solid #ef4444', background: 'transparent',
|
color: '#ef4444', fontWeight: 500, cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
|
||||||
color: '#ef4444', fontWeight: 500, cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
|
}}>
|
||||||
}}>
|
{cancelling ? t('Wird gekündigt…') : t('Kündigen')}
|
||||||
{cancelling ? t('Wird abgebrochen…') : t('Abbrechen')}
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
{(isPending || isScheduled) && onCancel && (
|
||||||
</div>
|
<button onClick={() => onCancel(sub.id)} disabled={cancelling} style={{
|
||||||
|
padding: '8px 16px', borderRadius: '8px',
|
||||||
|
border: '1px solid #ef4444', background: 'transparent',
|
||||||
|
color: '#ef4444', fontWeight: 500, cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
|
||||||
|
}}>
|
||||||
|
{cancelling ? t('Wird abgebrochen…') : t('Abbrechen')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -593,29 +642,31 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Available plans */}
|
{/* Available plans — hidden for enterprise subscriptions */}
|
||||||
<section className={styles.section}>
|
{!_isEnterpriseSub(subscription) && (
|
||||||
<h2 className={styles.sectionTitle}>{t('Verfügbare Pläne')}</h2>
|
<section className={styles.section}>
|
||||||
{plans.length === 0 ? (
|
<h2 className={styles.sectionTitle}>{t('Verfügbare Pläne')}</h2>
|
||||||
<div className={styles.noData}>{t('Keine Pläne verfügbar')}</div>
|
{plans.length === 0 ? (
|
||||||
) : (
|
<div className={styles.noData}>{t('Keine Pläne verfügbar')}</div>
|
||||||
<div style={{
|
) : (
|
||||||
display: 'grid',
|
<div style={{
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
display: 'grid',
|
||||||
gap: '1.25rem',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
}}>
|
gap: '1.25rem',
|
||||||
{plans.map((p) => (
|
}}>
|
||||||
<_PlanCard
|
{plans.map((p) => (
|
||||||
key={p.planKey}
|
<_PlanCard
|
||||||
plan={p}
|
key={p.planKey}
|
||||||
isCurrent={subscription?.planKey === p.planKey && subscription?.status === 'ACTIVE'}
|
plan={p}
|
||||||
onActivate={_handleActivate}
|
isCurrent={subscription?.planKey === p.planKey && subscription?.status === 'ACTIVE'}
|
||||||
activatingPlanKey={activatingPlanKey}
|
onActivate={_handleActivate}
|
||||||
/>
|
activatingPlanKey={activatingPlanKey}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</section>
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
349
src/pages/views/commcoach/Commcoach.module.css
Normal file
349
src/pages/views/commcoach/Commcoach.module.css
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
/* CommCoach Shared Styles — Assistant, Modules, Session views */
|
||||||
|
|
||||||
|
.assistantContainer,
|
||||||
|
.modulesContainer {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepIndicator {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border-color, #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepActive {
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardContent {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardStep {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid var(--border-color, #ddd);
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeCard:hover {
|
||||||
|
border-color: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeCardActive {
|
||||||
|
border-color: var(--primary-color, #F25843);
|
||||||
|
background: rgba(242, 88, 67, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeIcon {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardInput {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
background: var(--bg-input, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardTextarea {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
background: var(--bg-input, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardHint {
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmSummary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--surface-color, #f5f5f5);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorBanner {
|
||||||
|
background: rgba(241, 76, 76, 0.1);
|
||||||
|
color: #f14c4c;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesFilters {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesFilters select {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
background: var(--bg-input, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleCard {
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleExpanded {
|
||||||
|
border-color: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleHighlighted {
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleRow:hover {
|
||||||
|
background: var(--bg-hover, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleType {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(242, 88, 67, 0.1);
|
||||||
|
color: var(--primary-color, #F25843);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleTitle {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleStatus {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleSessions {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionList {
|
||||||
|
padding: 0.5rem 0 0 1rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionStatus {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionDate {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noSessions {
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmOverlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmDialog,
|
||||||
|
.editDialog {
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary:hover:not(:disabled) { filter: brightness(1.08); }
|
||||||
|
.btnPrimary:disabled {
|
||||||
|
background: var(--color-medium-gray, #ccc);
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSecondary {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSecondary:hover:not(:disabled) { background: var(--hover-bg, #f5f5f5); border-color: var(--primary-color, #F25843); color: var(--primary-color, #F25843); }
|
||||||
|
|
||||||
|
.btnDanger {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: #f14c4c;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnDanger:hover:not(:disabled) { filter: brightness(1.08); }
|
||||||
|
|
||||||
|
.btnSmall {
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSmall:hover:not(:disabled) { border-color: var(--primary-color, #F25843); color: var(--primary-color, #F25843); }
|
||||||
|
|
||||||
|
.btnSmall.btnSmallActive {
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
border-color: var(--primary-color, #F25843);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSmall.btnSmallActive:hover:not(:disabled) { filter: brightness(1.08); color: #fff; }
|
||||||
|
|
||||||
|
.btnSmallDanger {
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #f14c4c;
|
||||||
|
background: transparent;
|
||||||
|
color: #f14c4c;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSmallDanger:hover:not(:disabled) { background: var(--error-color, #dc2626); color: #fff; }
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
174
src/pages/views/commcoach/CommcoachAssistantView.tsx
Normal file
174
src/pages/views/commcoach/CommcoachAssistantView.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
/**
|
||||||
|
* CommCoach Assistant View
|
||||||
|
*
|
||||||
|
* Wizard flow: Module type → Topic → Persona → KPIs → "Start first session"
|
||||||
|
*/
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
|
import * as commcoachApi from '../../../api/commcoachApi';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import styles from './Commcoach.module.css';
|
||||||
|
|
||||||
|
type WizardStep = 'type' | 'topic' | 'persona' | 'kpis' | 'confirm';
|
||||||
|
|
||||||
|
const STEPS: WizardStep[] = ['type', 'topic', 'persona', 'kpis', 'confirm'];
|
||||||
|
|
||||||
|
const MODULE_TYPES = [
|
||||||
|
{ value: 'coaching', label: 'Coaching', icon: '🎯' },
|
||||||
|
{ value: 'training', label: 'Training', icon: '📚' },
|
||||||
|
{ value: 'exam', label: 'Prüfung', icon: '✍️' },
|
||||||
|
{ value: 'elearning', label: 'E-Learning', icon: '💻' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CommcoachAssistantView: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { instance, mandateId } = useCurrentInstance();
|
||||||
|
const instanceId = instance?.id || '';
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<WizardStep>('type');
|
||||||
|
const [moduleType, setModuleType] = useState('coaching');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [goals, setGoals] = useState('');
|
||||||
|
const [personaId, setPersonaId] = useState<string | null>(null);
|
||||||
|
const [_personas, _setPersonas] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const stepIdx = STEPS.indexOf(step);
|
||||||
|
|
||||||
|
const _handleNext = () => {
|
||||||
|
const nextIdx = stepIdx + 1;
|
||||||
|
if (nextIdx < STEPS.length) setStep(STEPS[nextIdx]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleBack = () => {
|
||||||
|
const prevIdx = stepIdx - 1;
|
||||||
|
if (prevIdx >= 0) setStep(STEPS[prevIdx]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleCreate = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError(t('Bitte einen Titel eingeben'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const apiRequest = commcoachApi.getApiRequest();
|
||||||
|
const module = await commcoachApi.createModuleApi(apiRequest, instanceId, {
|
||||||
|
title: title.trim(),
|
||||||
|
moduleType,
|
||||||
|
goals: goals.trim() || undefined,
|
||||||
|
personaId: personaId || undefined,
|
||||||
|
});
|
||||||
|
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/session?moduleId=${module.id}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || t('Fehler beim Erstellen'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.assistantContainer}>
|
||||||
|
<div className={styles.wizardHeader}>
|
||||||
|
<h2>{t('Neues Modul erstellen')}</h2>
|
||||||
|
<div className={styles.stepIndicator}>
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||||
|
|
||||||
|
<div className={styles.wizardContent}>
|
||||||
|
{step === 'type' && (
|
||||||
|
<div className={styles.wizardStep}>
|
||||||
|
<h3>{t('Modul-Typ wählen')}</h3>
|
||||||
|
<div className={styles.typeGrid}>
|
||||||
|
{MODULE_TYPES.map(mt => (
|
||||||
|
<button
|
||||||
|
key={mt.value}
|
||||||
|
className={`${styles.typeCard} ${moduleType === mt.value ? styles.typeCardActive : ''}`}
|
||||||
|
onClick={() => setModuleType(mt.value)}
|
||||||
|
>
|
||||||
|
<span className={styles.typeIcon}>{mt.icon}</span>
|
||||||
|
<span>{t(mt.label)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'topic' && (
|
||||||
|
<div className={styles.wizardStep}>
|
||||||
|
<h3>{t('Thema & Titel')}</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.wizardInput}
|
||||||
|
placeholder={t('z.B. Konfliktgespräche, Sales Training...')}
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className={styles.wizardTextarea}
|
||||||
|
placeholder={t('Ziele beschreiben (optional)')}
|
||||||
|
value={goals}
|
||||||
|
onChange={e => setGoals(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'persona' && (
|
||||||
|
<div className={styles.wizardStep}>
|
||||||
|
<h3>{t('Persona wählen (optional)')}</h3>
|
||||||
|
<p className={styles.wizardHint}>{t('Eine Persona bestimmt den Coaching-Stil. Du kannst dies später ändern.')}</p>
|
||||||
|
<button
|
||||||
|
className={`${styles.typeCard} ${!personaId ? styles.typeCardActive : ''}`}
|
||||||
|
onClick={() => setPersonaId(null)}
|
||||||
|
>
|
||||||
|
{t('Standard (kein Persona-Override)')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'kpis' && (
|
||||||
|
<div className={styles.wizardStep}>
|
||||||
|
<h3>{t('KPIs (optional)')}</h3>
|
||||||
|
<p className={styles.wizardHint}>{t('KPI-Ziele können später in den Modul-Einstellungen definiert werden.')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'confirm' && (
|
||||||
|
<div className={styles.wizardStep}>
|
||||||
|
<h3>{t('Zusammenfassung')}</h3>
|
||||||
|
<div className={styles.confirmSummary}>
|
||||||
|
<div><strong>{t('Typ')}:</strong> {MODULE_TYPES.find(m => m.value === moduleType)?.label}</div>
|
||||||
|
<div><strong>{t('Titel')}:</strong> {title || t('(kein Titel)')}</div>
|
||||||
|
{goals && <div><strong>{t('Ziele')}:</strong> {goals}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.wizardActions}>
|
||||||
|
{stepIdx > 0 && (
|
||||||
|
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{step !== 'confirm' ? (
|
||||||
|
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
|
||||||
|
) : (
|
||||||
|
<button className={styles.btnPrimary} onClick={_handleCreate} disabled={loading}>
|
||||||
|
{loading ? t('Erstelle...') : t('Modul erstellen & erste Session starten')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -19,15 +19,15 @@ export const CommcoachDashboardView: React.FC = () => {
|
||||||
const { mandateId, instanceId } = useCurrentInstance();
|
const { mandateId, instanceId } = useCurrentInstance();
|
||||||
const { dashboard, loading, error } = useCommcoachDashboard();
|
const { dashboard, loading, error } = useCommcoachDashboard();
|
||||||
|
|
||||||
const handleContextClick = (contextId: string) => {
|
const handleModuleClick = (moduleId: string) => {
|
||||||
if (mandateId && instanceId) {
|
if (mandateId && instanceId) {
|
||||||
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/coaching?context=${contextId}`);
|
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/modules?moduleId=${moduleId}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const _handleNewTopic = useCallback(() => {
|
const _handleNewTopic = useCallback(() => {
|
||||||
if (mandateId && instanceId) {
|
if (mandateId && instanceId) {
|
||||||
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/coaching?newContext=true`);
|
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/assistant`);
|
||||||
}
|
}
|
||||||
}, [mandateId, instanceId, navigate]);
|
}, [mandateId, instanceId, navigate]);
|
||||||
|
|
||||||
|
|
@ -100,25 +100,25 @@ export const CommcoachDashboardView: React.FC = () => {
|
||||||
+ {t('Neues Thema')}
|
+ {t('Neues Thema')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{dashboard.contexts.length === 0 ? (
|
{(dashboard.modules || dashboard.contexts || []).length === 0 ? (
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<p>{t('Noch keine Coaching-Themen angelegt.')}</p>
|
<p>{t('Noch keine Coaching-Themen angelegt.')}</p>
|
||||||
<p>{t('Klicken Sie auf "Neues Thema" um zu starten.')}</p>
|
<p>{t('Klicken Sie auf "Neues Thema" um zu starten.')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.contextGrid}>
|
<div className={styles.contextGrid}>
|
||||||
{dashboard.contexts.map(ctx => (
|
{(dashboard.modules || dashboard.contexts || []).map(ctx => (
|
||||||
<div
|
<div
|
||||||
key={ctx.id}
|
key={ctx.id}
|
||||||
className={styles.contextCard}
|
className={styles.contextCard}
|
||||||
onClick={() => handleContextClick(ctx.id)}
|
onClick={() => handleModuleClick(ctx.id)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={e => e.key === 'Enter' && handleContextClick(ctx.id)}
|
onKeyDown={e => e.key === 'Enter' && handleModuleClick(ctx.id)}
|
||||||
>
|
>
|
||||||
<div className={styles.contextTitle}>{ctx.title}</div>
|
<div className={styles.contextTitle}>{ctx.title}</div>
|
||||||
<div className={styles.contextMeta}>
|
<div className={styles.contextMeta}>
|
||||||
<span className={styles.contextCategory}>{_categoryLabel(ctx.category)}</span>
|
<span className={styles.contextCategory}>{_categoryLabel(ctx.moduleType)}</span>
|
||||||
<span>
|
<span>
|
||||||
{ctx.sessionCount} {t('Sessions')}
|
{ctx.sessionCount} {t('Sessions')}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@
|
||||||
|
|
||||||
.udbSidebar {
|
.udbSidebar {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
min-width: 280px;
|
min-width: 180px;
|
||||||
border-right: 1px solid var(--border-color, #e0e0e0);
|
border-right: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-card, #fff);
|
background: var(--bg-card, #fff);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: width 0.2s, min-width 0.2s;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.udbSidebarCollapsed {
|
.udbSidebarCollapsed {
|
||||||
|
|
@ -22,6 +22,20 @@
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.udbResizeHandle {
|
||||||
|
width: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: col-resize;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbResizeHandle:hover,
|
||||||
|
.udbResizeHandle:active {
|
||||||
|
background: var(--accent-color, #4a90d9);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dossierLayout {
|
.dossierLayout {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,12 @@ type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
|
||||||
interface CommcoachDossierViewProps {
|
interface CommcoachDossierViewProps {
|
||||||
persistentInstanceId?: string;
|
persistentInstanceId?: string;
|
||||||
persistentMandateId?: string;
|
persistentMandateId?: string;
|
||||||
|
initialModuleId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ persistentInstanceId,
|
export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ persistentInstanceId,
|
||||||
persistentMandateId,
|
persistentMandateId,
|
||||||
|
initialModuleId: _initialModuleId,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const _toolPayloadForDisplay = (payload: Record<string, unknown>): string => {
|
const _toolPayloadForDisplay = (payload: Record<string, unknown>): string => {
|
||||||
|
|
@ -83,6 +85,8 @@ export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ pers
|
||||||
const [newCategory, setNewCategory] = useState('custom');
|
const [newCategory, setNewCategory] = useState('custom');
|
||||||
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||||
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||||
|
const [udbWidth, setUdbWidth] = useState(280);
|
||||||
|
const udbResizing = useRef(false);
|
||||||
|
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||||
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
||||||
|
|
@ -271,7 +275,10 @@ export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ pers
|
||||||
<div className={styles.dossierLayout}>
|
<div className={styles.dossierLayout}>
|
||||||
{/* UDB Sidebar */}
|
{/* UDB Sidebar */}
|
||||||
{_udbContext && (
|
{_udbContext && (
|
||||||
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
|
<div
|
||||||
|
className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}
|
||||||
|
style={udbCollapsed ? undefined : { width: udbWidth, minWidth: 180 }}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
className={styles.udbToggle}
|
className={styles.udbToggle}
|
||||||
onClick={() => setUdbCollapsed(v => !v)}
|
onClick={() => setUdbCollapsed(v => !v)}
|
||||||
|
|
@ -280,12 +287,35 @@ export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ pers
|
||||||
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
||||||
</button>
|
</button>
|
||||||
{!udbCollapsed && (
|
{!udbCollapsed && (
|
||||||
<UnifiedDataBar
|
<>
|
||||||
context={_udbContext}
|
<UnifiedDataBar
|
||||||
activeTab={udbTab}
|
context={_udbContext}
|
||||||
onTabChange={setUdbTab}
|
activeTab={udbTab}
|
||||||
hideTabs={['chats']}
|
onTabChange={setUdbTab}
|
||||||
/>
|
hideTabs={['chats']}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={styles.udbResizeHandle}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
udbResizing.current = true;
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startW = udbWidth;
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
if (!udbResizing.current) return;
|
||||||
|
const newW = Math.max(180, Math.min(600, startW + (ev.clientX - startX)));
|
||||||
|
setUdbWidth(newW);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
udbResizing.current = false;
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
/**
|
/**
|
||||||
* CommcoachKeepAlive
|
* CommcoachKeepAlive
|
||||||
*
|
*
|
||||||
* Keeps the CommCoach dossier/coaching page mounted across route changes.
|
* Keeps the CommCoach session page mounted across route changes.
|
||||||
* Visibility is toggled via CSS so session state, messages, and input state
|
* The voice session must persist when the user navigates to other tabs.
|
||||||
* stay alive when the user leaves and later returns.
|
* Only the "session" tab is kept alive; modules/dashboard can unmount freely.
|
||||||
*
|
*
|
||||||
* Persistence is scoped per `(mandateId, instanceId)` — switching to a
|
* Persistence is scoped per `(mandateId, instanceId)` — switching to a
|
||||||
* different mandate or instance via the navigator unmounts the previous
|
* different mandate or instance unmounts the previous view.
|
||||||
* view and mounts a fresh one.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { CommcoachDossierView } from './CommcoachDossierView';
|
import { CommcoachSessionView } from './CommcoachSessionView';
|
||||||
|
|
||||||
const _COMMCOACH_ROUTE_RE = /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/(?:coaching|dossier)/;
|
const _COMMCOACH_SESSION_ROUTE_RE = /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/session/;
|
||||||
|
|
||||||
interface CommcoachKeepAliveProps {
|
interface CommcoachKeepAliveProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
|
@ -25,7 +24,7 @@ export const CommcoachKeepAlive: React.FC<CommcoachKeepAliveProps> = ({ isVisibl
|
||||||
const cachedMandateIdRef = useRef<string>('');
|
const cachedMandateIdRef = useRef<string>('');
|
||||||
const cachedInstanceIdRef = useRef<string>('');
|
const cachedInstanceIdRef = useRef<string>('');
|
||||||
|
|
||||||
const match = location.pathname.match(_COMMCOACH_ROUTE_RE);
|
const match = location.pathname.match(_COMMCOACH_SESSION_ROUTE_RE);
|
||||||
if (match?.[1] && match?.[2]) {
|
if (match?.[1] && match?.[2]) {
|
||||||
cachedMandateIdRef.current = match[1];
|
cachedMandateIdRef.current = match[1];
|
||||||
cachedInstanceIdRef.current = match[2];
|
cachedInstanceIdRef.current = match[2];
|
||||||
|
|
@ -48,11 +47,7 @@ export const CommcoachKeepAlive: React.FC<CommcoachKeepAliveProps> = ({ isVisibl
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CommcoachDossierView
|
<CommcoachSessionView key={scopeKey} />
|
||||||
key={scopeKey}
|
|
||||||
persistentInstanceId={instanceId}
|
|
||||||
persistentMandateId={mandateId}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
304
src/pages/views/commcoach/CommcoachModulesView.tsx
Normal file
304
src/pages/views/commcoach/CommcoachModulesView.tsx
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
/**
|
||||||
|
* CommCoach Modules View
|
||||||
|
*
|
||||||
|
* CRUD list of all TrainingModules, filterable by status/type.
|
||||||
|
* Each module row expands to show its sessions.
|
||||||
|
* Edit dialog includes persona multi-select for module-persona mapping.
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
|
import * as commcoachApi from '../../../api/commcoachApi';
|
||||||
|
import { getSessionExportUrl } from '../../../api/commcoachApi';
|
||||||
|
import type { CoachingPersona } from '../../../api/commcoachApi';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import styles from './Commcoach.module.css';
|
||||||
|
|
||||||
|
const MODULE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
coaching: 'Coaching',
|
||||||
|
training: 'Training',
|
||||||
|
exam: 'Prüfung',
|
||||||
|
elearning: 'E-Learning',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Aktiv',
|
||||||
|
paused: 'Pausiert',
|
||||||
|
archived: 'Archiviert',
|
||||||
|
completed: 'Abgeschlossen',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommcoachModulesView: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { instance, mandateId } = useCurrentInstance();
|
||||||
|
const instanceId = instance?.id || '';
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const highlightModuleId = searchParams.get('moduleId');
|
||||||
|
|
||||||
|
const [modules, setModules] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(highlightModuleId);
|
||||||
|
const [sessions, setSessions] = useState<Record<string, any[]>>({});
|
||||||
|
const [editingModule, setEditingModule] = useState<any | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
const [filterType, setFilterType] = useState<string>('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>('');
|
||||||
|
|
||||||
|
const [allPersonas, setAllPersonas] = useState<CoachingPersona[]>([]);
|
||||||
|
const [editPersonaIds, setEditPersonaIds] = useState<string[]>([]);
|
||||||
|
const [personasLoaded, setPersonasLoaded] = useState(false);
|
||||||
|
|
||||||
|
const _loadModules = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const apiRequest = commcoachApi.getApiRequest();
|
||||||
|
const result = await commcoachApi.listModulesApi(apiRequest, instanceId);
|
||||||
|
setModules(result || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load modules:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => { _loadModules(); }, [_loadModules]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId || personasLoaded) return;
|
||||||
|
const _loadAllPersonas = async () => {
|
||||||
|
try {
|
||||||
|
const apiRequest = commcoachApi.getApiRequest();
|
||||||
|
const personas = await commcoachApi.getPersonasApi(apiRequest, instanceId);
|
||||||
|
setAllPersonas(personas);
|
||||||
|
setPersonasLoaded(true);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
_loadAllPersonas();
|
||||||
|
}, [instanceId, personasLoaded]);
|
||||||
|
|
||||||
|
const _loadSessions = useCallback(async (moduleId: string) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
const apiRequest = commcoachApi.getApiRequest();
|
||||||
|
const result = await commcoachApi.listSessionsApi(apiRequest, instanceId, moduleId);
|
||||||
|
setSessions(prev => ({ ...prev, [moduleId]: result || [] }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load sessions:', err);
|
||||||
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
const _toggleExpand = (moduleId: string) => {
|
||||||
|
if (expandedId === moduleId) {
|
||||||
|
setExpandedId(null);
|
||||||
|
} else {
|
||||||
|
setExpandedId(moduleId);
|
||||||
|
if (!sessions[moduleId]) _loadSessions(moduleId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleDelete = async (moduleId: string) => {
|
||||||
|
try {
|
||||||
|
const apiRequest = commcoachApi.getApiRequest();
|
||||||
|
await commcoachApi.deleteModuleApi(apiRequest, instanceId, moduleId);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
_loadModules();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleEdit = async (moduleId: string, updates: any) => {
|
||||||
|
try {
|
||||||
|
const apiRequest = commcoachApi.getApiRequest();
|
||||||
|
await commcoachApi.updateModuleApi(apiRequest, instanceId, moduleId, updates);
|
||||||
|
await commcoachApi.setModulePersonasApi(apiRequest, instanceId, moduleId, editPersonaIds);
|
||||||
|
setEditingModule(null);
|
||||||
|
_loadModules();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _openEditDialog = async (mod: any) => {
|
||||||
|
setEditingModule(mod);
|
||||||
|
try {
|
||||||
|
const apiRequest = commcoachApi.getApiRequest();
|
||||||
|
const ids = await commcoachApi.getModulePersonasApi(apiRequest, instanceId, mod.id);
|
||||||
|
setEditPersonaIds(ids);
|
||||||
|
} catch {
|
||||||
|
setEditPersonaIds([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _togglePersonaId = (personaId: string) => {
|
||||||
|
setEditPersonaIds(prev =>
|
||||||
|
prev.includes(personaId)
|
||||||
|
? prev.filter(id => id !== personaId)
|
||||||
|
: [...prev, personaId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredModules = modules.filter(m => {
|
||||||
|
if (filterType && m.moduleType !== filterType) return false;
|
||||||
|
if (filterStatus && m.status !== filterStatus) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.modulesContainer}>
|
||||||
|
<div className={styles.modulesHeader}>
|
||||||
|
<h2>{t('Module')}</h2>
|
||||||
|
<div className={styles.modulesFilters}>
|
||||||
|
<select value={filterType} onChange={e => setFilterType(e.target.value)}>
|
||||||
|
<option value="">{t('Alle Typen')}</option>
|
||||||
|
{Object.entries(MODULE_TYPE_LABELS).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{t(v)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||||
|
<option value="">{t('Alle Status')}</option>
|
||||||
|
{Object.entries(STATUS_LABELS).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{t(v)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.btnPrimary}
|
||||||
|
onClick={() => navigate(`/mandates/${mandateId}/commcoach/${instanceId}/assistant`)}
|
||||||
|
>
|
||||||
|
{t('Neues Modul')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className={styles.loading}>{t('Laden...')}</div>}
|
||||||
|
|
||||||
|
<div className={styles.modulesList}>
|
||||||
|
{filteredModules.map(mod => (
|
||||||
|
<div
|
||||||
|
key={mod.id}
|
||||||
|
className={`${styles.moduleCard} ${expandedId === mod.id ? styles.moduleExpanded : ''} ${highlightModuleId === mod.id ? styles.moduleHighlighted : ''}`}
|
||||||
|
>
|
||||||
|
<div className={styles.moduleRow} onClick={() => _toggleExpand(mod.id)}>
|
||||||
|
<span className={styles.moduleType}>{t(MODULE_TYPE_LABELS[mod.moduleType] || mod.moduleType)}</span>
|
||||||
|
<span className={styles.moduleTitle}>{mod.title}</span>
|
||||||
|
<span className={styles.moduleStatus}>{t(STATUS_LABELS[mod.status] || mod.status)}</span>
|
||||||
|
<span className={styles.moduleSessions}>{mod.sessionCount || 0} {t('Sessions')}</span>
|
||||||
|
<div className={styles.moduleActions}>
|
||||||
|
<button className={styles.btnPrimary} style={{ padding: '0.3rem 0.7rem', fontSize: '0.8rem' }} onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/session?moduleId=${mod.id}`);
|
||||||
|
}}>{t('Session starten')}</button>
|
||||||
|
<button className={styles.btnSmall} onClick={e => { e.stopPropagation(); _openEditDialog(mod); }}>{t('Bearbeiten')}</button>
|
||||||
|
<button className={styles.btnSmallDanger} onClick={e => { e.stopPropagation(); setDeleteConfirm(mod.id); }}>{t('Löschen')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedId === mod.id && (
|
||||||
|
<div className={styles.moduleSessions}>
|
||||||
|
{(sessions[mod.id] || []).length === 0 ? (
|
||||||
|
<p className={styles.noSessions}>{t('Keine Sessions vorhanden')}</p>
|
||||||
|
) : (
|
||||||
|
<div className={styles.sessionList}>
|
||||||
|
{(sessions[mod.id] || []).map((sess: any) => (
|
||||||
|
<div key={sess.id} className={styles.sessionRow}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<span className={styles.sessionStatus}>{sess.status === 'completed' ? t('Abgeschlossen') : sess.status === 'active' ? t('Aktiv') : sess.status}</span>
|
||||||
|
<span className={styles.sessionDate}>
|
||||||
|
{sess.startedAt ? new Date(sess.startedAt * 1000).toLocaleDateString('de-CH') : '-'}
|
||||||
|
</span>
|
||||||
|
{sess.competenceScore != null && <span style={{ fontSize: '0.8rem', fontWeight: 600 }}>Score: {Math.round(sess.competenceScore)}</span>}
|
||||||
|
{sess.durationSeconds != null && <span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)' }}>{Math.round(sess.durationSeconds / 60)} Min.</span>}
|
||||||
|
{sess.messageCount != null && <span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)' }}>{sess.messageCount} {t('Nachrichten')}</span>}
|
||||||
|
{instanceId && sess.status === 'completed' && (
|
||||||
|
<a style={{ fontSize: '0.8rem' }} href={getSessionExportUrl(instanceId, sess.id, 'md')} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()}>{t('Export')}</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sess.summary && <div style={{ fontSize: '0.85rem', marginTop: '0.3rem', color: 'var(--text-secondary, #666)' }}>{sess.summary}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div className={styles.confirmOverlay}>
|
||||||
|
<div className={styles.confirmDialog}>
|
||||||
|
<p>{t('Modul und alle zugehörigen Sessions wirklich löschen?')}</p>
|
||||||
|
<div className={styles.confirmActions}>
|
||||||
|
<button className={styles.btnSecondary} onClick={() => setDeleteConfirm(null)}>{t('Abbrechen')}</button>
|
||||||
|
<button className={styles.btnDanger} onClick={() => _handleDelete(deleteConfirm)}>{t('Löschen')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingModule && (
|
||||||
|
<div className={styles.confirmOverlay}>
|
||||||
|
<div className={styles.editDialog}>
|
||||||
|
<h3>{t('Modul bearbeiten')}</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={editingModule.title}
|
||||||
|
className={styles.wizardInput}
|
||||||
|
onBlur={e => setEditingModule({ ...editingModule, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
defaultValue={editingModule.goals || ''}
|
||||||
|
className={styles.wizardTextarea}
|
||||||
|
placeholder={t('Ziele')}
|
||||||
|
rows={3}
|
||||||
|
onBlur={e => setEditingModule({ ...editingModule, goals: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Persona Multi-Select */}
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<label className={styles.wizardLabel} style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>
|
||||||
|
{t('Verfuegbare Gespraechspartner')}
|
||||||
|
</label>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
|
||||||
|
{t('Waehle, welche Gespraechspartner in Sessions dieses Moduls zur Verfuegung stehen. Ohne Auswahl sind alle verfuegbar.')}
|
||||||
|
</p>
|
||||||
|
<div style={{
|
||||||
|
maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border-color, #ddd)',
|
||||||
|
borderRadius: 6, padding: '0.5rem',
|
||||||
|
}}>
|
||||||
|
{allPersonas.filter(p => p.isActive).map(p => (
|
||||||
|
<label key={p.id} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.3rem 0',
|
||||||
|
cursor: 'pointer', fontSize: '0.85rem',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editPersonaIds.includes(p.id)}
|
||||||
|
onChange={() => _togglePersonaId(p.id)}
|
||||||
|
/>
|
||||||
|
<span>{p.label}</span>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #888)' }}>
|
||||||
|
({p.category === 'builtin' ? t('System') : t('Eigene')})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.confirmActions}>
|
||||||
|
<button className={styles.btnSecondary} onClick={() => setEditingModule(null)}>{t('Abbrechen')}</button>
|
||||||
|
<button className={styles.btnPrimary} onClick={() => _handleEdit(editingModule.id, {
|
||||||
|
title: editingModule.title,
|
||||||
|
goals: editingModule.goals,
|
||||||
|
})}>{t('Speichern')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
610
src/pages/views/commcoach/CommcoachSessionView.tsx
Normal file
610
src/pages/views/commcoach/CommcoachSessionView.tsx
Normal file
|
|
@ -0,0 +1,610 @@
|
||||||
|
/**
|
||||||
|
* CommCoach Session View (Refactored)
|
||||||
|
*
|
||||||
|
* Shows ONLY the active coaching session: Chat, Voice, TTS, Agent activity.
|
||||||
|
* Three states:
|
||||||
|
* 1. No module selected -> hint with links to Assistant / Modules
|
||||||
|
* 2. Module selected, no session -> persona picker + "Start session"
|
||||||
|
* 3. Session active -> full chat/voice/TTS interface
|
||||||
|
*
|
||||||
|
* Reachable via Assistant wizard or Modules page ("Session starten" button).
|
||||||
|
* KeepAlive-wrapped — voice sessions persist across tab switches.
|
||||||
|
*/
|
||||||
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useCommcoach } from '../../../hooks/useCommcoach';
|
||||||
|
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
||||||
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
|
||||||
|
import {
|
||||||
|
getPersonasApi, getModulePersonasApi,
|
||||||
|
type CoachingPersona,
|
||||||
|
type SendMessageOptions,
|
||||||
|
} from '../../../api/commcoachApi';
|
||||||
|
import api from '../../../api';
|
||||||
|
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||||
|
import type { UdbContext, UdbTab, AddToChat_FileItem } from '../../../components/UnifiedDataBar';
|
||||||
|
import { _defaultProviderSelection } from '../../../components/ProviderSelector';
|
||||||
|
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
||||||
|
import { getPageIcon } from '../../../config/pageRegistry';
|
||||||
|
import styles from './CommcoachDossierView.module.css';
|
||||||
|
import sessionStyles from './Commcoach.module.css';
|
||||||
|
import { useVoiceController } from './useVoiceController';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
interface WorkspaceFileInfo { id: string; fileName: string; mimeType: string; fileSize: number; }
|
||||||
|
interface DataSourceInfo { id: string; connectionId: string; sourceType: string; path: string; label: string; }
|
||||||
|
interface FeatureDataSourceInfo { id: string; featureInstanceId: string; featureCode: string; tableName: string; label: string; }
|
||||||
|
|
||||||
|
function _formatToolPayload(payload: Record<string, unknown>): string {
|
||||||
|
try {
|
||||||
|
const s = JSON.stringify(payload, null, 0);
|
||||||
|
return s.length > 120 ? s.substring(0, 120) + '...' : s;
|
||||||
|
} catch { return '[unlesbar]'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommcoachSessionView: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
const mandateId = useMandateId();
|
||||||
|
const coach = useCommcoach(instanceId);
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const moduleId = searchParams.get('moduleId');
|
||||||
|
|
||||||
|
const isSessionRoute = /\/commcoach\/[^/]+\/session/.test(location.pathname);
|
||||||
|
|
||||||
|
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||||||
|
const [modulePersonaIds, setModulePersonaIds] = useState<string[] | null>(null);
|
||||||
|
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
||||||
|
const [showAgentActivity, setShowAgentActivity] = useState(true);
|
||||||
|
|
||||||
|
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||||
|
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||||
|
const [udbWidth, setUdbWidth] = useState(280);
|
||||||
|
const udbResizing = useRef(false);
|
||||||
|
|
||||||
|
const [wsFiles, setWsFiles] = useState<WorkspaceFileInfo[]>([]);
|
||||||
|
const [wsDataSources, setWsDataSources] = useState<DataSourceInfo[]>([]);
|
||||||
|
const [wsFeatureDataSources, setWsFeatureDataSources] = useState<FeatureDataSourceInfo[]>([]);
|
||||||
|
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
|
||||||
|
const [attachedDsIds, setAttachedDsIds] = useState<string[]>([]);
|
||||||
|
const [attachedFdsIds, setAttachedFdsIds] = useState<string[]>([]);
|
||||||
|
const [providerSelection, _setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection);
|
||||||
|
const [_showFilePicker, setShowFilePicker] = useState(false);
|
||||||
|
const [_showSourcePicker, setShowSourcePicker] = useState(false);
|
||||||
|
|
||||||
|
const _udbContext: UdbContext | null = instanceId
|
||||||
|
? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const sendMessageRef = useRef(coach.sendMessage);
|
||||||
|
sendMessageRef.current = coach.sendMessage;
|
||||||
|
const attachedFileIdsRef = useRef(attachedFileIds);
|
||||||
|
attachedFileIdsRef.current = attachedFileIds;
|
||||||
|
const attachedDsIdsRef = useRef(attachedDsIds);
|
||||||
|
attachedDsIdsRef.current = attachedDsIds;
|
||||||
|
const attachedFdsIdsRef = useRef(attachedFdsIds);
|
||||||
|
attachedFdsIdsRef.current = attachedFdsIds;
|
||||||
|
const providerSelRef = useRef(providerSelection);
|
||||||
|
providerSelRef.current = providerSelection;
|
||||||
|
|
||||||
|
const voice = useVoiceController({
|
||||||
|
onFinalText: (text) => {
|
||||||
|
const opts: SendMessageOptions = {};
|
||||||
|
if (attachedFileIdsRef.current.length) opts.fileIds = attachedFileIdsRef.current;
|
||||||
|
if (attachedDsIdsRef.current.length) opts.dataSourceIds = attachedDsIdsRef.current;
|
||||||
|
if (attachedFdsIdsRef.current.length) opts.featureDataSourceIds = attachedFdsIdsRef.current;
|
||||||
|
const allowed = providerSelRef.current.include.length > 0 ? providerSelRef.current.include : undefined;
|
||||||
|
if (allowed) opts.allowedProviders = allowed;
|
||||||
|
sendMessageRef.current(text, Object.keys(opts).length ? opts : undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
coach.onTtsEventRef.current = (event: TtsEvent) => {
|
||||||
|
if (event === 'playing') voice.ttsPlaying();
|
||||||
|
else if (event === 'ended') voice.ttsEnded();
|
||||||
|
else if (event === 'paused') voice.ttsPaused();
|
||||||
|
else if (event === 'error') voice.ttsEnded();
|
||||||
|
};
|
||||||
|
return () => { coach.onTtsEventRef.current = null; };
|
||||||
|
}, [coach.onTtsEventRef, voice.ttsPlaying, voice.ttsEnded, voice.ttsPaused]);
|
||||||
|
|
||||||
|
// Auto-select module from URL param
|
||||||
|
useEffect(() => {
|
||||||
|
if (moduleId && coach.contexts.length > 0 && coach.selectedContextId !== moduleId) {
|
||||||
|
const found = coach.contexts.find(c => c.id === moduleId);
|
||||||
|
if (found) coach.selectContext(moduleId);
|
||||||
|
} else if (!moduleId && !coach.selectedContextId && coach.contexts.length > 0) {
|
||||||
|
coach.selectContext(coach.contexts[0].id, { skipSessionResume: true });
|
||||||
|
}
|
||||||
|
}, [moduleId, coach.contexts, coach.selectedContextId, coach.selectContext]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId || !isSessionRoute) return;
|
||||||
|
getPersonasApi(request, instanceId).then(p => setPersonas(p)).catch(() => {});
|
||||||
|
}, [instanceId, request, isSessionRoute]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId || !coach.selectedContextId || !isSessionRoute) { setModulePersonaIds(null); return; }
|
||||||
|
getModulePersonasApi(request, instanceId, coach.selectedContextId)
|
||||||
|
.then(ids => setModulePersonaIds(ids.length > 0 ? ids : null))
|
||||||
|
.catch(() => setModulePersonaIds(null));
|
||||||
|
}, [instanceId, request, coach.selectedContextId, isSessionRoute]);
|
||||||
|
|
||||||
|
const _refreshWorkspaceAssets = useCallback(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
api.get(`/api/workspace/${instanceId}/files`).then(r => setWsFiles(r.data.files || [])).catch(() => {});
|
||||||
|
api.get(`/api/workspace/${instanceId}/datasources`).then(r => setWsDataSources(r.data.dataSources || [])).catch(() => {});
|
||||||
|
api.get(`/api/workspace/${instanceId}/feature-datasources`).then(r => setWsFeatureDataSources(r.data.featureDataSources || [])).catch(() => {});
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => { _refreshWorkspaceAssets(); }, [_refreshWorkspaceAssets]);
|
||||||
|
useEffect(() => {
|
||||||
|
const h = () => _refreshWorkspaceAssets();
|
||||||
|
window.addEventListener('fileUploaded', h);
|
||||||
|
return () => window.removeEventListener('fileUploaded', h);
|
||||||
|
}, [_refreshWorkspaceAssets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!coach.session) {
|
||||||
|
voice.deactivate();
|
||||||
|
} else if (voice.state === 'idle') {
|
||||||
|
voice.activate();
|
||||||
|
}
|
||||||
|
}, [coach.session?.id, voice]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
coach.onDocumentCreatedRef.current = () => {
|
||||||
|
window.dispatchEvent(new CustomEvent('fileUploaded', { detail: { source: 'commcoachDocument' } }));
|
||||||
|
};
|
||||||
|
return () => { coach.onDocumentCreatedRef.current = null; };
|
||||||
|
}, [coach, _refreshWorkspaceAssets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (coach.agentToolCalls.length > 0) setShowAgentActivity(true);
|
||||||
|
}, [coach.agentToolCalls.length]);
|
||||||
|
|
||||||
|
const handleStopTts = useCallback(() => { coach.stopTts(); voice.ttsStopped(); }, [coach, voice]);
|
||||||
|
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
|
||||||
|
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
||||||
|
|
||||||
|
const handleSend = useCallback(async () => {
|
||||||
|
if (!coach.inputValue.trim() || coach.isStreaming) return;
|
||||||
|
const opts: SendMessageOptions = {};
|
||||||
|
if (attachedFileIds.length) opts.fileIds = attachedFileIds;
|
||||||
|
if (attachedDsIds.length) opts.dataSourceIds = attachedDsIds;
|
||||||
|
if (attachedFdsIds.length) opts.featureDataSourceIds = attachedFdsIds;
|
||||||
|
const allowed = providerSelection.include.length > 0 ? providerSelection.include : undefined;
|
||||||
|
if (allowed) opts.allowedProviders = allowed;
|
||||||
|
await coach.sendMessage(coach.inputValue, Object.keys(opts).length ? opts : undefined);
|
||||||
|
setAttachedFileIds([]);
|
||||||
|
setShowSourcePicker(false);
|
||||||
|
setShowFilePicker(false);
|
||||||
|
}, [coach, attachedFileIds, attachedDsIds, attachedFdsIds, providerSelection]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
||||||
|
}, [handleSend]);
|
||||||
|
|
||||||
|
const _toggleFile = useCallback((fileId: string) => {
|
||||||
|
setAttachedFileIds(prev => prev.includes(fileId) ? prev.filter(id => id !== fileId) : [...prev, fileId]);
|
||||||
|
}, []);
|
||||||
|
const _toggleDs = useCallback((dsId: string) => {
|
||||||
|
setAttachedDsIds(prev => prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId]);
|
||||||
|
}, []);
|
||||||
|
const _toggleFds = useCallback((fdsId: string) => {
|
||||||
|
setAttachedFdsIds(prev => prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const attachedFileNamesRef = useRef<Record<string, string>>({});
|
||||||
|
|
||||||
|
const _handleUdbFileSelect = useCallback((fileId: string, fileName?: string) => {
|
||||||
|
if (fileName) attachedFileNamesRef.current[fileId] = fileName;
|
||||||
|
setAttachedFileIds(prev => prev.includes(fileId) ? prev : [...prev, fileId]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleUdbSendToChat = useCallback((items: AddToChat_FileItem[]) => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.name) attachedFileNamesRef.current[item.id] = item.name;
|
||||||
|
}
|
||||||
|
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
|
||||||
|
setAttachedFileIds(prev => {
|
||||||
|
const merged = [...prev];
|
||||||
|
for (const fId of fileIds) {
|
||||||
|
if (!merged.includes(fId)) merged.push(fId);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _isTreeDrag = useCallback((e: React.DragEvent) => {
|
||||||
|
return e.dataTransfer.types.includes('application/tree-items') ||
|
||||||
|
e.dataTransfer.types.includes('application/file-id') ||
|
||||||
|
e.dataTransfer.types.includes('application/file-ids');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleInputDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
if (_isTreeDrag(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
}, [_isTreeDrag]);
|
||||||
|
|
||||||
|
const _handleInputDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
const treeJson = e.dataTransfer.getData('application/tree-items');
|
||||||
|
if (treeJson) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
const items: AddToChat_FileItem[] = JSON.parse(treeJson);
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.name) attachedFileNamesRef.current[item.id] = item.name;
|
||||||
|
}
|
||||||
|
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
|
||||||
|
setAttachedFileIds(prev => {
|
||||||
|
const merged = [...prev];
|
||||||
|
for (const fId of fileIds) {
|
||||||
|
if (!merged.includes(fId)) merged.push(fId);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fileId = e.dataTransfer.getData('application/file-id');
|
||||||
|
if (fileId) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const name = e.dataTransfer.getData('text/plain');
|
||||||
|
if (name) attachedFileNamesRef.current[fileId] = name;
|
||||||
|
setAttachedFileIds(prev => prev.includes(fileId) ? prev : [...prev, fileId]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _toolPayloadForDisplay = (payload: Record<string, unknown>): string => {
|
||||||
|
const formatted = _formatToolPayload(payload);
|
||||||
|
return formatted === '[unlesbar]' ? t('[unlesbar]') : formatted;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (coach.loadingContexts) {
|
||||||
|
return <div className={sessionStyles.loading}>{t('Laden...')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== STATE 1: No module selected ==========
|
||||||
|
if (!coach.selectedContextId && coach.contexts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={sessionStyles.modulesContainer} style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<h3>{t('Keine aktive Session')}</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary, #666)', textAlign: 'center', maxWidth: 400 }}>
|
||||||
|
{t('Erstelle zuerst ein Modul ueber den Assistenten oder starte eine Session ueber die Module-Seite.')}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
|
||||||
|
<button className={sessionStyles.btnPrimary}
|
||||||
|
onClick={() => navigate(`/mandates/${mandateId}/commcoach/${instanceId}/assistant`)}
|
||||||
|
>{t('Zum Assistenten')}</button>
|
||||||
|
<button className={sessionStyles.btnSecondary}
|
||||||
|
onClick={() => navigate(`/mandates/${mandateId}/commcoach/${instanceId}/modules`)}
|
||||||
|
>{t('Zu den Modulen')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== STATE 2: Module selected, no active session ==========
|
||||||
|
if (coach.selectedContextId && !coach.session) {
|
||||||
|
return (
|
||||||
|
<div className={sessionStyles.modulesContainer}>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<h3>{coach.selectedContext?.title || t('Modul')}</h3>
|
||||||
|
{coach.selectedContext?.description && (
|
||||||
|
<p style={{ color: 'var(--text-secondary, #666)', marginTop: '0.25rem' }}>{coach.selectedContext.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{personas.length > 0 && (() => {
|
||||||
|
const availablePersonas = modulePersonaIds
|
||||||
|
? personas.filter(p => modulePersonaIds.includes(p.id))
|
||||||
|
: personas;
|
||||||
|
return availablePersonas.length > 0 ? (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.5rem' }}>{t('Gespraechspartner waehlen')}</label>
|
||||||
|
{modulePersonaIds && (
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
|
||||||
|
{t('Fuer dieses Modul sind bestimmte Gespraechspartner konfiguriert.')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
|
{availablePersonas.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
className={`${sessionStyles.btnSmall} ${selectedPersonaId === p.id ? sessionStyles.btnSmallActive : ''}`}
|
||||||
|
onClick={() => setSelectedPersonaId(selectedPersonaId === p.id ? undefined : p.id)}
|
||||||
|
title={p.description}
|
||||||
|
>
|
||||||
|
<span>{p.gender === 'f' ? '\u2640' : p.gender === 'm' ? '\u2642' : '\u25CB'}</span>
|
||||||
|
{' '}{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={sessionStyles.btnPrimary}
|
||||||
|
onClick={() => coach.startSession(selectedPersonaId)}
|
||||||
|
disabled={!!coach.actionLoading}
|
||||||
|
style={{ alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{coach.actionLoading === 'starting'
|
||||||
|
? t('Wird gestartet...')
|
||||||
|
: selectedPersonaId && personas.find(p => p.id === selectedPersonaId)
|
||||||
|
? `${t('Session starten mit')} ${personas.find(p => p.id === selectedPersonaId)!.label}`
|
||||||
|
: t('Session starten')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{coach.error && <div className={sessionStyles.errorBanner}>{coach.error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== STATE 3: Active session ==========
|
||||||
|
return (
|
||||||
|
<div className={styles.dossierLayout}>
|
||||||
|
{/* UDB Sidebar */}
|
||||||
|
{_udbContext && (
|
||||||
|
<div
|
||||||
|
className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}
|
||||||
|
style={udbCollapsed ? undefined : { width: udbWidth, minWidth: 180 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={styles.udbToggle}
|
||||||
|
onClick={() => setUdbCollapsed(v => !v)}
|
||||||
|
title={udbCollapsed ? t('Seitenleiste einblenden') : t('Seitenleiste ausblenden')}
|
||||||
|
>
|
||||||
|
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
||||||
|
</button>
|
||||||
|
{!udbCollapsed && (
|
||||||
|
<UnifiedDataBar
|
||||||
|
context={_udbContext}
|
||||||
|
activeTab={udbTab}
|
||||||
|
onTabChange={setUdbTab}
|
||||||
|
hideTabs={['chats']}
|
||||||
|
onFileSelect={_handleUdbFileSelect}
|
||||||
|
onSendToChat_Files={_handleUdbSendToChat}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{_udbContext && !udbCollapsed && (
|
||||||
|
<div
|
||||||
|
className={styles.udbResizeHandle}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
udbResizing.current = true;
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startW = udbWidth;
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
if (!udbResizing.current) return;
|
||||||
|
const newW = Math.max(180, Math.min(600, startW + (ev.clientX - startX)));
|
||||||
|
setUdbWidth(newW);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
udbResizing.current = false;
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Session Content */}
|
||||||
|
<div className={styles.dossier}>
|
||||||
|
{/* Session Header */}
|
||||||
|
<div className={styles.sessionHeader}>
|
||||||
|
<span className={styles.sessionLabel}>
|
||||||
|
{coach.selectedContext?.title ? `${coach.selectedContext.title} — ` : ''}
|
||||||
|
{t('Session aktiv')}
|
||||||
|
</span>
|
||||||
|
<div className={styles.sessionActions}>
|
||||||
|
{voice.state === 'botSpeaking' && (
|
||||||
|
<>
|
||||||
|
<button className={styles.btnSmall} onClick={handlePauseTts}>Pause</button>
|
||||||
|
<button className={styles.btnSmallDanger} onClick={handleStopTts}>Stop</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{voice.state === 'interrupted' && coach.hasAudioToResume() && (
|
||||||
|
<button className={styles.btnSmall} onClick={handleResumeTts}>{t('Weitersprechen')}</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`${styles.btnSmall} ${voice.muted ? styles.mutedActive : ''}`}
|
||||||
|
onClick={voice.toggleMute}
|
||||||
|
title={voice.muted ? t('Stummschaltung aufheben') : t('stummschalten')}
|
||||||
|
>
|
||||||
|
{voice.muted ? t('Stumm') : t('Ton an')}
|
||||||
|
</button>
|
||||||
|
<button className={styles.btnSmall} onClick={coach.completeSession} disabled={!!coach.actionLoading}>
|
||||||
|
{coach.actionLoading === 'completing' ? t('wird abgeschlossen') : t('abschliessen')}
|
||||||
|
</button>
|
||||||
|
<button className={styles.btnSmallDanger} onClick={coach.cancelSession} disabled={!!coach.actionLoading}>
|
||||||
|
{coach.actionLoading === 'cancelling' ? t('wird abgebrochen') : t('Abbrechen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{coach.error && <div className={styles.errorBanner || sessionStyles.errorBanner}>{coach.error}</div>}
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<AutoScroll scrollDependency={coach.messages.length + (coach.isStreaming ? 1 : 0) + voice.liveTranscript.length}>
|
||||||
|
<div className={styles.messages}>
|
||||||
|
{coach.messages.map(msg => (
|
||||||
|
<div key={msg.id} className={`${styles.message} ${msg.role === 'user' ? styles.messageUser : styles.messageAssistant}`}>
|
||||||
|
<div className={styles.messageBubble}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
<div className={styles.messageTime}>
|
||||||
|
{msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{voice.liveTranscript && (
|
||||||
|
<div className={`${styles.message} ${styles.messageUser}`}>
|
||||||
|
<div className={`${styles.messageBubble} ${styles.messageLive}`}>{voice.liveTranscript}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{coach.isStreaming && (
|
||||||
|
<div className={`${styles.message} ${styles.messageAssistant}`}>
|
||||||
|
<div className={styles.messageBubble}>
|
||||||
|
{coach.streamingMessage ? (
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{coach.streamingMessage}</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
<div className={styles.typing}>{coach.streamingStatus || t('Coach denkt nach')}<span className={styles.typingDots}>...</span></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AutoScroll>
|
||||||
|
|
||||||
|
{/* Agent Activity Panel */}
|
||||||
|
{(coach.isStreaming || coach.agentToolCalls.length > 0) && (
|
||||||
|
<div className={styles.agentActivityPanel}>
|
||||||
|
<button
|
||||||
|
className={styles.agentActivityHeader}
|
||||||
|
onClick={() => setShowAgentActivity(prev => !prev)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className={styles.agentActivityTitle}>
|
||||||
|
{t('Agent-Aktivitaet')}
|
||||||
|
{coach.agentToolCalls.length > 0 ? ` (${coach.agentToolCalls.length})` : ''}
|
||||||
|
</span>
|
||||||
|
<span className={styles.agentActivityStatus}>
|
||||||
|
{coach.streamingStatus || (coach.agentToolCalls.length > 0 ? t('Toolaufrufe vorhanden') : t('Warte auf Agent'))}
|
||||||
|
</span>
|
||||||
|
<span className={styles.agentActivityChevron}>{showAgentActivity ? '\u25BE' : '\u25B8'}</span>
|
||||||
|
</button>
|
||||||
|
{showAgentActivity && (
|
||||||
|
<div className={styles.agentActivityBody}>
|
||||||
|
{coach.agentToolCalls.length === 0 ? (
|
||||||
|
<div className={styles.agentActivityEmpty}>{t('Noch keine Tool-Aufrufe in dieser Antwort.')}</div>
|
||||||
|
) : (
|
||||||
|
coach.agentToolCalls.map((toolCall, idx) => (
|
||||||
|
<div key={`${toolCall.toolName}-${idx}`} className={styles.agentActivityItem}>
|
||||||
|
<div className={styles.agentActivityItemHeader}>
|
||||||
|
<span className={styles.agentActivityToolName}>{toolCall.toolName}</span>
|
||||||
|
<span className={`${styles.agentActivityBadge} ${
|
||||||
|
toolCall.success === true ? styles.agentActivityBadgeSuccess
|
||||||
|
: toolCall.success === false ? styles.agentActivityBadgeError
|
||||||
|
: styles.agentActivityBadgeRunning
|
||||||
|
}`}>
|
||||||
|
{toolCall.success === true ? t('fertig') : toolCall.success === false ? t('fehler') : t('laeuft')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{toolCall.args && (
|
||||||
|
<div className={styles.agentActivityMeta}>
|
||||||
|
<strong>{t('Argumente:')}</strong> {_toolPayloadForDisplay(toolCall.args)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toolCall.result && (
|
||||||
|
<div className={styles.agentActivityMeta}>
|
||||||
|
<strong>{t('Ergebnis:')}</strong> {toolCall.result}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<div className={styles.inputArea} onDragOver={_handleInputDragOver} onDrop={_handleInputDrop}>
|
||||||
|
<div className={styles.voiceStatus}>
|
||||||
|
<span className={`${styles.voiceIndicator} ${voice.state === 'listening' ? styles.voiceActive : ''}`}>
|
||||||
|
{voice.muted
|
||||||
|
? t('Stumm - Mikrofon aus')
|
||||||
|
: voice.state === 'botSpeaking'
|
||||||
|
? (coach.streamingStatus || t('Coach spricht...'))
|
||||||
|
: coach.isStreaming
|
||||||
|
? (coach.streamingStatus || t('Coach denkt nach...'))
|
||||||
|
: voice.state === 'interrupted'
|
||||||
|
? t('Unterbrochen - Mikrofon an')
|
||||||
|
: voice.state === 'listening'
|
||||||
|
? (voice.liveTranscript ? t('Spricht...') : t('Mikrofon an - bitte sprechen'))
|
||||||
|
: t('Mikrofon wird gestartet...')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attachment Chips */}
|
||||||
|
{(attachedFileIds.length > 0 || attachedDsIds.length > 0 || attachedFdsIds.length > 0) && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', paddingBottom: 4 }}>
|
||||||
|
{attachedFileIds.map(fId => {
|
||||||
|
const file = wsFiles.find(f => f.id === fId);
|
||||||
|
const displayName = file?.fileName || attachedFileNamesRef.current[fId] || fId;
|
||||||
|
return (
|
||||||
|
<span key={fId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: '#e3f2fd', color: '#1565c0', fontWeight: 500 }}>
|
||||||
|
{displayName}
|
||||||
|
<button onClick={() => _toggleFile(fId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#1565c0', padding: 0, lineHeight: 1 }}>x</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{attachedDsIds.map(dsId => {
|
||||||
|
const ds = wsDataSources.find(d => d.id === dsId);
|
||||||
|
return (
|
||||||
|
<span key={dsId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: '#e8f5e9', color: '#2e7d32', fontWeight: 500 }}>
|
||||||
|
{ds?.label || ds?.path || dsId}
|
||||||
|
<button onClick={() => _toggleDs(dsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1 }}>x</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{attachedFdsIds.map(fdsId => {
|
||||||
|
const fds = wsFeatureDataSources.find(d => d.id === fdsId);
|
||||||
|
return (
|
||||||
|
<span key={fdsId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500 }}>
|
||||||
|
<span style={{ fontSize: 12 }}>{fds ? getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F' : '\uD83D\uDDC3\uFE0F'}</span>
|
||||||
|
{fds?.label || fdsId}
|
||||||
|
<button onClick={() => _toggleFds(fdsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1 }}>x</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.textInputRow}>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
className={styles.textInput}
|
||||||
|
placeholder={t('Nachricht eingeben')}
|
||||||
|
value={coach.inputValue}
|
||||||
|
onChange={e => coach.setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onDragOver={_handleInputDragOver}
|
||||||
|
onDrop={_handleInputDrop}
|
||||||
|
rows={1}
|
||||||
|
disabled={coach.isStreaming}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.sendBtn}
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={coach.isStreaming || !coach.inputValue.trim()}
|
||||||
|
>
|
||||||
|
{t('Senden')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -169,10 +169,142 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tab Bar */
|
||||||
|
.tabBar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 2px solid var(--border-color, #ddd);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
color: var(--primary-color, #F25843);
|
||||||
|
border-bottom-color: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Personas Tab */
|
||||||
|
.personasTab {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personasHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg-input, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modalOverlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 520px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHeader h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalClose {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalClose:hover {
|
||||||
|
background: var(--bg-hover, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalBody {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalFooter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSecondary {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSecondary:hover {
|
||||||
|
background: var(--bg-hover, #f5f5f5);
|
||||||
|
border-color: var(--primary-color, #F25843);
|
||||||
|
color: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.settings { padding: 0.75rem; max-width: 100%; }
|
.settings { padding: 0.75rem; max-width: 100%; }
|
||||||
.statsGrid { grid-template-columns: 1fr 1fr; gap: 0.5rem; }
|
.statsGrid { grid-template-columns: 1fr 1fr; gap: 0.5rem; }
|
||||||
.statItem { padding: 0.5rem; }
|
.statItem { padding: 0.5rem; }
|
||||||
.statValue { font-size: 1.2rem; }
|
.statValue { font-size: 1.2rem; }
|
||||||
.voiceRow { flex-direction: column; }
|
.voiceRow { flex-direction: column; }
|
||||||
|
.modal { width: 95vw; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,38 @@
|
||||||
/**
|
/**
|
||||||
* CommCoach Settings View
|
* CommCoach Settings View
|
||||||
*
|
*
|
||||||
* Coaching-specific settings: reminders, email notifications, stats.
|
* Two tabs:
|
||||||
* Voice/language settings are in user-level settings (/settings -> "Stimme & Sprache").
|
* 1. Allgemein – Reminders, email notifications, voice/language link
|
||||||
|
* 2. Gespraechspartner – Persona CRUD with FormGeneratorTable (apiEndpoint-driven)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import {
|
import {
|
||||||
getProfileApi, updateProfileApi,
|
getProfileApi, updateProfileApi,
|
||||||
type CoachingUserProfile,
|
fetchPersonasPaginated, createPersonaApi, updatePersonaApi, deletePersonaApi,
|
||||||
|
type CoachingUserProfile, type CoachingPersona,
|
||||||
} from '../../../api/commcoachApi';
|
} from '../../../api/commcoachApi';
|
||||||
|
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||||
import styles from './CommcoachSettingsView.module.css';
|
import styles from './CommcoachSettingsView.module.css';
|
||||||
|
import adminStyles from '../../admin/Admin.module.css';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
export const CommcoachSettingsView: React.FC = () => {
|
type SettingsTab = 'general' | 'personas';
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
|
export const CommcoachSettingsView: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
|
|
||||||
const [profile, setProfile] = useState<CoachingUserProfile | null>(null);
|
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Tab: Allgemein
|
||||||
|
// =========================================================================
|
||||||
|
const [_profile, setProfile] = useState<CoachingUserProfile | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -35,7 +44,7 @@ export const CommcoachSettingsView: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
const loadData = async () => {
|
const _loadProfile = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const profileData = await getProfileApi(request, instanceId);
|
const profileData = await getProfileApi(request, instanceId);
|
||||||
|
|
@ -51,7 +60,7 @@ export const CommcoachSettingsView: React.FC = () => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadData();
|
_loadProfile();
|
||||||
}, [request, instanceId]);
|
}, [request, instanceId]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
|
|
@ -73,64 +82,302 @@ export const CommcoachSettingsView: React.FC = () => {
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, reminderEnabled, reminderTime, emailEnabled]);
|
}, [request, instanceId, reminderEnabled, reminderTime, emailEnabled, t]);
|
||||||
|
|
||||||
if (loading) {
|
// =========================================================================
|
||||||
|
// Tab: Gespraechspartner (FormGeneratorTable with apiEndpoint)
|
||||||
|
// =========================================================================
|
||||||
|
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||||||
|
const [personasLoading, setPersonasLoading] = useState(false);
|
||||||
|
const [personaPagination, setPersonaPagination] = useState<{
|
||||||
|
currentPage: number; pageSize: number; totalItems: number; totalPages: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [showCreatePersona, setShowCreatePersona] = useState(false);
|
||||||
|
const [editingPersona, setEditingPersona] = useState<CoachingPersona | null>(null);
|
||||||
|
const [personaForm, setPersonaForm] = useState({ label: '', description: '', gender: '' });
|
||||||
|
const [personaSaving, setPersonaSaving] = useState(false);
|
||||||
|
const [personaError, setPersonaError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const personaApiEndpoint = instanceId ? `/api/commcoach/${instanceId}/personas` : undefined;
|
||||||
|
|
||||||
|
const _refetchPersonas = useCallback(async (params?: any) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setPersonasLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchPersonasPaginated(request, instanceId, params);
|
||||||
|
const items = data.items || data.personas || [];
|
||||||
|
setPersonas(items);
|
||||||
|
if (data.pagination) {
|
||||||
|
setPersonaPagination(data.pagination);
|
||||||
|
} else {
|
||||||
|
setPersonaPagination(null);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setPersonaError(err.message || 'Fehler beim Laden');
|
||||||
|
setPersonas([]);
|
||||||
|
} finally {
|
||||||
|
setPersonasLoading(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'personas' && instanceId) {
|
||||||
|
_refetchPersonas({ page: 1, pageSize: 50 });
|
||||||
|
}
|
||||||
|
}, [activeTab, instanceId, _refetchPersonas]);
|
||||||
|
|
||||||
|
const personaColumns: ColumnConfig[] = useMemo(() => [
|
||||||
|
{ key: 'label', label: t('Name'), minWidth: 160, sortable: true, searchable: true },
|
||||||
|
{ key: 'description', label: t('Beschreibung'), minWidth: 250 },
|
||||||
|
{
|
||||||
|
key: 'gender', label: t('Geschlecht'), width: 100, sortable: true,
|
||||||
|
formatter: (v: string) => v === 'f' ? t('Weiblich') : v === 'm' ? t('Maennlich') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'category', label: t('Typ'), width: 100, sortable: true, filterable: true,
|
||||||
|
formatter: (v: string) => v === 'builtin' ? t('System') : t('Eigene'),
|
||||||
|
filterOptions: ['builtin', 'custom'],
|
||||||
|
filterLabelResolver: (v: string) => v === 'builtin' ? t('System') : t('Eigene'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isActive', label: t('Aktiv'), width: 70, sortable: true, type: 'boolean' as any,
|
||||||
|
},
|
||||||
|
], [t]);
|
||||||
|
|
||||||
|
const _handleCreatePersona = useCallback(async () => {
|
||||||
|
if (!instanceId || !personaForm.label.trim()) return;
|
||||||
|
setPersonaSaving(true);
|
||||||
|
setPersonaError(null);
|
||||||
|
try {
|
||||||
|
await createPersonaApi(request, instanceId, {
|
||||||
|
label: personaForm.label.trim(),
|
||||||
|
description: personaForm.description.trim(),
|
||||||
|
gender: personaForm.gender || undefined,
|
||||||
|
});
|
||||||
|
setPersonaForm({ label: '', description: '', gender: '' });
|
||||||
|
setShowCreatePersona(false);
|
||||||
|
await _refetchPersonas({ page: 1, pageSize: 50 });
|
||||||
|
} catch (err: any) {
|
||||||
|
setPersonaError(err.message || 'Fehler beim Erstellen');
|
||||||
|
} finally {
|
||||||
|
setPersonaSaving(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId, personaForm, _refetchPersonas]);
|
||||||
|
|
||||||
|
const _handleUpdatePersona = useCallback(async () => {
|
||||||
|
if (!instanceId || !editingPersona) return;
|
||||||
|
setPersonaSaving(true);
|
||||||
|
setPersonaError(null);
|
||||||
|
try {
|
||||||
|
await updatePersonaApi(request, instanceId, editingPersona.id, {
|
||||||
|
label: personaForm.label.trim() || undefined,
|
||||||
|
description: personaForm.description.trim() || undefined,
|
||||||
|
gender: personaForm.gender || undefined,
|
||||||
|
});
|
||||||
|
setEditingPersona(null);
|
||||||
|
setPersonaForm({ label: '', description: '', gender: '' });
|
||||||
|
await _refetchPersonas({ page: 1, pageSize: 50 });
|
||||||
|
} catch (err: any) {
|
||||||
|
setPersonaError(err.message || 'Fehler beim Speichern');
|
||||||
|
} finally {
|
||||||
|
setPersonaSaving(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId, editingPersona, personaForm, _refetchPersonas]);
|
||||||
|
|
||||||
|
const _handleDeletePersona = useCallback(async (itemId: string): Promise<boolean> => {
|
||||||
|
if (!instanceId) return false;
|
||||||
|
try {
|
||||||
|
await deletePersonaApi(request, instanceId, itemId);
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
setPersonaError(err.message || 'Fehler beim Loeschen');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const _handleEditClick = useCallback((row: CoachingPersona) => {
|
||||||
|
setEditingPersona(row);
|
||||||
|
setPersonaForm({ label: row.label, description: row.description, gender: row.gender || '' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleInlineToggleActive = useCallback(async (row: CoachingPersona, _field: string, newValue: any) => {
|
||||||
|
if (!instanceId || row.category === 'builtin') return;
|
||||||
|
try {
|
||||||
|
await updatePersonaApi(request, instanceId, row.id, { isActive: newValue });
|
||||||
|
setPersonas(prev => prev.map(p => p.id === row.id ? { ...p, isActive: newValue } : p));
|
||||||
|
} catch {}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const personaHookData = useMemo(() => ({
|
||||||
|
refetch: _refetchPersonas,
|
||||||
|
pagination: personaPagination,
|
||||||
|
handleDelete: _handleDeletePersona,
|
||||||
|
}), [_refetchPersonas, personaPagination, _handleDeletePersona]);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Render
|
||||||
|
// =========================================================================
|
||||||
|
if (loading && activeTab === 'general') {
|
||||||
return <div className={styles.loading}>{t('Einstellungen werden geladen')}</div>;
|
return <div className={styles.loading}>{t('Einstellungen werden geladen')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isPersonasTab = activeTab === 'personas';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.settings}>
|
<div className={isPersonasTab ? `${adminStyles.adminPage} ${adminStyles.adminPageFill}` : styles.settings}>
|
||||||
{error && <div className={styles.error}>{error}</div>}
|
{/* Tab Bar */}
|
||||||
{success && <div className={styles.success}>{success}</div>}
|
<div className={styles.tabBar} style={{ flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
<div className={styles.section}>
|
className={`${styles.tab} ${activeTab === 'general' ? styles.tabActive : ''}`}
|
||||||
<h3 className={styles.sectionTitle}>{t('Stimme/Sprache')}</h3>
|
onClick={() => setActiveTab('general')}
|
||||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
|
>{t('Allgemein')}</button>
|
||||||
{t('Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert.')}
|
<button
|
||||||
</p>
|
className={`${styles.tab} ${activeTab === 'personas' ? styles.tabActive : ''}`}
|
||||||
<Link to="/settings" onClick={() => {}} style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}>
|
onClick={() => setActiveTab('personas')}
|
||||||
{t('Benutzereinstellungen öffnen (Tab "Stimme & Sprache")')}
|
>{t('Gespraechspartner')}</button>
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.section}>
|
{/* Tab: Allgemein */}
|
||||||
<h3 className={styles.sectionTitle}>{t('Erinnerungen')}</h3>
|
{activeTab === 'general' && (
|
||||||
<div className={styles.field}>
|
<>
|
||||||
<label className={styles.checkboxLabel}>
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
<input type="checkbox" checked={reminderEnabled} onChange={e => setReminderEnabled(e.target.checked)} />
|
{success && <div className={styles.success}>{success}</div>}
|
||||||
{t('Tägliche Coaching-Erinnerung per E-Mail')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{reminderEnabled && (
|
|
||||||
<div className={styles.field}>
|
|
||||||
<label className={styles.label}>{t('Uhrzeit')}</label>
|
|
||||||
<input type="time" className={styles.input} value={reminderTime} onChange={e => setReminderTime(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={styles.field}>
|
|
||||||
<label className={styles.checkboxLabel}>
|
|
||||||
<input type="checkbox" checked={emailEnabled} onChange={e => setEmailEnabled(e.target.checked)} />
|
|
||||||
{t('Session-Zusammenfassung per E-Mail senden')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{profile && (
|
<div className={styles.section}>
|
||||||
<div className={styles.section}>
|
<h3 className={styles.sectionTitle}>{t('Stimme/Sprache')}</h3>
|
||||||
<h3 className={styles.sectionTitle}>{t('Statistik')}</h3>
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
|
||||||
<div className={styles.statsGrid}>
|
{t('Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert.')}
|
||||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.totalSessions}</span><span className={styles.statLabel}>{t('Sessions gesamt')}</span></div>
|
</p>
|
||||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.totalMinutes}</span><span className={styles.statLabel}>{t('Minuten gesamt')}</span></div>
|
<Link to="/settings" style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}>
|
||||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.streakDays}</span><span className={styles.statLabel}>{t('Aktueller Streak')}</span></div>
|
{t('Benutzereinstellungen oeffnen (Tab "Stimme & Sprache")')}
|
||||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.longestStreak}</span><span className={styles.statLabel}>{t('Längster Streak')}</span></div>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>{t('Erinnerungen')}</h3>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.checkboxLabel}>
|
||||||
|
<input type="checkbox" checked={reminderEnabled} onChange={e => setReminderEnabled(e.target.checked)} />
|
||||||
|
{t('Taegliche Coaching-Erinnerung per E-Mail')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{reminderEnabled && (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>{t('Uhrzeit')}</label>
|
||||||
|
<input type="time" className={styles.input} value={reminderTime} onChange={e => setReminderTime(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.checkboxLabel}>
|
||||||
|
<input type="checkbox" checked={emailEnabled} onChange={e => setEmailEnabled(e.target.checked)} />
|
||||||
|
{t('Session-Zusammenfassung per E-Mail senden')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={styles.saveBtn} onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? t('speichern') : t('Einstellungen speichern')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button className={styles.saveBtn} onClick={handleSave} disabled={saving}>
|
{/* Tab: Gespraechspartner */}
|
||||||
{saving ? t('speichern') : t('Einstellungen speichern')}
|
{activeTab === 'personas' && (
|
||||||
</button>
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, width: '100%' }}>
|
||||||
|
{personaError && <div className={styles.error} style={{ flexShrink: 0 }}>{personaError}</div>}
|
||||||
|
|
||||||
|
<div className={styles.personasHeader} style={{ flexShrink: 0 }}>
|
||||||
|
<h3 className={styles.sectionTitle}>{t('Gespraechspartner verwalten')}</h3>
|
||||||
|
<button className={styles.saveBtn} style={{ width: 'auto', padding: '0.4rem 1rem' }} onClick={() => {
|
||||||
|
setShowCreatePersona(true);
|
||||||
|
setPersonaForm({ label: '', description: '', gender: '' });
|
||||||
|
}}>
|
||||||
|
{t('+ Neuer Gespraechspartner')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', margin: '0 0 1rem', flexShrink: 0 }}>
|
||||||
|
{t('System-Personas koennen nicht bearbeitet oder geloescht werden. Eigene Personas koennen pro Modul zugeordnet werden.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={adminStyles.tableContainer}>
|
||||||
|
<FormGeneratorTable
|
||||||
|
data={personas}
|
||||||
|
columns={personaColumns}
|
||||||
|
apiEndpoint={personaApiEndpoint}
|
||||||
|
loading={personasLoading}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={50}
|
||||||
|
searchable={true}
|
||||||
|
filterable={true}
|
||||||
|
sortable={true}
|
||||||
|
selectable={false}
|
||||||
|
inlineEditable={true}
|
||||||
|
onInlineUpdate={_handleInlineToggleActive}
|
||||||
|
actionButtons={[
|
||||||
|
{
|
||||||
|
type: 'edit' as const,
|
||||||
|
onAction: _handleEditClick,
|
||||||
|
title: t('Bearbeiten'),
|
||||||
|
visible: (row: CoachingPersona) => row.category !== 'builtin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'delete' as const,
|
||||||
|
title: t('Loeschen'),
|
||||||
|
visible: (row: CoachingPersona) => row.category !== 'builtin',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
hookData={personaHookData}
|
||||||
|
emptyMessage={t('Keine Gespraechspartner vorhanden')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create / Edit Modal */}
|
||||||
|
{(showCreatePersona || editingPersona) && (
|
||||||
|
<div className={styles.modalOverlay}>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<h3>{editingPersona ? t('Gespraechspartner bearbeiten') : t('Neuer Gespraechspartner')}</h3>
|
||||||
|
<button className={styles.modalClose} onClick={() => { setShowCreatePersona(false); setEditingPersona(null); }}>x</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalBody}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>{t('Name')}</label>
|
||||||
|
<input className={styles.input} value={personaForm.label}
|
||||||
|
onChange={e => setPersonaForm(f => ({ ...f, label: e.target.value }))}
|
||||||
|
placeholder={t('z.B. Kritischer Investor')} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>{t('Beschreibung / Rollenbeschreibung')}</label>
|
||||||
|
<textarea className={styles.textarea} rows={5} value={personaForm.description}
|
||||||
|
onChange={e => setPersonaForm(f => ({ ...f, description: e.target.value }))}
|
||||||
|
placeholder={t('Detaillierte Beschreibung der Rolle und des Verhaltens...')} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>{t('Geschlecht')}</label>
|
||||||
|
<select className={styles.select} value={personaForm.gender}
|
||||||
|
onChange={e => setPersonaForm(f => ({ ...f, gender: e.target.value }))}>
|
||||||
|
<option value="">{t('Nicht angegeben')}</option>
|
||||||
|
<option value="f">{t('Weiblich')}</option>
|
||||||
|
<option value="m">{t('Maennlich')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<button className={styles.btnSecondary} onClick={() => { setShowCreatePersona(false); setEditingPersona(null); }}>
|
||||||
|
{t('Abbrechen')}
|
||||||
|
</button>
|
||||||
|
<button className={styles.saveBtn} style={{ width: 'auto', padding: '0.4rem 1rem' }}
|
||||||
|
disabled={personaSaving || !personaForm.label.trim()}
|
||||||
|
onClick={editingPersona ? _handleUpdatePersona : _handleCreatePersona}>
|
||||||
|
{personaSaving ? t('Speichern...') : editingPersona ? t('Speichern') : t('Erstellen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
export { CommcoachDashboardView } from './CommcoachDashboardView';
|
export { CommcoachDashboardView } from './CommcoachDashboardView';
|
||||||
export { CommcoachDossierView } from './CommcoachDossierView';
|
export { CommcoachAssistantView } from './CommcoachAssistantView';
|
||||||
|
export { CommcoachModulesView } from './CommcoachModulesView';
|
||||||
|
export { CommcoachSessionView } from './CommcoachSessionView';
|
||||||
export { CommcoachSettingsView } from './CommcoachSettingsView';
|
export { CommcoachSettingsView } from './CommcoachSettingsView';
|
||||||
|
|
|
||||||
|
|
@ -436,7 +436,7 @@
|
||||||
|
|
||||||
.udbSidebar {
|
.udbSidebar {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
min-width: 280px;
|
min-width: 180px;
|
||||||
border: 1px solid var(--border-color, #e0e0e0);
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -444,7 +444,7 @@
|
||||||
background: var(--bg-card, #fff);
|
background: var(--bg-card, #fff);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: width 0.2s, min-width 0.2s;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.udbSidebarCollapsed {
|
.udbSidebarCollapsed {
|
||||||
|
|
@ -452,6 +452,20 @@
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.udbResizeHandle {
|
||||||
|
width: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: col-resize;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbResizeHandle:hover,
|
||||||
|
.udbResizeHandle:active {
|
||||||
|
background: var(--accent-color, #4a90d9);
|
||||||
|
}
|
||||||
|
|
||||||
.udbToggle {
|
.udbToggle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
|
|
@ -1303,3 +1317,340 @@
|
||||||
.spinner {
|
.spinner {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Agent Status Bubble + Stats Cards + Module Views (Greenfield IA)
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.agentStatusBubble {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(74, 144, 217, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0.5rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
animation: agentPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes agentPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentStatusDot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-color, #4A90D9);
|
||||||
|
animation: agentPulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsCards {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: var(--surface-color, #f5f5f5);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsValue {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantContainer,
|
||||||
|
.modulesContainer {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepIndicator {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border-color, #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepActive {
|
||||||
|
background: var(--primary-color, #4A90D9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardContent {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardStep {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardInput,
|
||||||
|
.wizardSelect {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
background: var(--bg-input, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardTextarea {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
background: var(--bg-input, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleChoice {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleCard {
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleExpanded {
|
||||||
|
border-color: var(--primary-color, #4A90D9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleRow:hover {
|
||||||
|
background: var(--bg-hover, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleType {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(74, 144, 217, 0.1);
|
||||||
|
color: var(--primary-color, #4A90D9);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleTitle {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleStatus {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleSessionsList {
|
||||||
|
padding: 0.5rem 1rem 1rem 2rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionRow:hover {
|
||||||
|
color: var(--primary-color, #4A90D9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionStatus {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noSessions {
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmOverlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmDialog,
|
||||||
|
.editDialog {
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmSummary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--surface-color, #f5f5f5);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizardHint {
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorBanner {
|
||||||
|
background: rgba(241, 76, 76, 0.1);
|
||||||
|
color: #f14c4c;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--primary-color, #4A90D9);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSecondary {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnDanger {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: #f14c4c;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSmall {
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnSmallDanger {
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #f14c4c;
|
||||||
|
background: transparent;
|
||||||
|
color: #f14c4c;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
|
||||||
190
src/pages/views/teamsbot/TeamsbotAssistantView.tsx
Normal file
190
src/pages/views/teamsbot/TeamsbotAssistantView.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
/**
|
||||||
|
* TeamsBot Assistant View
|
||||||
|
*
|
||||||
|
* Wizard: Select/create module → Meeting link → Bot selection → "Start bot"
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
|
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import styles from './Teamsbot.module.css';
|
||||||
|
|
||||||
|
type WizardStep = 'module' | 'meeting' | 'bot' | 'confirm';
|
||||||
|
const STEPS: WizardStep[] = ['module', 'meeting', 'bot', 'confirm'];
|
||||||
|
|
||||||
|
export const TeamsbotAssistantView: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { instance, mandateId } = useCurrentInstance();
|
||||||
|
const instanceId = instance?.id || '';
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const preselectedModuleId = searchParams.get('moduleId');
|
||||||
|
|
||||||
|
const [step, setStep] = useState<WizardStep>(preselectedModuleId ? 'meeting' : 'module');
|
||||||
|
const [modules, setModules] = useState<any[]>([]);
|
||||||
|
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(preselectedModuleId);
|
||||||
|
const [newModuleTitle, setNewModuleTitle] = useState('');
|
||||||
|
const [createNewModule, setCreateNewModule] = useState(false);
|
||||||
|
const [meetingLink, setMeetingLink] = useState('');
|
||||||
|
const [botName, setBotName] = useState('AI Assistant');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const stepIdx = STEPS.indexOf(step);
|
||||||
|
|
||||||
|
const _loadModules = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
const result = await teamsbotApi.listModules(instanceId);
|
||||||
|
setModules(result || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load modules:', err);
|
||||||
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => { _loadModules(); }, [_loadModules]);
|
||||||
|
|
||||||
|
const _handleNext = () => {
|
||||||
|
const nextIdx = stepIdx + 1;
|
||||||
|
if (nextIdx < STEPS.length) setStep(STEPS[nextIdx]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleBack = () => {
|
||||||
|
const prevIdx = stepIdx - 1;
|
||||||
|
if (prevIdx >= 0) setStep(STEPS[prevIdx]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleStart = async () => {
|
||||||
|
if (!meetingLink.trim()) {
|
||||||
|
setError(t('Meeting-Link erforderlich'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
let moduleId = selectedModuleId;
|
||||||
|
if (createNewModule && newModuleTitle.trim()) {
|
||||||
|
const mod = await teamsbotApi.createModule(instanceId, { title: newModuleTitle.trim() });
|
||||||
|
moduleId = mod.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await teamsbotApi.startSession(instanceId, {
|
||||||
|
meetingLink: meetingLink.trim(),
|
||||||
|
botName,
|
||||||
|
moduleId: moduleId || undefined,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${result.session.id}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || t('Fehler beim Starten'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.assistantContainer}>
|
||||||
|
<div className={styles.wizardHeader}>
|
||||||
|
<h2>{t('Neues Meeting starten')}</h2>
|
||||||
|
<div className={styles.stepIndicator}>
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||||
|
|
||||||
|
<div className={styles.wizardContent}>
|
||||||
|
{step === 'module' && (
|
||||||
|
<div className={styles.wizardStep}>
|
||||||
|
<h3>{t('Meeting-Modul wählen')}</h3>
|
||||||
|
<div className={styles.moduleChoice}>
|
||||||
|
<label>
|
||||||
|
<input type="radio" checked={!createNewModule} onChange={() => setCreateNewModule(false)} />
|
||||||
|
{t('Bestehendes Modul')}
|
||||||
|
</label>
|
||||||
|
{!createNewModule && (
|
||||||
|
<select
|
||||||
|
value={selectedModuleId || ''}
|
||||||
|
onChange={e => setSelectedModuleId(e.target.value || null)}
|
||||||
|
className={styles.wizardSelect}
|
||||||
|
>
|
||||||
|
<option value="">{t('Kein Modul (Adhoc)')}</option>
|
||||||
|
{modules.map(m => (
|
||||||
|
<option key={m.id} value={m.id}>{m.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
<input type="radio" checked={createNewModule} onChange={() => setCreateNewModule(true)} />
|
||||||
|
{t('Neues Modul erstellen')}
|
||||||
|
</label>
|
||||||
|
{createNewModule && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.wizardInput}
|
||||||
|
placeholder={t('z.B. Weekly Standup, Q3 Review...')}
|
||||||
|
value={newModuleTitle}
|
||||||
|
onChange={e => setNewModuleTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'meeting' && (
|
||||||
|
<div className={styles.wizardStep}>
|
||||||
|
<h3>{t('Meeting-Link')}</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.wizardInput}
|
||||||
|
placeholder="https://teams.microsoft.com/l/meetup-join/..."
|
||||||
|
value={meetingLink}
|
||||||
|
onChange={e => setMeetingLink(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'bot' && (
|
||||||
|
<div className={styles.wizardStep}>
|
||||||
|
<h3>{t('Bot-Name')}</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.wizardInput}
|
||||||
|
value={botName}
|
||||||
|
onChange={e => setBotName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'confirm' && (
|
||||||
|
<div className={styles.wizardStep}>
|
||||||
|
<h3>{t('Zusammenfassung')}</h3>
|
||||||
|
<div className={styles.confirmSummary}>
|
||||||
|
<div><strong>{t('Modul')}:</strong> {createNewModule ? newModuleTitle : (modules.find(m => m.id === selectedModuleId)?.title || t('Adhoc'))}</div>
|
||||||
|
<div><strong>{t('Meeting')}:</strong> {meetingLink}</div>
|
||||||
|
<div><strong>{t('Bot')}:</strong> {botName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.wizardActions}>
|
||||||
|
{stepIdx > 0 && (
|
||||||
|
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{step !== 'confirm' ? (
|
||||||
|
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
|
||||||
|
) : (
|
||||||
|
<button className={styles.btnPrimary} onClick={_handleStart} disabled={loading}>
|
||||||
|
{loading ? t('Starte...') : t('Bot starten')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -73,14 +73,15 @@ export const TeamsbotDashboardView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [joinMode, instanceId]);
|
}, [joinMode, instanceId]);
|
||||||
|
|
||||||
// Auto-refresh: poll every 10s when there are active sessions
|
// Adaptive polling: 3s with active sessions, 30s otherwise
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasActiveSessions = sessions.some(s => ['pending', 'joining', 'active'].includes(s.status));
|
const hasActiveSessions = sessions.some(s => ['pending', 'joining', 'active'].includes(s.status));
|
||||||
if (hasActiveSessions && instanceId) {
|
const interval = hasActiveSessions ? 3000 : 30000;
|
||||||
|
if (instanceId) {
|
||||||
pollRef.current = setInterval(() => {
|
pollRef.current = setInterval(() => {
|
||||||
teamsbotApi.listSessions(instanceId).then(r => setSessions(r.sessions || [])).catch(() => {});
|
teamsbotApi.listSessions(instanceId).then(r => setSessions(r.sessions || [])).catch(() => {});
|
||||||
}, 10000);
|
}, interval);
|
||||||
}
|
}
|
||||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||||
}, [sessions, instanceId]);
|
}, [sessions, instanceId]);
|
||||||
|
|
|
||||||
189
src/pages/views/teamsbot/TeamsbotModulesView.tsx
Normal file
189
src/pages/views/teamsbot/TeamsbotModulesView.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
/**
|
||||||
|
* TeamsBot Modules View
|
||||||
|
*
|
||||||
|
* CRUD list of MeetingModules with expandable session lists per module.
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
|
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import styles from './Teamsbot.module.css';
|
||||||
|
|
||||||
|
const SERIES_TYPE_LABELS: Record<string, string> = {
|
||||||
|
weekly: 'Wöchentlich',
|
||||||
|
biweekly: 'Zweiwöchentlich',
|
||||||
|
monthly: 'Monatlich',
|
||||||
|
adhoc: 'Adhoc',
|
||||||
|
project: 'Projekt',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Aktiv',
|
||||||
|
archived: 'Archiviert',
|
||||||
|
completed: 'Abgeschlossen',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TeamsbotModulesView: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { instance, mandateId } = useCurrentInstance();
|
||||||
|
const instanceId = instance?.id || '';
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [modules, setModules] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [moduleSessions, setModuleSessions] = useState<Record<string, any[]>>({});
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
const [editingModule, setEditingModule] = useState<any | null>(null);
|
||||||
|
|
||||||
|
const _loadModules = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await teamsbotApi.listModules(instanceId);
|
||||||
|
setModules(result || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load modules:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => { _loadModules(); }, [_loadModules]);
|
||||||
|
|
||||||
|
const _loadModuleSessions = useCallback(async (moduleId: string) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
const detail = await teamsbotApi.getModuleDetail(instanceId, moduleId);
|
||||||
|
setModuleSessions(prev => ({ ...prev, [moduleId]: detail?.sessions || [] }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load module sessions:', err);
|
||||||
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
const _toggleExpand = (moduleId: string) => {
|
||||||
|
if (expandedId === moduleId) {
|
||||||
|
setExpandedId(null);
|
||||||
|
} else {
|
||||||
|
setExpandedId(moduleId);
|
||||||
|
if (!moduleSessions[moduleId]) _loadModuleSessions(moduleId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleDelete = async (moduleId: string) => {
|
||||||
|
try {
|
||||||
|
await teamsbotApi.deleteModule(instanceId, moduleId);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
_loadModules();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleUpdate = async (moduleId: string, updates: any) => {
|
||||||
|
try {
|
||||||
|
await teamsbotApi.updateModule(instanceId, moduleId, updates);
|
||||||
|
setEditingModule(null);
|
||||||
|
_loadModules();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.modulesContainer}>
|
||||||
|
<div className={styles.modulesHeader}>
|
||||||
|
<h2>{t('Meeting-Module')}</h2>
|
||||||
|
<button
|
||||||
|
className={styles.btnPrimary}
|
||||||
|
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
|
||||||
|
>
|
||||||
|
{t('Neues Modul')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className={styles.loading}>{t('Laden...')}</div>}
|
||||||
|
|
||||||
|
<div className={styles.modulesList}>
|
||||||
|
{modules.map(mod => (
|
||||||
|
<div key={mod.id} className={`${styles.moduleCard} ${expandedId === mod.id ? styles.moduleExpanded : ''}`}>
|
||||||
|
<div className={styles.moduleRow} onClick={() => _toggleExpand(mod.id)}>
|
||||||
|
<span className={styles.moduleType}>{t(SERIES_TYPE_LABELS[mod.seriesType] || mod.seriesType)}</span>
|
||||||
|
<span className={styles.moduleTitle}>{mod.title}</span>
|
||||||
|
<span className={styles.moduleStatus}>{t(STATUS_LABELS[mod.status] || mod.status)}</span>
|
||||||
|
<div className={styles.moduleActions}>
|
||||||
|
<button className={styles.btnPrimary} style={{ padding: '0.3rem 0.7rem', fontSize: '0.8rem' }} onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant?moduleId=${mod.id}`);
|
||||||
|
}}>{t('Meeting starten')}</button>
|
||||||
|
<button className={styles.btnSmall} onClick={e => { e.stopPropagation(); setEditingModule(mod); }}>{t('Bearbeiten')}</button>
|
||||||
|
<button className={styles.btnSmallDanger} onClick={e => { e.stopPropagation(); setDeleteConfirm(mod.id); }}>{t('Löschen')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedId === mod.id && (
|
||||||
|
<div className={styles.moduleSessionsList}>
|
||||||
|
{(moduleSessions[mod.id] || []).length === 0 ? (
|
||||||
|
<p className={styles.noSessions}>{t('Keine Sitzungen')}</p>
|
||||||
|
) : (
|
||||||
|
(moduleSessions[mod.id] || []).map((sess: any) => (
|
||||||
|
<div
|
||||||
|
key={sess.id}
|
||||||
|
className={styles.sessionRow}
|
||||||
|
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${sess.id}`)}
|
||||||
|
>
|
||||||
|
<span>{sess.botName || 'Bot'}</span>
|
||||||
|
<span className={styles.sessionStatus}>{sess.status}</span>
|
||||||
|
<span>{sess.startedAt ? new Date(sess.startedAt * 1000).toLocaleDateString() : '-'}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div className={styles.confirmOverlay}>
|
||||||
|
<div className={styles.confirmDialog}>
|
||||||
|
<p>{t('Modul wirklich löschen? Sessions werden dem Modul entkoppelt.')}</p>
|
||||||
|
<div className={styles.confirmActions}>
|
||||||
|
<button className={styles.btnSecondary} onClick={() => setDeleteConfirm(null)}>{t('Abbrechen')}</button>
|
||||||
|
<button className={styles.btnDanger} onClick={() => _handleDelete(deleteConfirm)}>{t('Löschen')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingModule && (
|
||||||
|
<div className={styles.confirmOverlay}>
|
||||||
|
<div className={styles.editDialog}>
|
||||||
|
<h3>{t('Modul bearbeiten')}</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={editingModule.title}
|
||||||
|
className={styles.wizardInput}
|
||||||
|
onBlur={e => setEditingModule({ ...editingModule, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
defaultValue={editingModule.goals || ''}
|
||||||
|
className={styles.wizardTextarea}
|
||||||
|
placeholder={t('Ziele')}
|
||||||
|
rows={3}
|
||||||
|
onBlur={e => setEditingModule({ ...editingModule, goals: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className={styles.confirmActions}>
|
||||||
|
<button className={styles.btnSecondary} onClick={() => setEditingModule(null)}>{t('Abbrechen')}</button>
|
||||||
|
<button className={styles.btnPrimary} onClick={() => _handleUpdate(editingModule.id, {
|
||||||
|
title: editingModule.title,
|
||||||
|
goals: editingModule.goals,
|
||||||
|
})}>{t('Speichern')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -28,8 +28,9 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
*/
|
*/
|
||||||
export const TeamsbotSessionView: React.FC = () => {
|
export const TeamsbotSessionView: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { instance } = useCurrentInstance();
|
const { instance, mandateId } = useCurrentInstance();
|
||||||
const instanceId = instance?.id || '';
|
const instanceId = instance?.id || '';
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const sessionId = searchParams.get('sessionId') || '';
|
const sessionId = searchParams.get('sessionId') || '';
|
||||||
|
|
@ -56,6 +57,12 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}>>([]);
|
}>>([]);
|
||||||
|
|
||||||
|
const [agentStatus, setAgentStatus] = useState<{ toolName?: string; status?: string; reason?: string } | null>(null);
|
||||||
|
const agentStatusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [sessionStats, setSessionStats] = useState<any>(null);
|
||||||
|
const [reconnectTick, setReconnectTick] = useState(0);
|
||||||
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Director Prompt panel state
|
// Director Prompt panel state
|
||||||
const [directorPrompts, setDirectorPrompts] = useState<DirectorPrompt[]>([]);
|
const [directorPrompts, setDirectorPrompts] = useState<DirectorPrompt[]>([]);
|
||||||
const [directorText, setDirectorText] = useState('');
|
const [directorText, setDirectorText] = useState('');
|
||||||
|
|
@ -76,6 +83,8 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
// UDB Sidebar state
|
// UDB Sidebar state
|
||||||
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||||
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||||
|
const [udbWidth, setUdbWidth] = useState(280);
|
||||||
|
const udbResizing = useRef(false);
|
||||||
const _udbContext: UdbContext | null = instanceId
|
const _udbContext: UdbContext | null = instanceId
|
||||||
? { instanceId, featureInstanceId: instanceId }
|
? { instanceId, featureInstanceId: instanceId }
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -156,22 +165,21 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
};
|
};
|
||||||
}, [instanceId, sessionId]);
|
}, [instanceId, sessionId]);
|
||||||
|
|
||||||
// SSE Live Stream - connect once per session, don't re-create on status changes.
|
// SSE Live Stream with reconnect support.
|
||||||
// We deliberately depend ONLY on (instanceId, sessionId), not on session.status,
|
// Depends on (instanceId, sessionId, reconnectTick) -- reconnectTick is bumped
|
||||||
// so transient status transitions (pending -> joining -> active) don't tear down
|
// to force reconnect after connection loss without changing sessionId.
|
||||||
// and rebuild the EventSource (which used to flicker botConnected and spawn
|
|
||||||
// multiple parallel /stream connections to the gateway).
|
|
||||||
const sseSessionRef = useRef<string | null>(null);
|
const sseSessionRef = useRef<string | null>(null);
|
||||||
const sessionStatusRef = useRef<string | undefined>(session?.status);
|
const sessionStatusRef = useRef<string | undefined>(session?.status);
|
||||||
sessionStatusRef.current = session?.status;
|
sessionStatusRef.current = session?.status;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId || !sessionId) return;
|
if (!instanceId || !sessionId) return;
|
||||||
// Avoid reconnecting if already streaming this session
|
// Avoid reconnecting if already streaming this session (unless reconnectTick changed)
|
||||||
if (sseSessionRef.current === sessionId && eventSourceRef.current) return;
|
if (sseSessionRef.current === sessionId && eventSourceRef.current && eventSourceRef.current.readyState !== EventSource.CLOSED) return;
|
||||||
// Don't open a stream for sessions that are known to already be terminal.
|
// Don't open a stream for sessions that are known to already be terminal.
|
||||||
const initialStatus = sessionStatusRef.current;
|
const initialStatus = sessionStatusRef.current;
|
||||||
if (initialStatus && !['active', 'joining', 'pending'].includes(initialStatus)) return;
|
if (initialStatus && !['active', 'joining', 'pending'].includes(initialStatus)) return;
|
||||||
|
|
||||||
|
if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; }
|
||||||
eventSourceRef.current?.close();
|
eventSourceRef.current?.close();
|
||||||
sseSessionRef.current = sessionId;
|
sseSessionRef.current = sessionId;
|
||||||
|
|
||||||
|
|
@ -189,7 +197,10 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
|
|
||||||
switch (evType) {
|
switch (evType) {
|
||||||
case 'sessionState':
|
case 'sessionState':
|
||||||
if (sseEvent.data) setSession(prev => prev ? { ...prev, ...sseEvent.data } : sseEvent.data);
|
if (sseEvent.data) {
|
||||||
|
setSession(prev => prev ? { ...prev, ...sseEvent.data } : sseEvent.data);
|
||||||
|
if (sseEvent.data.stats) setSessionStats(sseEvent.data.stats);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'transcript': {
|
case 'transcript': {
|
||||||
|
|
@ -289,7 +300,15 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
|
|
||||||
case 'agentRun': {
|
case 'agentRun': {
|
||||||
const data = sseEvent.data || {};
|
const data = sseEvent.data || {};
|
||||||
_dlog('AGENT', `${data.status || ''} ${data.reason || ''}`.trim());
|
_dlog('AGENT', `${data.status || ''} ${data.toolName || ''} ${data.reason || ''}`.trim());
|
||||||
|
if (data.status === 'started' || data.status === 'running') {
|
||||||
|
setAgentStatus({ toolName: data.toolName, status: data.status, reason: data.reason });
|
||||||
|
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
|
||||||
|
agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 15000);
|
||||||
|
} else {
|
||||||
|
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
|
||||||
|
agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 2000);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -315,6 +334,17 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = () => {
|
||||||
setIsLive(false);
|
setIsLive(false);
|
||||||
|
if (eventSource.readyState === EventSource.CLOSED) {
|
||||||
|
_dlog('SSE', 'connection closed, scheduling reconnect');
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
sseSessionRef.current = null;
|
||||||
|
const status = sessionStatusRef.current;
|
||||||
|
if (status && ['active', 'joining', 'pending'].includes(status)) {
|
||||||
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
|
setReconnectTick(v => v + 1);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -323,27 +353,36 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
sseSessionRef.current = null;
|
sseSessionRef.current = null;
|
||||||
setIsLive(false);
|
setIsLive(false);
|
||||||
setBotConnected(false);
|
setBotConnected(false);
|
||||||
|
if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; }
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [instanceId, sessionId]);
|
}, [instanceId, sessionId, reconnectTick]);
|
||||||
|
|
||||||
// Polling fallback: refresh session data every 5s when SSE is not connected
|
// Polling fallback: refresh session data every 5s when SSE is not connected.
|
||||||
|
// Uses isActive (boolean) instead of session object to prevent interval resets.
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session]);
|
const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session?.status]);
|
||||||
|
const isLiveRef = useRef(isLive);
|
||||||
|
isLiveRef.current = isLive;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (instanceId && sessionId && (isActive || !session)) {
|
if (!instanceId || !sessionId) return;
|
||||||
pollRef.current = setInterval(async () => {
|
if (!isActive) return;
|
||||||
if (isLive) return;
|
pollRef.current = setInterval(async () => {
|
||||||
try {
|
if (isLiveRef.current) return;
|
||||||
const result = await teamsbotApi.getSession(instanceId, sessionId);
|
try {
|
||||||
setSession(result.session);
|
const result = await teamsbotApi.getSession(instanceId, sessionId);
|
||||||
if (result.transcripts) setTranscripts(result.transcripts);
|
setSession(result.session);
|
||||||
if (result.botResponses) setBotResponses(result.botResponses);
|
if (result.transcripts) setTranscripts(result.transcripts);
|
||||||
} catch {}
|
if (result.botResponses) setBotResponses(result.botResponses);
|
||||||
}, 5000);
|
// If session became active and SSE is dead, trigger reconnect
|
||||||
}
|
const newStatus = result.session?.status;
|
||||||
|
if (newStatus && ['active', 'joining', 'pending'].includes(newStatus) && !eventSourceRef.current) {
|
||||||
|
setReconnectTick(v => v + 1);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, 5000);
|
||||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||||
}, [isActive, instanceId, sessionId, isLive, session]);
|
}, [isActive, instanceId, sessionId]);
|
||||||
|
|
||||||
// Auto-scroll transcript
|
// Auto-scroll transcript
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -588,9 +627,19 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
|
|
||||||
if (loading) return <div className={styles.loading}>{t('Sitzung laden')}</div>;
|
if (loading) return <div className={styles.loading}>{t('Sitzung laden')}</div>;
|
||||||
if (noSessions) return (
|
if (noSessions) return (
|
||||||
<div className={styles.emptyState || styles.loading}>
|
<div className={styles.emptyState || styles.loading} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '1rem', padding: '3rem' }}>
|
||||||
<p>{t('Keine Sitzungen vorhanden')}</p>
|
<h3>{t('Keine aktive Sitzung')}</h3>
|
||||||
<p>{t('Starte eine neue Sitzung im Dashboard.')}</p>
|
<p style={{ color: 'var(--text-secondary, #666)', textAlign: 'center', maxWidth: 400 }}>
|
||||||
|
{t('Starte ein neues Meeting ueber den Assistenten oder die Module-Seite.')}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||||
|
<button className={styles.btnPrimary}
|
||||||
|
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
|
||||||
|
>{t('Zum Assistenten')}</button>
|
||||||
|
<button className={styles.btnSecondary}
|
||||||
|
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/modules`)}
|
||||||
|
>{t('Zu den Modulen')}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!session) return <div className={styles.errorBanner}>{t('Sitzung nicht gefunden')}</div>;
|
if (!session) return <div className={styles.errorBanner}>{t('Sitzung nicht gefunden')}</div>;
|
||||||
|
|
@ -626,6 +675,38 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Agent Status Bubble (F-fix-2) */}
|
||||||
|
{agentStatus && (
|
||||||
|
<div className={styles.agentStatusBubble}>
|
||||||
|
<span className={styles.agentStatusDot} />
|
||||||
|
<span>{t('Agent denkt nach')}{agentStatus.toolName ? `: ${agentStatus.toolName}` : '...'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards (F-fix-3) */}
|
||||||
|
{sessionStats && (
|
||||||
|
<div className={styles.statsCards}>
|
||||||
|
{sessionStats.talkingMinutes != null && (
|
||||||
|
<div className={styles.statsCard}>
|
||||||
|
<span className={styles.statsValue}>{Math.round(sessionStats.talkingMinutes)}</span>
|
||||||
|
<span className={styles.statsLabel}>{t('Sprechminuten')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sessionStats.botResponseCount != null && (
|
||||||
|
<div className={styles.statsCard}>
|
||||||
|
<span className={styles.statsValue}>{sessionStats.botResponseCount}</span>
|
||||||
|
<span className={styles.statsLabel}>{t('Bot-Antworten')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sessionStats.avgLatencyMs != null && (
|
||||||
|
<div className={styles.statsCard}>
|
||||||
|
<span className={styles.statsValue}>{Math.round(sessionStats.avgLatencyMs)}ms</span>
|
||||||
|
<span className={styles.statsLabel}>{t('Ø Latenz')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Session Header */}
|
{/* Session Header */}
|
||||||
<div className={styles.sessionViewHeader}>
|
<div className={styles.sessionViewHeader}>
|
||||||
<div className={styles.sessionInfo}>
|
<div className={styles.sessionInfo}>
|
||||||
|
|
@ -648,7 +729,10 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
<div className={styles.sessionLayout}>
|
<div className={styles.sessionLayout}>
|
||||||
{/* UDB Sidebar (Files / Sources) */}
|
{/* UDB Sidebar (Files / Sources) */}
|
||||||
{_udbContext && (
|
{_udbContext && (
|
||||||
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
|
<div
|
||||||
|
className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}
|
||||||
|
style={udbCollapsed ? undefined : { width: udbWidth, minWidth: 180 }}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
className={styles.udbToggle}
|
className={styles.udbToggle}
|
||||||
onClick={() => setUdbCollapsed((v) => !v)}
|
onClick={() => setUdbCollapsed((v) => !v)}
|
||||||
|
|
@ -657,16 +741,39 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
||||||
</button>
|
</button>
|
||||||
{!udbCollapsed && (
|
{!udbCollapsed && (
|
||||||
<UnifiedDataBar
|
<UnifiedDataBar
|
||||||
context={_udbContext}
|
context={_udbContext}
|
||||||
activeTab={udbTab}
|
activeTab={udbTab}
|
||||||
onTabChange={setUdbTab}
|
onTabChange={setUdbTab}
|
||||||
hideTabs={['chats']}
|
hideTabs={['chats']}
|
||||||
onFileSelect={_handleUdbFileSelect}
|
onFileSelect={_handleUdbFileSelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{_udbContext && !udbCollapsed && (
|
||||||
|
<div
|
||||||
|
className={styles.udbResizeHandle}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
udbResizing.current = true;
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startW = udbWidth;
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
if (!udbResizing.current) return;
|
||||||
|
const newW = Math.max(180, Math.min(600, startW + (ev.clientX - startX)));
|
||||||
|
setUdbWidth(newW);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
udbResizing.current = false;
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main column */}
|
{/* Main column */}
|
||||||
<div className={styles.sessionMain}>
|
<div className={styles.sessionMain}>
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ import { ToolActivityLog } from './ToolActivityLog';
|
||||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||||
import type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar';
|
import type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import { collectGroupItemIds } from '../../../api/fileApi';
|
|
||||||
import type { TableGroupNode } from '../../../api/connectionApi';
|
|
||||||
import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector';
|
import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector';
|
||||||
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
||||||
import { useBilling } from '../../../hooks/useBilling';
|
import { useBilling } from '../../../hooks/useBilling';
|
||||||
|
|
@ -83,8 +81,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
|
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
|
||||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||||
const workspaceInputRef = useRef<WorkspaceInputHandle>(null);
|
const workspaceInputRef = useRef<WorkspaceInputHandle>(null);
|
||||||
/** Persisted grouping tree from /api/files/list — resolves dropped groups → file IDs */
|
|
||||||
const [filesListGroupTree, setFilesListGroupTree] = useState<TableGroupNode[]>([]);
|
|
||||||
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
|
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
|
||||||
const { allowedProviders } = useBilling();
|
const { allowedProviders } = useBilling();
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
@ -115,27 +111,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
}
|
}
|
||||||
}, [isMobile]);
|
}, [isMobile]);
|
||||||
|
|
||||||
const _pullFilesGroupTree = useCallback(async (): Promise<TableGroupNode[]> => {
|
|
||||||
if (!instanceId) return [];
|
|
||||||
try {
|
|
||||||
const res = await api.get<{ groupTree?: TableGroupNode[] }>('/api/files/list', {
|
|
||||||
params: { page: 1, pageSize: 1 },
|
|
||||||
});
|
|
||||||
const gt = res.data?.groupTree;
|
|
||||||
const list = Array.isArray(gt) ? gt : [];
|
|
||||||
setFilesListGroupTree(list);
|
|
||||||
return list;
|
|
||||||
} catch {
|
|
||||||
setFilesListGroupTree([]);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [instanceId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
_pullFilesGroupTree();
|
|
||||||
}, [_pullFilesGroupTree]);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoStartHandled.current || !instanceId || workspace.isProcessing) return;
|
if (autoStartHandled.current || !instanceId || workspace.isProcessing) return;
|
||||||
const prompt = searchParams.get('prompt');
|
const prompt = searchParams.get('prompt');
|
||||||
|
|
@ -153,20 +128,15 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
}, [instanceId, searchParams, setSearchParams, workspace, providerSelection, allowedProviders]);
|
}, [instanceId, searchParams, setSearchParams, workspace, providerSelection, allowedProviders]);
|
||||||
|
|
||||||
const _resolveTreeItemsToFileIds = useCallback(async (items: TreeItemDrop[]) => {
|
const _resolveTreeItemsToFileIds = useCallback(async (items: TreeItemDrop[]) => {
|
||||||
let tree = filesListGroupTree;
|
|
||||||
if (items.some(i => i.type === 'group')) {
|
|
||||||
tree = await _pullFilesGroupTree();
|
|
||||||
}
|
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
if (it.type === 'group') {
|
// Group drops are no longer supported — groups are now presentation-only (view-based)
|
||||||
out.push(...collectGroupItemIds(tree, it.id));
|
if (it.type !== 'group') {
|
||||||
} else {
|
|
||||||
out.push(it.id);
|
out.push(it.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...new Set(out)];
|
return [...new Set(out)];
|
||||||
}, [filesListGroupTree, _pullFilesGroupTree]);
|
}, []);
|
||||||
|
|
||||||
const _uploadAndAttach = useCallback(async (file: File) => {
|
const _uploadAndAttach = useCallback(async (file: File) => {
|
||||||
const result = await fileOps.handleFileUpload(file, undefined, instanceId);
|
const result = await fileOps.handleFileUpload(file, undefined, instanceId);
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import {
|
|
||||||
AuthenticationResult,
|
|
||||||
EventType,
|
|
||||||
PublicClientApplication
|
|
||||||
} from "@azure/msal-browser";
|
|
||||||
import { msalConfig } from "./authConfig";
|
|
||||||
import { MsalProvider } from "@azure/msal-react";
|
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
|
||||||
import { useLanguage } from "../language/LanguageContext";
|
|
||||||
|
|
||||||
interface AuthProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const [msalInstance, setMsalInstance] = useState<PublicClientApplication | null>(null);
|
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const msalApp = new PublicClientApplication(msalConfig);
|
|
||||||
|
|
||||||
const initializeMsal = async () => {
|
|
||||||
try {
|
|
||||||
// Set event handlers first, so we catch all events
|
|
||||||
msalApp.addEventCallback((event) => {
|
|
||||||
if (event.eventType === EventType.LOGIN_SUCCESS) {
|
|
||||||
const payload = event?.payload as AuthenticationResult;
|
|
||||||
if (payload?.account) {
|
|
||||||
msalApp.setActiveAccount(payload.account);
|
|
||||||
console.log("MSAL login successful");
|
|
||||||
|
|
||||||
// Store authentication authority for backend communication
|
|
||||||
if (payload.account?.environment) {
|
|
||||||
sessionStorage.setItem('auth_authority', payload.account.environment);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ MSAL login successful - tokens will be set in httpOnly cookies by backend');
|
|
||||||
}
|
|
||||||
} else if (event.eventType === EventType.LOGIN_FAILURE) {
|
|
||||||
console.error("MSAL login failed:", event.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize MSAL
|
|
||||||
await msalApp.initialize();
|
|
||||||
msalApp.enableAccountStorageEvents();
|
|
||||||
|
|
||||||
// Handle any redirect response
|
|
||||||
const response = await msalApp.handleRedirectPromise();
|
|
||||||
if (response) {
|
|
||||||
// If we have a response, we've completed a redirect flow
|
|
||||||
console.log("MSAL redirect completed successfully");
|
|
||||||
if (response.account) {
|
|
||||||
msalApp.setActiveAccount(response.account);
|
|
||||||
|
|
||||||
// Store authentication authority
|
|
||||||
if (response.account.environment) {
|
|
||||||
sessionStorage.setItem('auth_authority', response.account.environment);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ MSAL redirect completed - tokens will be set in httpOnly cookies by backend');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for accounts
|
|
||||||
const accounts = msalApp.getAllAccounts();
|
|
||||||
if (accounts.length > 0) {
|
|
||||||
msalApp.setActiveAccount(accounts[0]);
|
|
||||||
|
|
||||||
// Store authentication authority for existing accounts
|
|
||||||
if (accounts[0].environment) {
|
|
||||||
sessionStorage.setItem('auth_authority', accounts[0].environment);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ MSAL account found - tokens will be set in httpOnly cookies by backend');
|
|
||||||
}
|
|
||||||
|
|
||||||
setMsalInstance(msalApp);
|
|
||||||
setIsInitialized(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("MSAL initialization failed", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initializeMsal();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!isInitialized || !msalInstance) {
|
|
||||||
return <div>{t('Authentifizierung wird geladen…')}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <MsalProvider instance={msalInstance}>{children}</MsalProvider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useAuthProvider() {
|
|
||||||
return { AuthProvider };
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useMsal } from "@azure/msal-react";
|
|
||||||
import { Navigate, useLocation } from "react-router-dom";
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import { useLanguage } from "../language/LanguageContext";
|
import { useLanguage } from "../language/LanguageContext";
|
||||||
|
|
@ -13,110 +12,36 @@ export const ProtectedRoute = ({
|
||||||
redirectPath = "/login"
|
redirectPath = "/login"
|
||||||
}: ProtectedRouteProps) => {
|
}: ProtectedRouteProps) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { accounts } = useMsal();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isChecking, setIsChecking] = useState(true);
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuthentication = async () => {
|
const authAuthority = sessionStorage.getItem('auth_authority');
|
||||||
try {
|
setIsAuthenticated(!!authAuthority);
|
||||||
// Check for MSAL authentication
|
setIsChecking(false);
|
||||||
const hasMsalAccount = accounts.length > 0;
|
}, []);
|
||||||
|
|
||||||
// Check for backend authentication via API call
|
|
||||||
let hasBackendAuth = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check for authentication authority (httpOnly cookies are handled automatically)
|
|
||||||
const authAuthority = sessionStorage.getItem('auth_authority');
|
|
||||||
console.log('🔍 Checking auth authority:', authAuthority);
|
|
||||||
|
|
||||||
if (authAuthority) {
|
|
||||||
hasBackendAuth = true;
|
|
||||||
console.log('✅ Authenticated with backend (httpOnly cookies), authority:', authAuthority);
|
|
||||||
} else {
|
|
||||||
hasBackendAuth = false;
|
|
||||||
console.log('❌ No authentication authority found');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('❌ Backend authentication failed:', error);
|
|
||||||
hasBackendAuth = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User is authenticated if either method is valid
|
|
||||||
const isAuth = hasMsalAccount || hasBackendAuth;
|
|
||||||
setIsAuthenticated(isAuth);
|
|
||||||
|
|
||||||
console.log('🔐 Authentication status:', {
|
|
||||||
hasMsalAccount,
|
|
||||||
hasBackendAuth,
|
|
||||||
isAuthenticated: isAuth,
|
|
||||||
authAuthority: sessionStorage.getItem('auth_authority')
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasBackendAuth) {
|
|
||||||
console.log('✅ Authenticated with backend cookies');
|
|
||||||
} else if (hasMsalAccount) {
|
|
||||||
console.log('✅ Authenticated with MSAL');
|
|
||||||
} else {
|
|
||||||
console.log('❌ No valid authentication found');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error checking authentication:', error);
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
} finally {
|
|
||||||
setIsChecking(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Small delay to ensure MSAL is initialized and localStorage is updated
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
checkAuthentication();
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [accounts]);
|
|
||||||
|
|
||||||
// Re-check authentication when component mounts or accounts change
|
|
||||||
// This handles cases where auth_authority is set after initial mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isChecking) {
|
if (!isChecking) {
|
||||||
// Double-check authentication state periodically when not initially loading
|
|
||||||
const recheckTimer = setTimeout(() => {
|
const recheckTimer = setTimeout(() => {
|
||||||
const authAuthority = sessionStorage.getItem('auth_authority');
|
const authAuthority = sessionStorage.getItem('auth_authority');
|
||||||
const hasMsalAccount = accounts.length > 0;
|
const isAuth = !!authAuthority;
|
||||||
const hasBackendAuth = !!authAuthority;
|
|
||||||
const isAuth = hasMsalAccount || hasBackendAuth;
|
|
||||||
|
|
||||||
// Only update if authentication state actually changed
|
|
||||||
if (isAuth !== isAuthenticated) {
|
if (isAuth !== isAuthenticated) {
|
||||||
console.log('🔄 Authentication state changed, updating...', {
|
|
||||||
previous: isAuthenticated,
|
|
||||||
current: isAuth,
|
|
||||||
authAuthority,
|
|
||||||
hasMsalAccount,
|
|
||||||
hasBackendAuth
|
|
||||||
});
|
|
||||||
setIsAuthenticated(isAuth);
|
setIsAuthenticated(isAuth);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
return () => clearTimeout(recheckTimer);
|
return () => clearTimeout(recheckTimer);
|
||||||
}
|
}
|
||||||
}, [isChecking, isAuthenticated, accounts]);
|
}, [isChecking, isAuthenticated]);
|
||||||
|
|
||||||
// If still checking, show loading
|
|
||||||
if (isChecking) {
|
if (isChecking) {
|
||||||
return <div>{t('Authentifizierung wird geprüft…')}</div>;
|
return <div>{t('Authentifizierung wird geprüft…')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is authenticated through either method
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
console.log("No valid authentication found, redirecting to login");
|
|
||||||
return <Navigate to={redirectPath} state={{ from: location }} replace />;
|
return <Navigate to={redirectPath} state={{ from: location }} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("User is authenticated, rendering protected content");
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { LogLevel } from '@azure/msal-browser';
|
|
||||||
|
|
||||||
export const msalConfig = {
|
|
||||||
auth: {
|
|
||||||
clientId: '24cd6c8a-b592-4905-a5ba-d5fa9f911154',
|
|
||||||
authority: 'https://login.microsoftonline.com/6a51aaeb-2467-4186-9504-2a05aedc591f/',
|
|
||||||
redirectUri: '/',
|
|
||||||
postLogoutRedirectUri: '/',
|
|
||||||
navigateToLoginRequestUrl: false,
|
|
||||||
},
|
|
||||||
cache: {
|
|
||||||
cacheLocation: 'localStorage',
|
|
||||||
storeAuthStateInCookie: false,
|
|
||||||
},
|
|
||||||
system: {
|
|
||||||
loggerOptions: {
|
|
||||||
loggerCallback: (level: any, message: any, containsPii: any) => {
|
|
||||||
if (containsPii) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (level) {
|
|
||||||
case LogLevel.Error:
|
|
||||||
console.error(message);
|
|
||||||
return;
|
|
||||||
case LogLevel.Info:
|
|
||||||
console.info(message);
|
|
||||||
return;
|
|
||||||
case LogLevel.Verbose:
|
|
||||||
console.debug(message);
|
|
||||||
return;
|
|
||||||
case LogLevel.Warning:
|
|
||||||
console.warn(message);
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export const loginRequest = {
|
|
||||||
scopes: ["openid", "profile", "email", "api://24cd6c8a-b592-4905-a5ba-d5fa9f911154/user_impersonation"],
|
|
||||||
};
|
|
||||||
|
|
@ -247,7 +247,9 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
icon: 'headset_mic',
|
icon: 'headset_mic',
|
||||||
views: [
|
views: [
|
||||||
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
|
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
|
||||||
{ code: 'sessions', label: 'Sitzungen', path: 'sessions' },
|
{ code: 'assistant', label: 'Assistent', path: 'assistant' },
|
||||||
|
{ code: 'modules', label: 'Module', path: 'modules' },
|
||||||
|
{ code: 'sessions', label: 'Live-Session', path: 'sessions' },
|
||||||
{ code: 'settings', label: 'Einstellungen', path: 'settings' },
|
{ code: 'settings', label: 'Einstellungen', path: 'settings' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -280,8 +282,9 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
icon: 'account_voice',
|
icon: 'account_voice',
|
||||||
views: [
|
views: [
|
||||||
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
|
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
|
||||||
{ code: 'coaching', label: 'Coaching', path: 'coaching' },
|
{ code: 'assistant', label: 'Assistent', path: 'assistant' },
|
||||||
{ code: 'dossier', label: 'Dossier', path: 'dossier' },
|
{ code: 'modules', label: 'Module', path: 'modules' },
|
||||||
|
{ code: 'session', label: 'Session', path: 'session' },
|
||||||
{ code: 'settings', label: 'Einstellungen', path: 'settings' },
|
{ code: 'settings', label: 'Einstellungen', path: 'settings' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue