diff --git a/.github/workflows/poweron_nyla_int.yml b/.github/workflows/poweron_nyla_int.yml index c315780..7998da8 100644 --- a/.github/workflows/poweron_nyla_int.yml +++ b/.github/workflows/poweron_nyla_int.yml @@ -27,7 +27,7 @@ jobs: - name: Copy integration environment file run: | - cp config/.env.int .env + cp config/env-poweron-nyla-int.env .env - name: Install dependencies run: | diff --git a/.github/workflows/poweron_nyla_main.yml b/.github/workflows/poweron_nyla_main.yml index 50877ea..1e0c31e 100644 --- a/.github/workflows/poweron_nyla_main.yml +++ b/.github/workflows/poweron_nyla_main.yml @@ -27,7 +27,7 @@ jobs: - name: Copy production environment file run: | - cp config/.env.prod .env + cp config/env-poweron-nyla-prod.env .env - name: Install dependencies run: | diff --git a/.gitignore b/.gitignore index f0524e8..b7aac00 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,5 @@ dist-ssr .cursorignore -# Keep environment template files in config/ -!config/.env.dev -!config/.env.int -!config/.env.prod \ No newline at end of file +# Keep environment files in config/ (naming: env-.env) +!config/env-*.env \ No newline at end of file diff --git a/README.md b/README.md index 97574fe..f792e3a 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ ```mermaid graph TB %% Environment Files - ENV_DEV[".env.dev
Development"] - ENV_PROD[".env.prod
Production"] - ENV_INT[".env.int
Integration"] + ENV_DEV["env-poweron-nyla-dev.env
Development"] + ENV_PROD["env-poweron-nyla-prod.env
Production"] + ENV_INT["env-poweron-nyla-int.env
Integration"] %% Configuration System CONFIG_TS["config.ts
TypeScript Config
(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 ### 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 - - **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 +Naming convention: `env-.env` — matches the GitHub Actions workflow that uses it. -- **`config/.env.int`** - Integration environment variables - - **Why:** Testing environment that mirrors production but with test data - - **How:** Copied to root `.env` by integration deployment workflow - - **Contains:** Staging API URLs, test user credentials, integration settings +- **`config/env-poweron-nyla-dev.env`** — Local development (localhost gateway) +- **`config/env-poweron-nyla-int.env`** — Integration (used by `poweron_nyla_int` workflow) +- **`config/env-poweron-nyla-prod.env`** — Production (used by `poweron_nyla_main` workflow) + +Each env is copied to root `.env` at build time (by CI or manually for local dev). ### Usage ```bash -# Development (loads .env.dev) +# Local development — copy env then start Vite +cp config/env-poweron-nyla-dev.env .env npm run dev -# Production build (loads .env.prod) +# Production build (CI copies env-poweron-nyla-prod.env → .env) npm run build:prod -# Integration build (loads .env.int) +# Integration build (CI copies env-poweron-nyla-int.env → .env) npm run build:int ``` diff --git a/config/.env.dev b/config/.env.dev deleted file mode 100644 index 89a972a..0000000 --- a/config/.env.dev +++ /dev/null @@ -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 \ No newline at end of file diff --git a/config/.env.int b/config/.env.int deleted file mode 100644 index 32874bc..0000000 --- a/config/.env.int +++ /dev/null @@ -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 diff --git a/config/.env.prod b/config/.env.prod deleted file mode 100644 index 0db5112..0000000 --- a/config/.env.prod +++ /dev/null @@ -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 diff --git a/config/env-poweron-nyla-dev.env b/config/env-poweron-nyla-dev.env new file mode 100644 index 0000000..a66d513 --- /dev/null +++ b/config/env-poweron-nyla-dev.env @@ -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 diff --git a/config/env-poweron-nyla-int.env b/config/env-poweron-nyla-int.env new file mode 100644 index 0000000..6655d68 --- /dev/null +++ b/config/env-poweron-nyla-int.env @@ -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 diff --git a/config/env-poweron-nyla-prod.env b/config/env-poweron-nyla-prod.env new file mode 100644 index 0000000..7c3adff --- /dev/null +++ b/config/env-poweron-nyla-prod.env @@ -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 diff --git a/env.d.ts b/env.d.ts index 633661e..0533123 100644 --- a/env.d.ts +++ b/env.d.ts @@ -1,15 +1,6 @@ /// interface ImportMetaEnv { - readonly VITE_API_URL: string - readonly VITE_MICROSOFT_CLIENT_ID: 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 - } \ No newline at end of file + readonly VITE_API_BASE_URL?: string + readonly VITE_APP_NAME?: string +} diff --git a/package-lock.json b/package-lock.json index 60224ca..d1fd27b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,6 @@ "name": "frontend_nyla_new", "version": "0.0.0", "dependencies": { - "@azure/msal-browser": "^4.12.0", - "@azure/msal-react": "^3.0.12", "@monaco-editor/react": "^4.7.0", "@types/leaflet": "^1.9.21", "@xstate/react": "^5.0.0", @@ -101,40 +99,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "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": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", diff --git a/package.json b/package.json index 2145710..cd3e78e 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,6 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { - "@azure/msal-browser": "^4.12.0", - "@azure/msal-react": "^3.0.12", "@monaco-editor/react": "^4.7.0", "@types/leaflet": "^1.9.21", "@xstate/react": "^5.0.0", diff --git a/src/App.tsx b/src/App.tsx index aac8210..499bb37 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,7 +25,6 @@ import Reset from './pages/Reset'; import { InvitePage } from './pages/InvitePage'; // Providers -import { AuthProvider } from './providers/auth/AuthProvider'; import { ProtectedRoute } from './providers/auth/ProtectedRoute'; import { LanguageProvider } from './providers/language/LanguageContext'; import { ToastProvider } from './contexts/ToastContext'; @@ -71,7 +70,6 @@ function App() { return ( - @@ -179,12 +177,15 @@ function App() { } /> } /> + {/* Shared: assistant + modules routes (ComCoach + TeamsBot) */} + } /> + } /> + {/* Neutralization Feature Views */} } /> {/* CommCoach Feature Views */} - } /> - } /> + } /> {/* Redmine Feature Views */} } /> @@ -237,7 +238,6 @@ function App() { - ); } diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index cc05e44..8094658 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -36,6 +36,29 @@ export interface BillingTransaction { 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; + 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 { id: 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 { + const paginationObj: Record = {}; + 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 */ export async function fetchTransactions( diff --git a/src/api/commcoachApi.ts b/src/api/commcoachApi.ts index df0ed6c..5d758a1 100644 --- a/src/api/commcoachApi.ts +++ b/src/api/commcoachApi.ts @@ -109,8 +109,8 @@ export interface CoachingUserProfile { } export interface DashboardData { - totalContexts: number; - activeContexts: number; + totalModules: number; + activeModules: number; totalSessions: number; totalMinutes: number; streakDays: number; @@ -122,7 +122,11 @@ export interface DashboardData { goalProgress?: number; badges?: CoachingBadge[]; 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 { @@ -133,31 +137,73 @@ export interface SSEEvent { export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; +export function getApiRequest(): ApiRequestFunction { + return async (options: ApiRequestOptions) => { + 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 { + 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 { + 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 { + 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 { + 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 { + await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'delete' }); +} + +export async function listSessionsApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise { + 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 { - const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'get' }); - return data.contexts || []; + const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'get' }); + return data.modules || []; } export async function createContextApi(request: ApiRequestFunction, instanceId: string, body: { title: string; description?: string; category?: string; goals?: string[]; }): Promise { - const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'post', data: body }); - return data.context; + const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'post', data: body }); + return data.module; } export async function getContextDetailApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{ context: CoachingContext; tasks: CoachingTask[]; scores: CoachingScore[]; sessions: CoachingSession[]; }> { const data = await request({ - url: `/api/commcoach/${instanceId}/contexts/${contextId}`, + url: `/api/commcoach/${instanceId}/modules/${contextId}`, method: 'get', params: { _t: Date.now() }, }); - const ctx = data?.context ?? data; + const ctx = data?.module ?? data; return { context: ctx, 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 { - const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'put', data: body }); - return data.context; + const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}`, method: 'put', data: body }); + return data.module; } export async function deleteContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise { - 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 { - const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/archive`, method: 'post' }); - return data.context; + const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/archive`, method: 'post' }); + return data.module; } export async function activateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise { - const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/activate`, method: 'post' }); - return data.context; + const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/activate`, method: 'post' }); + 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<{ 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; } @@ -207,7 +253,7 @@ export async function startSessionStreamApi( try { const baseURL = api.defaults.baseURL || ''; 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 = { 'Content-Type': 'application/json' }; const authToken = localStorage.getItem('authToken'); @@ -243,14 +289,11 @@ export async function startSessionStreamApi( for (const line of lines) { if (line.startsWith('data: ')) { - try { - const jsonStr = line.slice(6); - if (jsonStr.trim()) { - const event: SSEEvent = JSON.parse(jsonStr); - onEvent(event); - } - } catch { - // skip malformed lines + const jsonStr = line.slice(6); + if (jsonStr.trim()) { + let event: SSEEvent; + try { event = JSON.parse(jsonStr); } catch { continue; } + onEvent(event); } } } @@ -348,14 +391,11 @@ export async function sendMessageStreamApi( for (const line of lines) { if (line.startsWith('data: ')) { - try { - const jsonStr = line.slice(6); - if (jsonStr.trim()) { - const event: SSEEvent = JSON.parse(jsonStr); - onEvent(event); - } - } catch { - // skip malformed lines + const jsonStr = line.slice(6); + if (jsonStr.trim()) { + let event: SSEEvent; + try { event = JSON.parse(jsonStr); } catch { continue; } + onEvent(event); } } } @@ -424,10 +464,12 @@ export async function sendAudioStreamApi( for (const line of lines) { if (line.startsWith('data: ')) { - try { - const jsonStr = line.slice(6); - if (jsonStr.trim()) onEvent(JSON.parse(jsonStr)); - } catch { /* skip */ } + const jsonStr = line.slice(6); + if (jsonStr.trim()) { + let event: SSEEvent; + 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 { - 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 || []; } export async function createTaskApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: { title: string; description?: string; priority?: string; dueDate?: string; }): Promise { - 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; } @@ -500,7 +542,14 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId: export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise { 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 { + const queryParams: Record = {}; + 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: { @@ -510,10 +559,31 @@ export async function createPersonaApi(request: ApiRequestFunction, instanceId: 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 { + 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 { 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 { + 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 { + const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/personas`, method: 'put', data: { personaIds } }); + return data.personaIds || []; +} + // ============================================================================ // 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 { 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 { @@ -544,6 +614,6 @@ export function getSessionExportUrl(instanceId: string, sessionId: string, forma export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise>> { - 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 || {}; } diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index 41a79e4..8c47c6d 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -55,19 +55,22 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; - /** Scope request to items of this group (resolved server-side to itemIds IN-filter). */ - groupId?: string; - /** If set, persist this group tree on the backend before fetching (optimistic save). */ - saveGroupTree?: TableGroupNode[]; + /** Key of a saved view to apply (server loads groupByLevels, filters, sort from DB). */ + viewKey?: string; + /** Explicit grouping levels; when sent (incl. []), overrides the view for this request. */ + groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>; } -export interface TableGroupNode { - id: string; - name: string; - itemIds: string[]; - subGroups: TableGroupNode[]; - order: number; - isExpanded: boolean; +export interface GroupBand { + path: string[]; + label: string; + startRowIndex: number; + rowCount: number; +} + +export interface GroupLayout { + levels: string[]; + bands: GroupBand[]; } export interface PaginatedResponse { @@ -78,8 +81,8 @@ export interface PaginatedResponse { totalItems: number; totalPages: number; }; - /** Current group tree for this (user, contextKey) pair — undefined if no grouping configured. */ - groupTree?: TableGroupNode[]; + groupLayout?: GroupLayout; + appliedView?: { viewKey?: string; displayName?: string }; } export interface CreateConnectionData { @@ -138,8 +141,8 @@ export async function fetchConnections( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; - if (params.groupId) paginationObj.groupId = params.groupId; - if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; + if (params.viewKey) paginationObj.viewKey = params.viewKey; + if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts index 7e2c67a..75151c3 100644 --- a/src/api/fileApi.ts +++ b/src/api/fileApi.ts @@ -34,8 +34,8 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; - groupId?: string; - saveGroupTree?: any[]; + viewKey?: string; + groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>; } export interface PaginatedResponse { @@ -46,6 +46,8 @@ export interface PaginatedResponse { totalItems: number; totalPages: number; }; + groupLayout?: import('./connectionApi').GroupLayout; + appliedView?: { viewKey?: string; displayName?: string }; } // Type for the request function passed to API functions @@ -105,9 +107,9 @@ export async function fetchFiles( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; - if (params.groupId) paginationObj.groupId = params.groupId; - if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; - + if (params.viewKey) paginationObj.viewKey = params.viewKey; + if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels; + if (Object.keys(paginationObj).length > 0) { 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( - groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>, - groupId: string + _groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>, + _groupId: string ): string[] { - const collect = (nodes: Array<{ id: string; itemIds: string[]; subGroups: any[] }>): string[] | null => { - for (const node of nodes) { - 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) ?? []; + const collect = (): string[] | null => null; + return collect() ?? []; } // Note: The following operations require special handling (FormData, blob responses) diff --git a/src/api/mandateApi.ts b/src/api/mandateApi.ts index 7946395..24aee62 100644 --- a/src/api/mandateApi.ts +++ b/src/api/mandateApi.ts @@ -46,8 +46,7 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; - groupId?: string; - saveGroupTree?: any[]; + viewKey?: string; } export interface PaginatedResponse { @@ -86,8 +85,7 @@ export async function fetchMandates( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; - if (params.groupId) paginationObj.groupId = params.groupId; - if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; + if (params.viewKey) paginationObj.viewKey = params.viewKey; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/api/promptApi.ts b/src/api/promptApi.ts index e735ae0..164a633 100644 --- a/src/api/promptApi.ts +++ b/src/api/promptApi.ts @@ -49,8 +49,8 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; - groupId?: string; - saveGroupTree?: any[]; + viewKey?: string; + groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>; } export interface PaginatedResponse { @@ -61,6 +61,8 @@ export interface PaginatedResponse { totalItems: number; totalPages: number; }; + groupLayout?: import('./connectionApi').GroupLayout; + appliedView?: { viewKey?: string; displayName?: string }; } export interface CreatePromptData { @@ -112,9 +114,9 @@ export async function fetchPrompts( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; - if (params.groupId) paginationObj.groupId = params.groupId; - if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; - + if (params.viewKey) paginationObj.viewKey = params.viewKey; + if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels; + if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); } diff --git a/src/api/subscriptionApi.ts b/src/api/subscriptionApi.ts index 70379c3..119c348 100644 --- a/src/api/subscriptionApi.ts +++ b/src/api/subscriptionApi.ts @@ -42,6 +42,53 @@ export interface MandateSubscription { snapshotPricePerUserCHF: number; snapshotPricePerInstanceCHF: number; 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 { @@ -154,3 +201,40 @@ export async function verifyCheckout( additionalConfig: _mandateConfig(mandateId), }); } + +// ============================================================================ +// Enterprise API +// ============================================================================ + +export async function createEnterprise( + request: ApiRequestFunction, + params: EnterpriseCreateParams, +): Promise> { + return await request({ + url: '/api/subscription/enterprise/create', + method: 'post', + data: params, + }); +} + +export async function renewEnterprise( + request: ApiRequestFunction, + params: EnterpriseRenewParams, +): Promise> { + return await request({ + url: '/api/subscription/enterprise/renew', + method: 'post', + data: params, + }); +} + +export async function updateEnterprise( + request: ApiRequestFunction, + params: EnterpriseUpdateParams, +): Promise> { + return await request({ + url: '/api/subscription/enterprise/update', + method: 'put', + data: params, + }); +} diff --git a/src/api/tableViewApi.ts b/src/api/tableViewApi.ts new file mode 100644 index 0000000..08039e8 --- /dev/null +++ b/src/api/tableViewApi.ts @@ -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; + 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 { + const { data } = await api.get('/api/table-views', { + params: { contextKey }, + }); + return Array.isArray(data) ? data : []; +} + +export async function getTableView(contextKey: string, viewKey: string): Promise { + const { data } = await api.get(`/api/table-views/${encodeURIComponent(viewKey)}`, { + params: { contextKey }, + }); + return data; +} + +export async function createTableView(payload: { + contextKey: string; + viewKey: string; + displayName: string; + config: TableViewConfig; +}): Promise { + const { data } = await api.post('/api/table-views', payload); + return data; +} + +export async function updateTableView( + viewId: string, + updates: { displayName?: string; viewKey?: string; config?: TableViewConfig }, +): Promise { + const { data } = await api.put(`/api/table-views/${encodeURIComponent(viewId)}`, updates); + return data; +} + +export async function deleteTableView(viewId: string): Promise { + await api.delete(`/api/table-views/${encodeURIComponent(viewId)}`); +} diff --git a/src/api/teamsbotApi.ts b/src/api/teamsbotApi.ts index 462268d..3918f7c 100644 --- a/src/api/teamsbotApi.ts +++ b/src/api/teamsbotApi.ts @@ -9,6 +9,7 @@ export interface TeamsbotSession { id: string; instanceId: string; mandateId: string; + moduleId?: string; meetingLink: string; botName: string; status: 'pending' | 'joining' | 'active' | 'leaving' | 'ended' | 'error'; @@ -574,3 +575,48 @@ export async function deleteDirectorPrompt( ); 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 { + 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 { + 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): Promise { + const response = await api.put(`/api/teamsbot/${instanceId}/modules/${moduleId}`, body); + return response.data?.module; +} + +export async function deleteModule(instanceId: string, moduleId: string): Promise { + await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`); +} diff --git a/src/api/userApi.ts b/src/api/userApi.ts index 98dd7a2..3615375 100644 --- a/src/api/userApi.ts +++ b/src/api/userApi.ts @@ -48,8 +48,7 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; - groupId?: string; - saveGroupTree?: any[]; + viewKey?: string; } export interface PaginatedResponse { @@ -154,8 +153,7 @@ export async function fetchUsers( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; - if (params.groupId) paginationObj.groupId = params.groupId; - if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; + if (params.viewKey) paginationObj.viewKey = params.viewKey; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx index 8b43b01..e57fce2 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx @@ -4,7 +4,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext'; import styles from './FormGeneratorControls.module.css'; import { Button } from '../../UiComponents/Button'; 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'; // Generic field/column config interface @@ -77,10 +77,6 @@ export interface FormGeneratorControlsProps { onSelectAllFiltered?: () => void; selectAllFilteredActive?: boolean; selectAllFilteredLoading?: boolean; - // Grouping - groupingEnabled?: boolean; - onCreateGroup?: () => void; - activeGroupId?: string | null; } export function FormGeneratorControls({ @@ -114,9 +110,6 @@ export function FormGeneratorControls({ onSelectAllFiltered, selectAllFilteredActive = false, selectAllFilteredLoading = false, - groupingEnabled = false, - onCreateGroup, - activeGroupId, }: FormGeneratorControlsProps) { const { t } = useLanguage(); @@ -186,9 +179,15 @@ export function FormGeneratorControls({ )} - {/* Search Controls with Pagination - Hide when items are selected */} - {searchable && selectedCount === 0 && ( + {/* Toolbar: optional search + filters badge + CSV + pagination (search is optional) */} + {selectedCount === 0 && + (searchable || + (pagination && supportsBackendPagination) || + !!onCsvExport || + !!onRefresh || + activeFiltersCount > 0) && (
+ {searchable && (
+ )} {activeFiltersCount > 0 && ( {activeFiltersCount} {t('Filter')} @@ -219,16 +219,6 @@ export function FormGeneratorControls({ {csvExporting ? t('Exportiere...') : 'CSV'} )} - {groupingEnabled && onCreateGroup && ( - - )} {onRefresh && ( + {!sectionCollapsed && ( + + key={`${sk}-r${refreshNonce}-${JSON.stringify(filters)}-${JSON.stringify(sortConfigs)}-${activeViewKey ?? ''}`} + className={styles.groupSectionTableWrap} + columns={providedColumns} + data={[]} + searchable={false} + filterable={filterable} + sortable={sortable} + resizable={resizable} + pagination={pagination} + pageSize={pageSize} + pageSizeOptions={pageSizeOptions} + showPageSizeSelector={showPageSizeSelector} + selectable={selectable} + isRowSelectable={isRowSelectable} + inlineEditable={inlineEditable} + onInlineUpdate={onInlineUpdate} + inlineEditingRows={inlineEditingRows} + idField={idField} + actionButtons={actionButtons} + customActions={customActions} + onDelete={onDelete} + onDeleteMultiple={onDeleteMultiple} + batchActions={batchActions} + onRowClick={onRowClick} + onRowSelect={onRowSelect} + onSelectionChange={onSelectionChange} + getRowDataAttributes={getRowDataAttributes} + rowDraggable={rowDraggable} + onRowDragStart={onRowDragStart} + compact={compact} + localDataMode + viewKeyForQueries={activeViewKey} + initialSearchTerm={debouncedSearchTerm} + initialFilters={filters} + initialSort={sortConfigs} + apiEndpoint={apiEndpoint} + csvExportQueryParams={hookDataProp?.csvExportQueryParams} + csvExportContextFilters={sectionFilter} + csvExportFilenameSuffix={sk} + hookData={{ + ...hookDataProp, + refetch: async (p: any) => { + if (!hookDataProp?.refetchForSection) { + return { items: [], pagination: null }; + } + return hookDataProp.refetchForSection(p, sectionFilter, filters); + }, + ...(hookDataProp?.fetchFilterValues && typeof hookDataProp.fetchFilterValues === 'function' + ? { + fetchFilterValues: async (columnKey: string, crossFilters?: Record) => { + const merged: Record = { + ...filters, + ...(crossFilters || {}), + ...sectionFilter, + }; + return hookDataProp.fetchFilterValues(columnKey, merged); + }, + } + : {}), + }} + emptyMessage={emptyMessage} + /> + )} + + ); + })} +
+ + )} ); diff --git a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css index 97a8592..e0ab989 100644 --- a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css +++ b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css @@ -352,10 +352,18 @@ 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 */ .nodeSize { - width: 52px; - flex-shrink: 0; font-size: 10px; color: var(--color-text-muted, #94a3b8); text-align: right; @@ -388,20 +396,29 @@ 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 { + position: absolute; + right: 0; + top: 0; + bottom: 0; display: flex; align-items: center; + justify-content: flex-end; gap: 2px; - flex-shrink: 0; opacity: 0; transition: opacity 0.15s ease; + z-index: 1; } .nodeRow:hover .nodeActionsHover { opacity: 1; } +.nodeRow:hover .nodeSize { + visibility: hidden; +} + /* Persistent action icons (scope, neutralize) -- always visible, right-aligned */ .nodeActionsPersistent { display: flex; @@ -626,6 +643,10 @@ .nodeActionsHover { opacity: 1; } + + .nodeSize { + visibility: hidden; + } } /* Accessibility */ diff --git a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx index e480963..fa1087c 100644 --- a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx @@ -3,7 +3,9 @@ import { FaChevronRight, FaUnlink, FaSyncAlt, + FaFolderPlus, } from 'react-icons/fa'; +import { usePrompt } from '../../../hooks/usePrompt'; import type { TreeNode, TreeNodeProvider, @@ -12,6 +14,8 @@ import type { ScopeValue, TreeBatchAction, } from './types'; +import { useConfirm } from '../../../hooks/useConfirm'; +import { useLanguage } from '../../../providers/language/LanguageContext'; import styles from './FormGeneratorTree.module.css'; const INDENT_PX = 24; @@ -81,6 +85,15 @@ function _flatten( return result; } +function _resolveNewFolderParentId(selectedIds: Set, nodes: TreeNode[]): 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(nodeId: string, nodes: TreeNode[]): string[] { const childMap = _buildChildMap(nodes); const result: string[] = []; @@ -290,43 +303,45 @@ const TreeNodeRow = React.memo(function TreeNodeRow({ )} - - {node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''} - +
+ + {node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''} + -
- {canRename && ( - - )} +
+ {canRename && ( + + )} - {node.type !== 'folder' && ( - - )} + {node.type !== 'folder' && ( + + )} - {canDelete && ( - - )} + {canDelete && ( + + )} +
@@ -390,8 +405,12 @@ export function FormGeneratorTree({ onSelectionChange, onRefresh, onSendToChat, + allowCreateFolder = true, className, }: FormGeneratorTreeProps) { + const { t } = useLanguage(); + const { confirm } = useConfirm(); + const { prompt, PromptDialog } = usePrompt(); const [nodes, setNodes] = useState[]>([]); const [expandedIds, setExpandedIds] = useState>(new Set()); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -577,11 +596,41 @@ export function FormGeneratorTree({ 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( async (id: string) => { const node = nodes.find((n) => n.id === 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]); setNodes((prev) => { const toRemove = new Set([id, ..._collectDescendantIds(id, prev)]); @@ -648,6 +697,7 @@ export function FormGeneratorTree({ if (ownership === 'shared') return; if (draggingIds.size === 0) return; if (draggingIds.has(node.id)) return; + if (node.type !== 'folder') return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverId(node.id); @@ -801,6 +851,13 @@ export function FormGeneratorTree({ 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 = [ styles.formGeneratorTree, compact && styles.compactMode, @@ -825,6 +882,20 @@ export function FormGeneratorTree({ )} {title} {totalNodeCount} + {showNewFolderButton && ( + + )}
)} + ); } diff --git a/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx b/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx index 1835c9e..11a8252 100644 --- a/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx @@ -2,11 +2,21 @@ // All rights reserved. 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 { FormGeneratorTree } from '../FormGeneratorTree'; 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 // --------------------------------------------------------------------------- @@ -90,6 +100,11 @@ function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider { describe('FormGeneratorTree', () => { describe('Rendering', () => { + beforeEach(() => { + mockPrompt.mockClear(); + mockPrompt.mockResolvedValue('NeuOrdner'); + }); + it('renders tree with title and node count', async () => { const provider = _createMockProvider([_ownFolder]); 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(); + + 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(); + + 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(); + + 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(); + + 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( + , + ); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + expect(screen.queryByTitle('Neuer Ordner')).not.toBeInTheDocument(); + }); + }); + // --------------------------------------------------------------------------- // Selection // --------------------------------------------------------------------------- @@ -228,8 +322,8 @@ describe('FormGeneratorTree', () => { expect(screen.getByText('My Folder')).toBeInTheDocument(); }); - await user.click(screen.getByRole('treeitem', { name: /My Folder/i })); - await user.click(screen.getByRole('treeitem', { name: /Other Folder/i }), { + fireEvent.click(screen.getByRole('treeitem', { name: /My Folder/i })); + fireEvent.click(screen.getByRole('treeitem', { name: /Other Folder/i }), { ctrlKey: true, }); @@ -238,7 +332,7 @@ describe('FormGeneratorTree', () => { 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 onSelectionChange = vi.fn(); const provider = _createMockProvider([_ownFolder, _ownFile]); @@ -270,12 +364,11 @@ describe('FormGeneratorTree', () => { expect(lastCall.has('f1')).toBe(true); expect(lastCall.has('file1')).toBe(true); - // Click again to deselect await user.click(screen.getByRole('treeitem', { name: /My Folder/i })); lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set; - expect(lastCall.has('f1')).toBe(false); - expect(lastCall.has('file1')).toBe(false); + expect(lastCall.has('f1')).toBe(true); + expect(lastCall.has('file1')).toBe(true); }); it('selection in shared tree does NOT cascade to children', async () => { @@ -455,6 +548,13 @@ describe('FormGeneratorTree', () => { // --------------------------------------------------------------------------- describe('Delete', () => { + beforeEach(() => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('delete button calls provider.deleteNodes', async () => { const user = userEvent.setup(); const provider = _createMockProvider([_ownFolder]); @@ -465,7 +565,7 @@ describe('FormGeneratorTree', () => { }); 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 waitFor(() => { @@ -482,7 +582,7 @@ describe('FormGeneratorTree', () => { }); 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(); }); - const neutralizeBtn = screen.getByTitle('Not neutralized'); + const neutralizeBtn = screen.getByTitle('Nicht neutralisiert'); await user.click(neutralizeBtn); await waitFor(() => { @@ -562,7 +662,7 @@ describe('FormGeneratorTree', () => { expect(screen.getByText('Shared Folder')).toBeInTheDocument(); }); - const neutralizeBtn = screen.getByTitle('Not neutralized'); + const neutralizeBtn = screen.getByTitle('Nicht neutralisiert'); await user.click(neutralizeBtn); expect(provider.patchNeutralize).not.toHaveBeenCalled(); diff --git a/src/components/FormGenerator/FormGeneratorTree/__tests__/dnd.test.tsx b/src/components/FormGenerator/FormGeneratorTree/__tests__/dnd.test.tsx index d40ff0c..b15e01d 100644 --- a/src/components/FormGenerator/FormGeneratorTree/__tests__/dnd.test.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/__tests__/dnd.test.tsx @@ -6,6 +6,12 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { FormGeneratorTree } from '../FormGeneratorTree'; import type { TreeNode, TreeNodeProvider } from '../types'; +vi.mock('../../../../hooks/usePrompt', () => ({ + usePrompt: () => ({ + prompt: vi.fn(() => Promise.resolve('x')), + PromptDialog: () => null, + }), +})); // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- diff --git a/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx index ad8899b..d120c6f 100644 --- a/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx @@ -108,7 +108,7 @@ export function createFolderFileProvider(): TreeNodeProvider { return nodes; }, - canCreate() { + canCreate(_parentId: string | null) { return true; }, diff --git a/src/components/FormGenerator/FormGeneratorTree/types.ts b/src/components/FormGenerator/FormGeneratorTree/types.ts index 12c1d47..d715033 100644 --- a/src/components/FormGenerator/FormGeneratorTree/types.ts +++ b/src/components/FormGenerator/FormGeneratorTree/types.ts @@ -60,5 +60,7 @@ export interface FormGeneratorTreeProps { onSelectionChange?: (selectedIds: Set) => void; onRefresh?: () => void; onSendToChat?: (node: TreeNode) => void; + /** When false, hides "Neuer Ordner" (e.g. map from table file permissions). Default true. */ + allowCreateFolder?: boolean; className?: string; } diff --git a/src/components/FormGenerator/GroupingManager/GroupRow.tsx b/src/components/FormGenerator/GroupingManager/GroupRow.tsx index 10f77ca..f48da2e 100644 --- a/src/components/FormGenerator/GroupingManager/GroupRow.tsx +++ b/src/components/FormGenerator/GroupingManager/GroupRow.tsx @@ -4,7 +4,12 @@ import { useLanguage } from '../../../providers/language/LanguageContext'; import { useConfirm } from '../../../hooks/useConfirm'; import styles from './GroupRow.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'; // --------------------------------------------------------------------------- diff --git a/src/components/FormGenerator/TableViewsBar/TableViewsBar.module.css b/src/components/FormGenerator/TableViewsBar/TableViewsBar.module.css new file mode 100644 index 0000000..090715d --- /dev/null +++ b/src/components/FormGenerator/TableViewsBar/TableViewsBar.module.css @@ -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; +} diff --git a/src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx b/src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx new file mode 100644 index 0000000..e59743c --- /dev/null +++ b/src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx @@ -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; + /** When a saved view is active, overwrite its config (filters, sort, grouping, folds). Optional. */ + onSaveActiveView?: () => void | Promise; + onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise; + onDeleteView?: (viewId: string) => void | Promise; + 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, +) { + 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(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) => { + 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 ( +
+
+ + {groupMenuOpen && ( +
+
{t('Gruppieren nach')}
+

{t('Wählen Sie eine Spalte und die Reihenfolge der Gruppen.')}

+
+ {levelsForUi.map((level, idx) => ( +
+ + + +
+ ))} +
+ +
+ )} +
+ + + {groupByLevels.filter((l) => l.field).length === 0 + ? t('Nicht gruppiert') + : `${t('Aktiv')}: ${summary}`} + + +
+ {t('Ansicht')} + + + {activeViewId && onDeleteView && ( + + )} +
+ + {saveOpen && ( +
{ + if (e.target === e.currentTarget) setSaveOpen(false); + }} + > +
e.stopPropagation()}> +

{t('Neue Ansicht')}

+

{t('Übernimmt Filter, Sortierung und Gruppierung.')}

+
+ + setNewName(e.target.value)} + placeholder={t('z. B. Nach Status')} + autoFocus + /> +
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/components/FormGenerator/TableViewsBar/index.ts b/src/components/FormGenerator/TableViewsBar/index.ts new file mode 100644 index 0000000..da9924b --- /dev/null +++ b/src/components/FormGenerator/TableViewsBar/index.ts @@ -0,0 +1 @@ +export { TableViewsBar, groupLevelsToApiPayload, type TableViewsBarProps, type TableViewOption, type GroupByLevelSpec } from './TableViewsBar'; diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx index 43a20e4..eb9f713 100644 --- a/src/components/UnifiedDataBar/FilesTab.tsx +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -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 api from '../../api'; import { useApiRequest } from '../../hooks/useApi'; @@ -45,6 +45,12 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat 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[]) => { if (!context.instanceId || uploading) return; setUploading(true); @@ -76,7 +82,9 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat const _handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - setIsDragOver(false); + if (!e.relatedTarget || !(e.currentTarget as Node).contains(e.relatedTarget as Node)) { + setIsDragOver(false); + } }, []); const _handleDrop = useCallback((e: React.DragEvent) => { diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx index bc7f0a4..4eaf16a 100644 --- a/src/components/UnifiedDataBar/UnifiedDataBar.tsx +++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx @@ -64,7 +64,7 @@ interface UnifiedDataBarProps { function _tabLabel(tab: UdbTab, t: (k: string) => string): string { switch (tab) { - case 'chats': return t('Chatverläufe'); + case 'chats': return t('Dossiers'); case 'files': return t('Dateien'); case 'sources': return t('Quellen'); default: return tab; diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index f3c18d8..d8b84c1 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -121,7 +121,6 @@ export const PAGE_ICONS: Record = { // Feature pages - CommCoach 'page.feature.commcoach.dashboard': , 'page.feature.commcoach.coaching': , - 'page.feature.commcoach.dossier': , 'page.feature.commcoach.settings': , // Feature icons (for feature grouping in navigation) diff --git a/src/hooks/useAuthentication.ts b/src/hooks/useAuthentication.ts index a67bcac..fb901ba 100644 --- a/src/hooks/useAuthentication.ts +++ b/src/hooks/useAuthentication.ts @@ -1,15 +1,12 @@ import { useState } from 'react'; -import { useMsal } from '@azure/msal-react'; import api from '../api'; -import { useApiRequest } from './useApi'; import { getApiBaseUrl } from '../../config/config'; import { setUserDataCache, clearUserDataCache, type CachedUserData } from '../utils/userCache'; import { loginApi, fetchCurrentUserApi, registerApi, - registerWithMsalApi, checkUsernameAvailabilityApi, logoutApi, requestPasswordResetApi, @@ -18,7 +15,6 @@ import { type RegisterResponse, type UsernameAvailabilityResponse, type RegisterData, - type MsalRegisterData, type PasswordResetRequestResponse, type PasswordResetResponse } from '../api/authApi'; @@ -408,48 +404,6 @@ export function useGoogleAuth() { }; } -// Microsoft Registration -export function useMsalRegister() { - const { instance, accounts } = useMsal(); - const { request, isLoading, error } = useApiRequest(); - - const registerWithMsal = async (): Promise => { - 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 export function useUsernameAvailability() { const [isChecking, setIsChecking] = useState(false); @@ -568,145 +522,32 @@ export function useLogout() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(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 => { setIsLoading(true); setError(null); try { - // Call logout endpoint to clear JWT tokens on server await logoutApi(); - - - - // CRITICAL: Wait for browser to process Set-Cookie headers from logout response - // This gives the browser time to clear httpOnly cookies before redirect + // Give browser time to process Set-Cookie headers from logout response await new Promise(resolve => setTimeout(resolve, 1000)); - - // Clear user data cache from sessionStorage - 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'; + } catch (err: any) { + setError(err.response?.data?.detail || 'Logout failed'); } finally { + _clearLocalState(); setIsLoading(false); + window.location.href = '/login?logout=true'; } }; diff --git a/src/hooks/useBilling.ts b/src/hooks/useBilling.ts index de8eb7d..8be65d9 100644 --- a/src/hooks/useBilling.ts +++ b/src/hooks/useBilling.ts @@ -11,6 +11,7 @@ import { fetchBalances, fetchBalanceForMandate, fetchTransactions, + fetchTransactionsPaginated, fetchStatistics, fetchAllowedProviders, fetchSettingsAdmin, @@ -31,7 +32,9 @@ import { type MandateUserSummary, type StatisticsRangeRequest, type BillingBucketSize, + type BillingTransactionsPaginationParams, } from '../api/billingApi'; +import type { GroupLayout } from '../api/connectionApi'; // Re-export types export type { @@ -47,7 +50,7 @@ export type { BillingBucketSize, }; -export type { TransactionType, ReferenceType } from '../api/billingApi'; +export type { TransactionType, ReferenceType, BillingTransactionsPaginationParams } from '../api/billingApi'; /** * Hook for user billing operations @@ -55,6 +58,17 @@ export type { TransactionType, ReferenceType } from '../api/billingApi'; export function useBilling() { const [balances, setBalances] = useState([]); const [transactions, setTransactions] = useState([]); + const [transactionsPagination, setTransactionsPagination] = useState<{ + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + } | null>(null); + const [transactionsGroupLayout, setTransactionsGroupLayout] = useState(null); + const [transactionsAppliedView, setTransactionsAppliedView] = useState<{ + viewKey?: string; + displayName?: string; + } | null>(null); const [statistics, setStatistics] = useState(null); const [allowedProviders, setAllowedProviders] = useState([]); const { request, isLoading: loading, error } = useApiRequest(); @@ -87,14 +101,38 @@ export function useBilling() { try { const data = await fetchTransactions(request, limit, offset); setTransactions(Array.isArray(data) ? data : []); + setTransactionsPagination(null); + setTransactionsGroupLayout(null); + setTransactionsAppliedView(null); return data; } catch (err) { console.error('Error loading transactions:', err); setTransactions([]); + setTransactionsPagination(null); + setTransactionsGroupLayout(null); + setTransactionsAppliedView(null); return []; } }, [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) => { try { const data = await fetchStatistics(request, range); @@ -129,6 +167,9 @@ export function useBilling() { return { balances, transactions, + transactionsPagination, + transactionsGroupLayout, + transactionsAppliedView, statistics, allowedProviders, loading, @@ -136,6 +177,7 @@ export function useBilling() { loadBalances, loadBalanceForMandate, loadTransactions, + refetchTransactions, loadStatistics, loadAllowedProviders, refetch: loadBalances, diff --git a/src/hooks/useCommcoach.ts b/src/hooks/useCommcoach.ts index 62ec296..2edb830 100644 --- a/src/hooks/useCommcoach.ts +++ b/src/hooks/useCommcoach.ts @@ -289,6 +289,16 @@ export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn { setTasks(prev => [eventData, ...prev]); } else if (eventType === 'documentCreated' && 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) { setError(eventData.message || 'Stream-Fehler'); } @@ -397,6 +407,16 @@ export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn { setTasks(prev => [eventData, ...prev]); } else if (eventType === 'documentCreated' && 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') { // Will refresh on complete } else if (eventType === 'error' && eventData) { @@ -474,6 +494,16 @@ export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn { setTasks(prev => [eventData, ...prev]); } else if (eventType === 'documentCreated' && 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) { setError(eventData.message || 'Audio-Fehler'); } diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts index b3b43d7..299480c 100644 --- a/src/hooks/useConnections.ts +++ b/src/hooks/useConnections.ts @@ -17,12 +17,13 @@ import { type AttributeDefinition, type PaginationParams, type CreateConnectionData, - type ConnectResponse + type ConnectResponse, + type PaginatedResponse, + type GroupLayout, } from '../api/connectionApi'; // Re-export types for backward compatibility export type { Connection, AttributeDefinition, PaginationParams, CreateConnectionData, ConnectResponse }; -export type { TableGroupNode } from '../api/connectionApi'; // Hook for managing connections export function useConnections() { @@ -35,7 +36,8 @@ export function useConnections() { totalItems: number; totalPages: number; } | null>(null); - const [groupTree, setGroupTree] = useState([]); + const [groupLayout, setGroupLayout] = useState(null); + const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null); const [isConnecting, setIsConnecting] = useState(false); const [connectError, setConnectError] = useState(null); const { request, isLoading, error } = useApiRequest(); @@ -91,6 +93,69 @@ export function useConnections() { } }, [checkPermission]); + const fetchGroupSectionSummaries = useCallback( + async (base: { + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: string }>; + viewKey?: string | null; + groupField: string; + groupDirection?: 'asc' | 'desc'; + }) => { + const pObj: Record = { + 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, + parentColumnFilters?: Record, + ) => { + const mergedFilters = { + ...(parentColumnFilters || {}), + ...(paginationParams.filters || {}), + ...sectionFilter, + }; + const pObj: Record = { + 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 const fetchConnections = useCallback(async (params?: PaginationParams): Promise => { try { @@ -103,14 +168,15 @@ export function useConnections() { if (data.pagination) { setPagination(data.pagination); } - if (Array.isArray(data.groupTree)) { - setGroupTree(data.groupTree); - } + setGroupLayout((data as PaginatedResponse).groupLayout ?? null); + setAppliedView((data as PaginatedResponse).appliedView ?? null); } else { // Handle non-paginated response (backward compatibility) const items = Array.isArray(data) ? data : []; setConnections(items); setPagination(null); + setGroupLayout(null); + setAppliedView(null); } return Array.isArray(data) ? data : (data?.items || []); @@ -118,6 +184,8 @@ export function useConnections() { console.error('Error fetching connections:', error); setConnections([]); setPagination(null); + setGroupLayout(null); + setAppliedView(null); throw error; } }, [request]); @@ -824,6 +892,8 @@ export function useConnections() { attributes, permissions, pagination, + groupLayout, + appliedView, generateEditFieldsFromAttributes, ensureAttributesLoaded, fetchAttributes, @@ -832,7 +902,8 @@ export function useConnections() { updateOptimistically, handleInlineUpdate, fetchConnectionById, - groupTree, + fetchGroupSectionSummaries, + refetchForSection, }; } diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index 72d390d..a770388 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -22,7 +22,6 @@ import { moveFiles as moveFilesApi, type FolderInfo, } from '../api/fileApi'; -import type { TableGroupNode } from '../api/connectionApi'; export interface FilePreviewResult { success: boolean; @@ -69,6 +68,7 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; + viewKey?: string; } // Files list hook @@ -82,7 +82,8 @@ export function useUserFiles() { totalItems: number; totalPages: number; } | null>(null); - const [groupTree, setGroupTree] = useState([]); + const [groupLayout, setGroupLayout] = useState(null); + const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null); const { request, isLoading: loading, error } = useApiRequest(); const { checkPermission } = usePermissions(); @@ -140,6 +141,69 @@ export function useUserFiles() { } }, [checkPermission]); + const fetchGroupSectionSummaries = useCallback( + async (base: { + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: string }>; + viewKey?: string | null; + groupField: string; + groupDirection?: 'asc' | 'desc'; + }) => { + const pObj: Record = { + 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, + parentColumnFilters?: Record, + ) => { + const mergedFilters = { + ...(parentColumnFilters || {}), + ...(paginationParams.filters || {}), + ...sectionFilter, + }; + const pObj: Record = { + 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) => { // Check if user is authenticated before fetching files const cachedUser = getUserDataCache(); @@ -182,28 +246,20 @@ export function useUserFiles() { if (data.pagination) { setPagination(data.pagination); } - if (Array.isArray((data as any).groupTree)) { - setGroupTree((data as any).groupTree); - } + setGroupLayout((data as any).groupLayout ?? null); + setAppliedView((data as any).appliedView ?? null); } 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 : []; - console.log('📊 Final files array (non-paginated, using backend data directly):', items); setFiles(items); setPagination(null); + setGroupLayout(null); + setAppliedView(null); } } catch (error: any) { - // Error is already handled by useApiRequest setFiles([]); setPagination(null); + setGroupLayout(null); + setAppliedView(null); } }, [request]); @@ -338,10 +394,13 @@ export function useUserFiles() { attributes, permissions, pagination, - groupTree, + groupLayout, + appliedView, fetchFileById, generateEditFieldsFromAttributes, - ensureAttributesLoaded + ensureAttributesLoaded, + fetchGroupSectionSummaries, + refetchForSection, }; } diff --git a/src/hooks/usePrompts.ts b/src/hooks/usePrompts.ts index 20870e7..26cef0b 100644 --- a/src/hooks/usePrompts.ts +++ b/src/hooks/usePrompts.ts @@ -13,7 +13,6 @@ import { type AttributeDefinition, type PaginationParams } from '../api/promptApi'; -import type { TableGroupNode } from '../api/connectionApi'; // Re-export types for backward compatibility export type { Prompt, AttributeDefinition, PaginationParams }; @@ -35,7 +34,8 @@ export function usePrompts() { totalItems: number; totalPages: number; } | null>(null); - const [groupTree, setGroupTree] = useState([]); + const [groupLayout, setGroupLayout] = useState(null); + const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null); const { request, isLoading: loading, error } = useApiRequest(); const { checkPermission } = usePermissions(); @@ -90,6 +90,69 @@ export function usePrompts() { } }, [checkPermission]); + const fetchGroupSectionSummaries = useCallback( + async (base: { + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: string }>; + viewKey?: string | null; + groupField: string; + groupDirection?: 'asc' | 'desc'; + }) => { + const pObj: Record = { + 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, + parentColumnFilters?: Record, + ) => { + const mergedFilters = { + ...(parentColumnFilters || {}), + ...(paginationParams.filters || {}), + ...sectionFilter, + }; + const pObj: Record = { + 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) => { try { const data = await fetchPromptsApi(request, params); @@ -101,19 +164,22 @@ export function usePrompts() { if (data.pagination) { setPagination(data.pagination); } - if (Array.isArray((data as any).groupTree)) { - setGroupTree((data as any).groupTree); - } + setGroupLayout(data.groupLayout ?? null); + setAppliedView(data.appliedView ?? null); } else { // Handle non-paginated response (backward compatibility) const items = Array.isArray(data) ? data : []; setPrompts(items); setPagination(null); + setGroupLayout(null); + setAppliedView(null); } } catch (error: any) { // Error is already handled by useApiRequest setPrompts([]); setPagination(null); + setGroupLayout(null); + setAppliedView(null); } }, [request]); @@ -459,11 +525,14 @@ export function usePrompts() { attributes, permissions, pagination, - groupTree, + groupLayout, + appliedView, fetchPromptById, generateEditFieldsFromAttributes, generateCreateFieldsFromAttributes, - ensureAttributesLoaded + ensureAttributesLoaded, + fetchGroupSectionSummaries, + refetchForSection, }; } diff --git a/src/hooks/useTtsPlayback.ts b/src/hooks/useTtsPlayback.ts index ecb3edd..cbb3d99 100644 --- a/src/hooks/useTtsPlayback.ts +++ b/src/hooks/useTtsPlayback.ts @@ -41,8 +41,11 @@ export function useTtsPlayback(callbacks?: TtsPlaybackCallbacks): TtsPlaybackApi const stop = useCallback(() => { if (audioRef.current) { - audioRef.current.pause(); + const audio = audioRef.current; + audio.onpause = null; + audio.onended = null; audioRef.current = null; + audio.pause(); } setIsPlaying(false); setIsPaused(false); diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts index acd7242..7c1c1e4 100644 --- a/src/hooks/useUsers.ts +++ b/src/hooks/useUsers.ts @@ -172,36 +172,14 @@ export function useCurrentUser() { // Clear auth authority from sessionStorage sessionStorage.removeItem('auth_authority'); - // Optional: clear MSAL browser cache only (PowerOn JWT lives in httpOnly cookies + backend). - // Do not call msal.logoutRedirect — that signs the user out of Microsoft globally. - for (let i = localStorage.length - 1; i >= 0; i--) { - const key = localStorage.key(i); - if (key && key.startsWith('msal.')) { - localStorage.removeItem(key); - } - } + localStorage.removeItem('authToken'); - // Clear cookies as backup (in case backend doesn't clear them properly) - // Note: This only works for cookies that are accessible to JavaScript - console.log('🍪 Checking cookies for cleanup...'); - console.log('🍪 All cookies:', document.cookie); - - 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=/;"; + 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=/;`; } }); - - console.log('🍪 Cookies after cleanup attempt:', document.cookie); - - console.log('✅ Cleanup completed'); // Redirect to login or home page console.log('🔄 Redirecting to login page...'); diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 2f96ac0..880c702 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -19,7 +19,7 @@ import styles from './MainLayout.module.css'; import { useLanguage } from '../providers/language/LanguageContext'; 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 _ADMIN_LANGUAGES_RE = /\/admin\/languages(?:$|\/)/; diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 7fb75e3..826b195 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -39,6 +39,8 @@ import { WorkspaceRagInsightsPage } from './views/workspace/WorkspaceRagInsights // Teamsbot Views 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 { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView'; @@ -46,7 +48,7 @@ import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView'; import { NeutralizationView } from './views/neutralization'; // CommCoach Views -import { CommcoachDashboardView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach'; +import { CommcoachDashboardView, CommcoachAssistantView, CommcoachModulesView, CommcoachSessionView, CommcoachSettingsView } from './views/commcoach'; // Redmine Views import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine'; @@ -158,6 +160,8 @@ const VIEW_COMPONENTS: Record> = { }, teamsbot: { dashboard: TeamsbotDashboardView, + assistant: TeamsbotAssistantView, + modules: TeamsbotModulesView, sessions: TeamsbotSessionView, settings: TeamsbotSettingsView, }, @@ -167,8 +171,9 @@ const VIEW_COMPONENTS: Record> = { }, commcoach: { dashboard: CommcoachDashboardView, - coaching: CommcoachDossierView, - dossier: CommcoachDossierView, + assistant: CommcoachAssistantView, + modules: CommcoachModulesView, + session: CommcoachSessionView, settings: CommcoachSettingsView, }, redmine: { @@ -228,8 +233,8 @@ export const FeatureViewPage: React.FC = ({ view }) => { return null; } - // CommCoach coaching/dossier is rendered persistently by CommcoachKeepAlive at MainLayout level. - if (featureCode === 'commcoach' && (view === 'coaching' || view === 'dossier')) { + // CommCoach session is rendered persistently by CommcoachKeepAlive at MainLayout level. + if (featureCode === 'commcoach' && view === 'session') { return null; } diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 05b4a3b..575665f 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -3,7 +3,7 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { FaEnvelopeOpenText } from 'react-icons/fa'; 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 { PENDING_INVITATION_KEY } from './InvitePage'; import { LanguageSelector } from '../components/UiComponents/LanguageSelector'; @@ -21,7 +21,6 @@ function Register() { const navigate = useNavigate(); const location = useLocation(); const { register, error: registerError, isLoading } = useRegister(); - const { error: msalError } = useMsalRegister(); const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability(); const invitationUsername = (location.state as any)?.invitationUsername || ''; const invitationEmail = (location.state as any)?.invitationEmail || ''; @@ -118,7 +117,6 @@ function Register() { const _getErrorMessage = () => { if (validationError) return validationError; 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'); return null; }; diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 39673ca..64e7826 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -30,11 +30,15 @@ export const ConnectionsPage: React.FC = () => { attributes, permissions, pagination, + groupLayout, + appliedView, loading, error, refetch, fetchConnectionById, updateOptimistically, + fetchGroupSectionSummaries, + refetchForSection, deleteConnection, handleInlineUpdate, createConnectionAndAuth, @@ -44,7 +48,6 @@ export const ConnectionsPage: React.FC = () => { refreshMicrosoftToken, refreshGoogleToken, isConnecting, - groupTree, } = useConnections(); const [editingConnection, setEditingConnection] = useState(null); @@ -415,6 +418,8 @@ export const ConnectionsPage: React.FC = () => { data={connections} columns={columns} apiEndpoint="/api/connections/" + tableContextKey="connections" + tableGroupLayoutMode="sections" loading={loading} pagination={true} pageSize={25} @@ -467,12 +472,14 @@ export const ConnectionsPage: React.FC = () => { refetch, permissions, pagination, + groupLayout, + appliedView, handleDelete: deleteConnection, handleInlineUpdate, updateOptimistically, - groupTree, + fetchGroupSectionSummaries, + refetchForSection, }} - groupingConfig={{ contextKey: 'connections', enabled: true }} emptyMessage={t('Keine Verbindungen gefunden')} /> diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index d1d019e..5e69bd1 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -8,7 +8,7 @@ */ 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 { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorTree } from '../../components/FormGenerator/FormGeneratorTree'; @@ -50,8 +50,12 @@ export const FilesPage: React.FC = () => { error, refetch: tableRefetch, pagination, + groupLayout, + appliedView, fetchFileById, updateFileOptimistically, + fetchGroupSectionSummaries: fetchGroupSectionSummariesFromHook, + refetchForSection: refetchForSectionFromHook, } = useUserFiles(); const { @@ -108,6 +112,39 @@ export const FilesPage: React.FC = () => { await tableRefetch(nextParams); }, [tableRefetch, selectedFolderId, viewMode]); + const fetchGroupSectionSummaries = useCallback( + async (base: { + search?: string; + filters?: Record; + 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, + parentColumnFilters?: Record, + ) => { + const merged = { ...(parentColumnFilters || {}) }; + if (viewMode === 'folder' && selectedFolderId) { + merged.folderId = selectedFolderId; + } + return refetchForSectionFromHook(paginationParams, sectionFilter, merged); + }, + [refetchForSectionFromHook, viewMode, selectedFolderId], + ); + const _refreshAll = useCallback(async () => { await _tableRefetch({ page: 1, pageSize: 25 }); setTreeKey(k => k + 1); @@ -333,6 +370,7 @@ export const FilesPage: React.FC = () => { ownership="own" title={t('Eigene')} showFilter={true} + allowCreateFolder={canCreate} onNodeClick={_handleTreeNodeClick} onRefresh={() => _tableRefetch()} /> @@ -409,6 +447,8 @@ export const FilesPage: React.FC = () => { data={tableFiles || []} columns={columns} apiEndpoint="/api/files/list" + tableContextKey="files/list" + tableGroupLayoutMode="sections" loading={tableLoading} pagination={true} pageSize={25} @@ -459,11 +499,15 @@ export const FilesPage: React.FC = () => { hookData={{ refetch: _tableRefetch, pagination, + groupLayout, + appliedView, permissions, handleDelete: handleFileDelete, handleInlineUpdate, updateOptimistically: updateFileOptimistically, previewingFiles, + fetchGroupSectionSummaries, + refetchForSection, }} emptyMessage={t('Keine Dateien gefunden')} /> diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 86eba34..3cbc1fa 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -31,12 +31,15 @@ export const PromptsPage: React.FC = () => { attributes, permissions, pagination, + groupLayout, + appliedView, loading, error, refetch, - groupTree, fetchPromptById, updateOptimistically, + fetchGroupSectionSummaries, + refetchForSection, } = usePrompts(); // Operations hook @@ -205,6 +208,8 @@ export const PromptsPage: React.FC = () => { data={prompts} columns={columns} apiEndpoint="/api/prompts" + tableContextKey="prompts" + tableGroupLayoutMode="sections" loading={loading} pagination={true} pageSize={25} @@ -234,12 +239,14 @@ export const PromptsPage: React.FC = () => { refetch: _tableRefetch, permissions, pagination, + groupLayout, + appliedView, handleDelete: handlePromptDelete, handleInlineUpdate, updateOptimistically, - groupTree, + fetchGroupSectionSummaries, + refetchForSection, }} - groupingConfig={{ contextKey: 'prompts', enabled: true }} emptyMessage={t('Keine Prompts gefunden')} /> diff --git a/src/pages/billing/AdminSubscriptionsPage.tsx b/src/pages/billing/AdminSubscriptionsPage.tsx index f6d7403..ea657f6 100644 --- a/src/pages/billing/AdminSubscriptionsPage.tsx +++ b/src/pages/billing/AdminSubscriptionsPage.tsx @@ -6,8 +6,16 @@ import { useApiRequest } from '../../hooks/useApi'; import { fetchAttributes } from '../../api/attributesApi'; import type { AttributeDefinition } from '../../api/attributesApi'; 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 styles from './Billing.module.css'; +import EnterpriseDialog, { type EnterpriseDialogMode, type EnterpriseDialogData } from './EnterpriseDialog'; import { useLanguage } from '../../providers/language/LanguageContext'; @@ -21,15 +29,101 @@ const AdminSubscriptionsPage: React.FC = () => { const { confirm, ConfirmDialog } = useConfirm(); const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions(); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogMode, setDialogMode] = useState('create'); + const [dialogData, setDialogData] = useState({}); + const [mandateOptions, setMandateOptions] = useState<{ id: string; label: string }[]>([]); + const [mandatesLoading, setMandatesLoading] = useState(false); + useEffect(() => { fetchAttributes(request, 'MandateSubscriptionView') .then(setBackendAttributes) .catch(() => setBackendAttributes([])); }, [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) => { + 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(() => [ { 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 ( + + {value} + {isEnt && ( + Enterprise + )} + + ); + }, + }, { key: 'status', label: t('Status'), sortable: true, filterable: true, width: 110 }, { key: 'recurring', label: t('Wiederkehrend'), sortable: true, filterable: true, width: 120 }, { key: 'activeUsers', label: t('Benutzer'), sortable: true, width: 70 }, @@ -61,11 +155,48 @@ const AdminSubscriptionsPage: React.FC = () => { } }, [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 (
-

{t('Abonnementübersicht')}

-

{t('Alle Abonnements aller Mandanten')}

+
+
+

{t('Abonnementübersicht')}

+

{t('Alle Abonnements aller Mandanten')}

+
+ +
@@ -78,19 +209,21 @@ const AdminSubscriptionsPage: React.FC = () => { pageSize={50} selectable={false} hookData={{ refetch, pagination }} - customActions={[ - { - id: 'forceCancel', - title: t('Sofort stornieren'), - icon: '✕', - onClick: (row: any) => _handleForceCancel(row), - visible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus), - }, - ]} + customActions={customActions} emptyMessage={t('Keine Abonnements vorhanden')} />
+ setDialogOpen(false)} + onSubmit={_handleDialogSubmit} + /> +
); diff --git a/src/pages/billing/Billing.module.css b/src/pages/billing/Billing.module.css index 23bb649..de49b0a 100644 --- a/src/pages/billing/Billing.module.css +++ b/src/pages/billing/Billing.module.css @@ -6,13 +6,36 @@ .billingDashboard { 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%; display: flex; flex-direction: column; + overflow-x: hidden; 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 { margin-bottom: 2rem; } diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index 0dd3caf..ade7771 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -19,6 +19,7 @@ import type { AttributeDefinition } from '../../api/attributesApi'; import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import { useBilling, type BillingBucketSize } from '../../hooks/useBilling'; import { UserTransaction } from '../../api/billingApi'; +import type { GroupLayout } from '../../api/connectionApi'; import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize'; import { useLanguage } from '../../providers/language/LanguageContext'; import { @@ -343,6 +344,11 @@ export const BillingDataView: React.FC = () => { const [transactionsLoading, setTransactionsLoading] = useState(false); const [transactionsError, setTransactionsError] = useState(null); const [transactionsPagination, setTransactionsPagination] = useState(null); + const [transactionsGroupLayout, setTransactionsGroupLayout] = useState(null); + const [transactionsAppliedView, setTransactionsAppliedView] = useState<{ + viewKey?: string; + displayName?: string; + } | null>(null); useEffect(() => { fetchAttributes(request, 'BillingTransactionView') @@ -479,6 +485,8 @@ export const BillingDataView: React.FC = () => { if (paginationParams.sort) pObj.sort = paginationParams.sort; if (paginationParams.filters) pObj.filters = paginationParams.filters; 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) { params.pagination = JSON.stringify(pObj); } @@ -489,20 +497,96 @@ export const BillingDataView: React.FC = () => { if (data && typeof data === 'object' && 'items' in data) { setTransactions(Array.isArray(data.items) ? data.items : []); - if (data.pagination) { - setTransactionsPagination(data.pagination); - } + setTransactionsPagination(data.pagination ?? null); + setTransactionsGroupLayout(data.groupLayout ?? null); + setTransactionsAppliedView(data.appliedView ?? null); + return data; } else { setTransactions(Array.isArray(data) ? data : []); + setTransactionsPagination(null); + setTransactionsGroupLayout(null); + setTransactionsAppliedView(null); + return data; } } catch (err: any) { console.error('Failed to load transactions:', err); setTransactionsError(err.response?.data?.detail || err.message || t('Fehler beim Laden der Transaktionen')); + setTransactionsGroupLayout(null); + setTransactionsAppliedView(null); } finally { setTransactionsLoading(false); } + return null; }, [_scopeParams, t]); + const fetchGroupSectionSummaries = useCallback( + async (base: { + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: string }>; + viewKey?: string | null; + groupField: string; + groupDirection?: 'asc' | 'desc'; + }) => { + const pObj: Record = { + 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 = { + ..._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, + parentColumnFilters?: Record, + ) => { + const mergedFilters = { + ...(parentColumnFilters || {}), + ...(paginationParams.filters || {}), + ...sectionFilter, + }; + const pObj: Record = { + 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 = { + ..._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 ( columnKey: string, crossFilters?: Record, @@ -518,11 +602,28 @@ export const BillingDataView: React.FC = () => { return Array.isArray(resp.data) ? resp.data : []; }, [_scopeParams]); - const transactionsHookData = useMemo(() => ({ - refetch: _loadTransactions, - pagination: transactionsPagination || undefined, - fetchFilterValues: _fetchTransactionFilterValues, - }), [_loadTransactions, transactionsPagination, _fetchTransactionFilterValues]); + const transactionsHookData = useMemo( + () => ({ + refetch: _loadTransactions, + pagination: transactionsPagination || undefined, + groupLayout: transactionsGroupLayout ?? undefined, + appliedView: transactionsAppliedView ?? undefined, + fetchFilterValues: _fetchTransactionFilterValues, + fetchGroupSectionSummaries, + refetchForSection, + csvExportQueryParams: _scopeParams, + }), + [ + _loadTransactions, + transactionsPagination, + transactionsGroupLayout, + transactionsAppliedView, + _fetchTransactionFilterValues, + fetchGroupSectionSummaries, + refetchForSection, + _scopeParams, + ], + ); const _rawTransactionColumns: ColumnConfig[] = useMemo(() => [ { key: 'sysCreatedAt', label: t('Datum'), sortable: true, width: 160 }, @@ -635,6 +736,7 @@ export const BillingDataView: React.FC = () => { )} +
{/* ================================================================ */} {/* Tab: Übersicht (KPI overview) */} {/* ================================================================ */} @@ -722,7 +824,7 @@ export const BillingDataView: React.FC = () => { {/* Tab: Transaktionen */} {/* ================================================================ */} {activeTab === 'transactions' && ( -
+
{transactionsError && (
{transactionsError} @@ -734,6 +836,8 @@ export const BillingDataView: React.FC = () => { data={transactions} columns={columns} apiEndpoint="/api/billing/view/users/transactions" + tableContextKey="billing/view/users/transactions" + tableGroupLayoutMode="sections" loading={transactionsLoading} pagination={true} pageSize={25} @@ -742,12 +846,13 @@ export const BillingDataView: React.FC = () => { sortable={true} selectable={false} emptyMessage={t('Keine Transaktionen vorhanden')} - onRefresh={_loadTransactions} hookData={transactionsHookData} />
)} +
+
); }; diff --git a/src/pages/billing/BillingTransactions.tsx b/src/pages/billing/BillingTransactions.tsx index b176ff8..3e862cf 100644 --- a/src/pages/billing/BillingTransactions.tsx +++ b/src/pages/billing/BillingTransactions.tsx @@ -1,149 +1,178 @@ /** * 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 { BillingNav } from './BillingNav'; import styles from './Billing.module.css'; - +import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import { useLanguage } from '../../providers/language/LanguageContext'; -// ============================================================================ -// TRANSACTION ROW COMPONENT -// ============================================================================ - -interface TransactionRowProps { - transaction: BillingTransaction; +function typePillClass(type: string): string { + switch (type) { + case 'CREDIT': + return styles.credit; + case 'DEBIT': + return styles.debit; + case 'ADJUSTMENT': + return styles.adjustment; + default: + return ''; + } } -const TransactionRow: React.FC = ({ transaction }) => { - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('de-CH', { - style: 'currency', - currency: 'CHF' - }).format(amount); - }; - - const formatDate = (dateString?: string) => { - if (!dateString) return '-'; - 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 ( - - {formatDate(transaction.sysCreatedAt)} - {transaction.mandateName || '-'} - - - {getTypeLabel(transaction.transactionType)} - - - {transaction.description} - {transaction.aicoreProvider || '-'} - {transaction.aicoreModel || '-'} - {transaction.featureCode || '-'} - - {transaction.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(transaction.amount)} - - - ); -}; - -// ============================================================================ -// MAIN COMPONENT -// ============================================================================ +function typeLabel(type: string, t: (k: string) => string): string { + switch (type) { + case 'CREDIT': + return t('Gutschrift'); + case 'DEBIT': + return t('Belastung'); + case 'ADJUSTMENT': + return t('Korrektur'); + default: + return type; + } +} export const BillingTransactions: React.FC = () => { const { t } = useLanguage(); - const { transactions, loading, loadTransactions } = useBilling(); - const [limit, setLimit] = useState(50); - - useEffect(() => { - loadTransactions(limit); - }, [limit, loadTransactions]); - - const handleLoadMore = () => { - setLimit(prev => prev + 50); - }; - + const { + transactions, + loading, + refetchTransactions, + transactionsPagination, + transactionsGroupLayout, + transactionsAppliedView, + } = useBilling(); + + const columns = useMemo((): ColumnConfig[] => { + const fmtChf = (amount: number) => + new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount); + + return [ + { + 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) => ( + + {typeLabel(row.transactionType, t)} + + ), + }, + { + 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 ( + + {prefix} + {abs} + + ); + }, + }, + ]; + }, [t]); + return (

{t('Transaktionen')}

{t('Übersicht aller Kontobewegungen')}

- + - +
- {loading && transactions.length === 0 ? ( -
{t('Transaktionen laden')}
- ) : transactions.length === 0 ? ( -
{t('Keine Transaktionen vorhanden')}
- ) : ( - <> -
- - - - - - - - - - - - - - - {transactions.map((transaction) => ( - - ))} - -
Datum{t('Mandant')}Typ{t('Beschreibung')}AnbieterModellFeature{t('Betrag')}
-
- - {transactions.length >= limit && ( -
- -
- )} - - )} + + data={transactions} + columns={columns} + apiEndpoint="/api/billing/transactions" + tableContextKey="billing/transactions" + loading={loading} + pagination={true} + pageSize={25} + searchable={true} + filterable={true} + sortable={true} + selectable={false} + hookData={{ + refetch: refetchTransactions, + pagination: transactionsPagination ?? undefined, + groupLayout: transactionsGroupLayout ?? undefined, + appliedView: transactionsAppliedView ?? undefined, + }} + emptyMessage={t('Keine Transaktionen vorhanden')} + />
); diff --git a/src/pages/billing/EnterpriseDialog.tsx b/src/pages/billing/EnterpriseDialog.tsx new file mode 100644 index 0000000..0ce6543 --- /dev/null +++ b/src/pages/billing/EnterpriseDialog.tsx @@ -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) => Promise; +} + +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 = ({ + 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(''); + const [maxUsers, setMaxUsers] = useState(''); + const [maxFeatureInstances, setMaxFeatureInstances] = useState(''); + const [maxDataVolumeMB, setMaxDataVolumeMB] = useState(''); + const [budgetAiCHF, setBudgetAiCHF] = useState(''); + const [note, setNote] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [errorMsg, setErrorMsg] = useState(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 = {}; + + 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 = { + create: t('Enterprise-Abo erstellen'), + renew: t('Enterprise-Abo erneuern'), + update: t('Enterprise-Abo anpassen'), + }; + + const _submitLabel: Record = { + create: t('Erstellen'), + renew: t('Erneuern'), + update: t('Speichern'), + }; + + const isCreate = mode === 'create'; + const isUpdate = mode === 'update'; + + return ( + +
+ {errorMsg &&
{errorMsg}
} + + {data.mandateName && !isCreate && ( +
+ {t('Mandant:')} {data.mandateName} +
+ )} + + {isCreate && ( +
+ + +
+ )} + + {!isUpdate && ( +
+ {isCreate && ( +
+ + setStartDate(e.target.value)} /> +
+ )} +
+ + setEndDate(e.target.value)} /> +
+
+ )} + +
+
+ + setFlatPrice(e.target.value)} + placeholder={isUpdate ? t('Unverändert') : ''} /> +
+
+ +
+ setAutoRenew(e.target.checked)} + style={{ width: 18, height: 18, accentColor: 'var(--primary-color, #F25843)' }} /> +
+
+
+ +
+
+ + setMaxUsers(e.target.value)} + placeholder={isUpdate ? t('Unverändert') : t('Unbegrenzt')} /> +
+
+ + setMaxFeatureInstances(e.target.value)} + placeholder={isUpdate ? t('Unverändert') : t('Unbegrenzt')} /> +
+
+ +
+
+ + setMaxDataVolumeMB(e.target.value)} + placeholder={isUpdate ? t('Unverändert') : t('Unbegrenzt')} /> +
+
+ + setBudgetAiCHF(e.target.value)} + placeholder={isUpdate ? t('Unverändert') : t('Kein Budget')} /> +
+
+ +
+ +