From dc4b475728c7dbd7cabf085a08fb20c24241511d Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Fri, 23 Jan 2026 21:05:36 +0100 Subject: [PATCH] refactored pages ui access with saas mandates --- src/App.tsx | 40 ++ src/api/automationApi.ts | 238 +++++++++ .../Navigation/MandateNavigation.tsx | 85 ++- .../RbacExportImport/RbacExportImport.tsx | 3 +- src/core/PageManager/SidebarProvider.tsx | 16 +- .../PageManager/data/pages/automations.ts | 282 ++++++++++ .../PageManager/data/pages/connections.ts | 6 +- src/core/PageManager/data/pages/dashboard.ts | 16 +- src/core/PageManager/data/pages/files.ts | 10 +- src/core/PageManager/data/pages/index.ts | 16 +- src/core/PageManager/data/pages/prompts.ts | 8 +- src/core/PageManager/data/pages/workflows.ts | 10 +- src/core/PageManager/index.ts | 26 +- src/hooks/useAutomations.ts | 399 ++++++++++++++ src/hooks/useConnections.ts | 46 +- src/hooks/useFiles.ts | 14 + src/hooks/useResizablePanels.ts | 185 +++++++ src/hooks/useTrusteeOptions.ts | 287 ++++++++++ src/hooks/useWorkflows.ts | 21 + src/locales/de.ts | 23 +- src/locales/en.ts | 23 +- src/locales/fr.ts | 23 +- src/pages/InvitePage.tsx | 12 +- src/pages/admin/AdminInvitationsPage.tsx | 16 +- src/pages/basedata/BasedataPages.module.css | 352 +++++++++++++ src/pages/basedata/ConnectionsPage.tsx | 348 +++++++++++++ src/pages/basedata/FilesPage.tsx | 328 ++++++++++++ src/pages/basedata/PromptsPage.tsx | 295 +++++++++++ src/pages/basedata/index.ts | 3 + src/pages/migrate/ChatbotPage.tsx | 130 +++++ src/pages/migrate/MigratePages.module.css | 223 ++++++++ src/pages/migrate/PekPage.tsx | 39 ++ src/pages/migrate/SpeechPage.tsx | 39 ++ src/pages/migrate/index.ts | 3 + src/pages/views/trustee/TrusteeAccessView.tsx | 174 ++++++- .../views/trustee/TrusteeContractsView.tsx | 128 ++++- .../views/trustee/TrusteeDocumentsView.tsx | 200 ++++++- .../trustee/TrusteeOrganisationsView.tsx | 141 ++++- .../trustee/TrusteePositionDocumentsView.tsx | 206 ++++++++ .../views/trustee/TrusteePositionsView.tsx | 246 ++++++++- src/pages/views/trustee/TrusteeRolesView.tsx | 120 ++++- .../views/trustee/TrusteeViews.module.css | 174 +++++++ .../trustee/components/TrusteeEditForm.tsx | 329 ++++++++++++ src/pages/views/trustee/components/index.ts | 6 + src/pages/views/trustee/index.ts | 1 + src/pages/workflows/AutomationsPage.tsx | 334 ++++++++++++ src/pages/workflows/PlaygroundPage.module.css | 493 ++++++++++++++++++ src/pages/workflows/PlaygroundPage.tsx | 445 ++++++++++++++++ src/pages/workflows/WorkflowPages.module.css | 298 +++++++++++ src/pages/workflows/WorkflowsPage.tsx | 255 +++++++++ src/pages/workflows/index.ts | 3 + 51 files changed, 7000 insertions(+), 118 deletions(-) create mode 100644 src/api/automationApi.ts create mode 100644 src/core/PageManager/data/pages/automations.ts create mode 100644 src/hooks/useAutomations.ts create mode 100644 src/hooks/useResizablePanels.ts create mode 100644 src/hooks/useTrusteeOptions.ts create mode 100644 src/pages/basedata/BasedataPages.module.css create mode 100644 src/pages/basedata/ConnectionsPage.tsx create mode 100644 src/pages/basedata/FilesPage.tsx create mode 100644 src/pages/basedata/PromptsPage.tsx create mode 100644 src/pages/basedata/index.ts create mode 100644 src/pages/migrate/ChatbotPage.tsx create mode 100644 src/pages/migrate/MigratePages.module.css create mode 100644 src/pages/migrate/PekPage.tsx create mode 100644 src/pages/migrate/SpeechPage.tsx create mode 100644 src/pages/migrate/index.ts create mode 100644 src/pages/views/trustee/TrusteePositionDocumentsView.tsx create mode 100644 src/pages/views/trustee/components/TrusteeEditForm.tsx create mode 100644 src/pages/views/trustee/components/index.ts create mode 100644 src/pages/workflows/AutomationsPage.tsx create mode 100644 src/pages/workflows/PlaygroundPage.module.css create mode 100644 src/pages/workflows/PlaygroundPage.tsx create mode 100644 src/pages/workflows/WorkflowPages.module.css create mode 100644 src/pages/workflows/WorkflowsPage.tsx create mode 100644 src/pages/workflows/index.ts diff --git a/src/App.tsx b/src/App.tsx index a88720e..0aeee8f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,8 @@ import { AuthProvider } from './providers/auth/AuthProvider'; import { ProtectedRoute } from './providers/auth/ProtectedRoute'; import { LanguageProvider } from './providers/language/LanguageContext'; import { ToastProvider } from './contexts/ToastContext'; +import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext'; +import { FileProvider } from './contexts/FileContext'; // Layouts import { MainLayout } from './layouts/MainLayout'; @@ -39,6 +41,15 @@ import { SettingsPage } from './pages/Settings'; import { FeatureViewPage } from './pages/FeatureView'; import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage } from './pages/admin'; +// Workflow Pages (global) +import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows'; + +// Basedata Pages (global) +import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; + +// Migrate Pages (temporary - to be migrated to feature instances) +import { ChatbotPage, PekPage, SpeechPage } from './pages/migrate'; + function App() { // Load saved theme preference and set app name on app mount useEffect(() => { @@ -66,6 +77,8 @@ function App() { + + {/* ================================================== */} @@ -91,6 +104,31 @@ function App() { {/* System-Seiten (ohne Instanz-Kontext) */} } /> + {/* ============================================== */} + {/* WORKFLOWS ROUTES (global) */} + {/* ============================================== */} + + } /> + } /> + } /> + + + {/* ============================================== */} + {/* BASISDATEN ROUTES (global) */} + {/* ============================================== */} + + } /> + } /> + } /> + + + {/* ============================================== */} + {/* MIGRATE TO FEATURES (temporary) */} + {/* ============================================== */} + } /> + } /> + } /> + {/* ============================================== */} {/* FEATURE-INSTANZ ROUTES */} {/* /mandates/:mandateId/:featureCode/:instanceId */} @@ -141,6 +179,8 @@ function App() { } /> + + diff --git a/src/api/automationApi.ts b/src/api/automationApi.ts new file mode 100644 index 0000000..b192cd7 --- /dev/null +++ b/src/api/automationApi.ts @@ -0,0 +1,238 @@ +import { ApiRequestOptions } from '../hooks/useApi'; + +// ============================================================================ +// TYPES & INTERFACES +// ============================================================================ + +export interface Automation { + id: string; + mandateId: string; + label: string; + template: string | object; + placeholders: Record; + schedule: string; + active: boolean; + status?: string; + lastExecution?: number; + nextExecution?: number; + executionLogs?: AutomationLog[]; + _createdAt?: number; + _updatedAt?: number; + _createdByUserName?: string; + mandateName?: string; + [key: string]: any; +} + +export interface AutomationLog { + id: string; + timestamp: number; + status: string; + workflowId?: string; + messages?: string[]; +} + +export interface AutomationTemplate { + template: { + overview?: string; + tasks?: Array<{ + description?: string; + objective?: string; + [key: string]: any; + }>; + [key: string]: any; + }; + parameters?: Record; +} + +export interface CreateAutomationRequest { + label: string; + template: string; + placeholders?: Record; + schedule?: string; + active?: boolean; + mandateId?: string; +} + +export interface UpdateAutomationRequest { + label?: string; + template?: string; + placeholders?: Record; + schedule?: string; + active?: boolean; +} + +export interface ExecuteAutomationResponse { + id: string; + status: string; + workflowId?: string; + [key: string]: any; +} + +// Type for the request function passed to API functions +export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; + +// ============================================================================ +// API REQUEST FUNCTIONS +// ============================================================================ + +/** + * Fetch all automations for the current mandate + * Endpoint: GET /api/automations + */ +export async function fetchAutomations(request: ApiRequestFunction): Promise { + console.log('📤 fetchAutomations: Making API request to /api/automations'); + + try { + const data = await request({ + url: '/api/automations', + method: 'get' + }); + + console.log('📥 fetchAutomations: API response:', data); + + // Handle different response formats + let automations: Automation[] = []; + + if (Array.isArray(data)) { + automations = data; + } else if (data && typeof data === 'object') { + if (Array.isArray(data.automations)) { + automations = data.automations; + } else if (Array.isArray(data.items)) { + automations = data.items; + } else if (Array.isArray(data.data)) { + automations = data.data; + } + } + + console.log(`✅ fetchAutomations: Returning ${automations.length} automations`); + return automations; + } catch (error) { + console.error('❌ fetchAutomations: Error fetching automations:', error); + throw error; + } +} + +/** + * Fetch a single automation by ID + * Endpoint: GET /api/automations/{automationId} + */ +export async function fetchAutomation( + request: ApiRequestFunction, + automationId: string +): Promise { + return await request({ + url: `/api/automations/${automationId}`, + method: 'get' + }); +} + +/** + * Create a new automation + * Endpoint: POST /api/automations + */ +export async function createAutomationApi( + request: ApiRequestFunction, + automationData: CreateAutomationRequest +): Promise { + return await request({ + url: '/api/automations', + method: 'post', + data: automationData + }); +} + +/** + * Update an existing automation + * Endpoint: PUT /api/automations/{automationId} + */ +export async function updateAutomationApi( + request: ApiRequestFunction, + automationId: string, + updateData: UpdateAutomationRequest +): Promise { + return await request({ + url: `/api/automations/${automationId}`, + method: 'put', + data: updateData + }); +} + +/** + * Delete an automation + * Endpoint: DELETE /api/automations/{automationId} + */ +export async function deleteAutomationApi( + request: ApiRequestFunction, + automationId: string +): Promise { + await request({ + url: `/api/automations/${automationId}`, + method: 'delete' + }); +} + +/** + * Execute an automation (test mode) + * Endpoint: POST /api/automations/{automationId}/execute + */ +export async function executeAutomationApi( + request: ApiRequestFunction, + automationId: string +): Promise { + return await request({ + url: `/api/automations/${automationId}/execute`, + method: 'post' + }); +} + +/** + * Fetch automation templates + * Endpoint: GET /api/automations/templates + */ +export async function fetchAutomationTemplates( + request: ApiRequestFunction +): Promise { + const data = await request({ + url: '/api/automations/templates', + method: 'get' + }); + + if (Array.isArray(data)) { + return data; + } + + if (data && typeof data === 'object') { + if (Array.isArray(data.sets)) { + return data.sets; + } + if (Array.isArray(data.templates)) { + return data.templates; + } + } + + return []; +} + +/** + * Fetch automation attributes for dynamic form generation + * Endpoint: GET /api/attributes/AutomationDefinition + */ +export async function fetchAutomationAttributes( + request: ApiRequestFunction +): Promise { + const data = await request({ + url: '/api/attributes/AutomationDefinition', + method: 'get' + }); + + if (data?.attributes && Array.isArray(data.attributes)) { + return data.attributes; + } + + if (Array.isArray(data)) { + return data; + } + + return []; +} diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index 179c927..805691c 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -22,7 +22,12 @@ import { useMandates, useFeatureStore } from '../../stores/featureStore'; import { useCurrentUser } from '../../hooks/useUsers'; import { FEATURE_REGISTRY, getLabel } from '../../types/mandate'; import type { Mandate, MandateFeature, FeatureInstance } from '../../types/mandate'; -import { FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserTag, FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube } from 'react-icons/fa'; +import { + FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserTag, + FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, + FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaChartBar, FaMicrophone, + FaListAlt, FaCogs +} from 'react-icons/fa'; import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation'; import styles from './MandateNavigation.module.css'; @@ -174,6 +179,84 @@ export const MandateNavigation: React.FC = () => { ], }); + // Workflows section (global pages) + items.push({ + type: 'section', + title: 'WORKFLOWS', + children: [ + { + id: 'workflows-playground', + label: 'Chat Playground', + icon: , + path: '/workflows/playground', + }, + { + id: 'workflows-list', + label: 'Workflows', + icon: , + path: '/workflows/list', + }, + { + id: 'workflows-automations', + label: 'Automations', + icon: , + path: '/workflows/automations', + }, + ], + }); + + // Basisdaten section (global pages) + items.push({ + type: 'section', + title: 'BASISDATEN', + children: [ + { + id: 'basedata-prompts', + label: 'Prompts', + icon: , + path: '/basedata/prompts', + }, + { + id: 'basedata-files', + label: 'Files', + icon: , + path: '/basedata/files', + }, + { + id: 'basedata-connections', + label: 'Connections', + icon: , + path: '/basedata/connections', + }, + ], + }); + + // Migrate to Feature Instances section (temporary) + items.push({ + type: 'section', + title: 'MIGRATE TO FEATURES', + children: [ + { + id: 'migrate-chatbot', + label: 'Chatbot', + icon: , + path: '/chatbot', + }, + { + id: 'migrate-pek', + label: 'PEK', + icon: , + path: '/pek', + }, + { + id: 'migrate-speech', + label: 'Speech', + icon: , + path: '/speech', + }, + ], + }); + // Separator items.push({ type: 'separator' }); diff --git a/src/components/RbacExportImport/RbacExportImport.tsx b/src/components/RbacExportImport/RbacExportImport.tsx index 42095e3..0935894 100644 --- a/src/components/RbacExportImport/RbacExportImport.tsx +++ b/src/components/RbacExportImport/RbacExportImport.tsx @@ -5,7 +5,7 @@ * Supports mandate-level and global exports with different import modes. */ -import React, { useState, useRef, useCallback, useEffect } from 'react'; +import React, { useState, useRef } from 'react'; import { FaDownload, FaUpload, @@ -182,7 +182,6 @@ export const RbacExportImport: React.FC = ({ exporting, importing, error, - lastExport, lastImportResult, exportMandateRbac, exportGlobalRbac, diff --git a/src/core/PageManager/SidebarProvider.tsx b/src/core/PageManager/SidebarProvider.tsx index 31aaf52..4a620e7 100644 --- a/src/core/PageManager/SidebarProvider.tsx +++ b/src/core/PageManager/SidebarProvider.tsx @@ -3,7 +3,7 @@ import { allPageData, SidebarItem } from './data'; import { useLanguage } from '../../providers/language/LanguageContext'; import { resolveLanguageText } from './pageInterface'; import { usePermissions } from '../../hooks/usePermissions'; -import { FaHome, FaHatWizard, FaBriefcase } from 'react-icons/fa'; +import { FaHome, FaHatWizard, FaBriefcase, FaProjectDiagram } from 'react-icons/fa'; import { RiFolderSettingsFill } from 'react-icons/ri'; // Configuration for parent groups that don't have a page definition @@ -16,17 +16,21 @@ const parentGroupConfig: Record { + return attributes + .filter(attr => { + // Exclude template and complex fields from table display + const attrNameLower = attr.name.toLowerCase(); + const excludedColumns = ['template', 'executionlogs', 'execution_logs']; + return !excludedColumns.includes(attrNameLower); + }) + .map(attr => { + const attrNameLower = attr.name.toLowerCase(); + const isDateField = attr.type === 'date' || + /(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name); + + const column: any = { + key: attr.name, + label: attr.label || attr.name, + type: attr.type || 'string', + width: attr.width || 200, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + sortable: attr.sortable !== false, + filterable: isDateField ? false : (attr.filterable !== false), + searchable: attr.searchable !== false, + filterOptions: attr.filterOptions + }; + + // Format schedule field + if (attrNameLower === 'schedule') { + column.formatter = (value: any) => { + if (!value) return '-'; + const scheduleLabels: Record = { + '0 */4 * * *': 'Every 4 hours', + '0 22 * * *': 'Daily at 22:00', + '0 10 * * 1': 'Weekly Monday 10:00' + }; + return scheduleLabels[value] || value; + }; + } + + // Format active field as badge + if (attrNameLower === 'active') { + column.type = 'boolean'; + } + + // Format placeholders as count + if (attrNameLower === 'placeholders') { + column.formatter = (value: any) => { + if (!value) return '-'; + let obj; + if (typeof value === 'string') { + try { + obj = JSON.parse(value); + } catch { + return '-'; + } + } else if (typeof value === 'object') { + obj = value; + } else { + return '-'; + } + const count = Object.keys(obj).length; + return `${count} placeholder${count !== 1 ? 's' : ''}`; + }; + } + + return column; + }); +}; + +// Hook factory function for automations data +const createAutomationsHook = () => { + return () => { + const { + automations, + loading, + error, + refetch, + removeOptimistically, + updateOptimistically, + attributes, + permissions, + pagination, + fetchAutomationById, + generateEditFieldsFromAttributes, + generateCreateFieldsFromAttributes, + ensureAttributesLoaded + } = useAutomations(); + const { + handleAutomationDelete, + handleAutomationCreate, + handleAutomationUpdate, + handleAutomationExecute, + handleAutomationToggleActive, + deletingAutomations, + creatingAutomation, + executingAutomations, + deleteError, + createError, + updateError + } = useAutomationOperations(); + + const generatedColumns = attributes && attributes.length > 0 + ? attributesToColumns(attributes) + : undefined; + + // Handle single automation deletion + const handleDeleteSingle = useCallback(async (automation: any) => { + const success = await handleAutomationDelete(automation.id); + if (success) { + refetch(); + } + }, [handleAutomationDelete, refetch]); + + // Handle multiple automation deletion + const handleDeleteMultiple = useCallback(async (selectedAutomations: any[]) => { + const results = await Promise.all( + selectedAutomations.map(a => handleAutomationDelete(a.id)) + ); + const allSuccessful = results.every(result => result); + if (allSuccessful) { + refetch(); + } + }, [handleAutomationDelete, refetch]); + + // Wrapped create handler + const wrappedHandleAutomationCreate = useCallback(async (formData: any) => { + return await handleAutomationCreate(formData); + }, [handleAutomationCreate]); + + return { + data: automations, + loading, + error, + refetch, + removeOptimistically, + updateOptimistically, + // Operations + handleDelete: handleAutomationDelete, + handleDeleteMultiple, + handleAutomationCreate: wrappedHandleAutomationCreate, + handleAutomationUpdate, + handleAutomationExecute, + handleAutomationToggleActive, + // FormGenerator specific handlers + onDelete: handleDeleteSingle, + onDeleteMultiple: handleDeleteMultiple, + // Loading states + deletingAutomations, + creatingAutomation, + executingAutomations, + // Error states + deleteError, + createError, + updateError, + // Attributes and permissions + attributes, + permissions, + pagination, + columns: generatedColumns, + // Functions for EditActionButton + fetchAutomationById, + generateEditFieldsFromAttributes, + generateCreateFieldsFromAttributes, + ensureAttributesLoaded + }; + }; +}; + +export const automationsPageData: GenericPageData = { + id: 'workflows-automations', + path: 'workflows/automations', + name: 'automations.title', + description: 'automations.description', + + // Parent page - under 'workflows' group + parentPath: 'workflows', + + // Visual + icon: FaCog, + title: 'automations.title', + subtitle: 'automations.subtitle', + + // Header buttons + headerButtons: [ + { + id: 'new-automation', + label: 'automations.new_button', + icon: FaPlus, + variant: 'primary', + formConfig: { + fields: [], // Fields will be generated dynamically from attributes + popupTitle: 'automations.modal.create.title', + popupSize: 'large', + createOperationName: 'handleAutomationCreate', + successMessage: 'automations.create.success', + errorMessage: 'automations.create.error' + } + } + ], + + // Content sections - using generic table approach + content: [ + { + id: 'automations-table', + type: 'table', + tableConfig: { + hookFactory: createAutomationsHook, + // Columns are generated dynamically from attributes via hookData.columns + actionButtons: [ + { + type: 'play', + title: 'automations.action.execute', + idField: 'id', + operationName: 'handleAutomationExecute', + loadingStateName: 'executingAutomations', + disabled: (hookData: any) => { + if (!hookData?.permissions) return { disabled: false }; + const hasExecute = hookData.permissions.update !== 'n' && hookData.permissions.view; + return { disabled: !hasExecute, message: 'No permission to execute automations' }; + } + }, + { + type: 'edit', + title: 'automations.action.edit', + idField: 'id', + nameField: 'label', + operationName: 'handleAutomationUpdate', + loadingStateName: 'updatingAutomations', + fetchItemFunctionName: 'fetchAutomationById', + disabled: (hookData: any) => { + if (!hookData?.permissions) return { disabled: false }; + const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view; + return { disabled: !hasUpdate, message: 'No permission to edit automations' }; + } + }, + { + type: 'delete', + title: 'automations.action.delete', + idField: 'id', + operationName: 'handleDelete', + loadingStateName: 'deletingAutomations', + disabled: (hookData: any) => { + if (!hookData?.permissions) return { disabled: false }; + const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view; + return { disabled: !hasDelete, message: 'No permission to delete automations' }; + } + } + ], + searchable: true, + filterable: true, + sortable: true, + resizable: true, + pagination: true, + pageSize: 10, + className: 'automations-table' + } + } + ], + + // Page behavior + persistent: false, + preload: false, + preserveState: true, + moduleEnabled: true, + + // Lifecycle hooks + onActivate: async () => { + if (import.meta.env.DEV) console.log('Automations activated'); + }, + onLoad: async () => { + if (import.meta.env.DEV) console.log('Automations loaded'); + }, + onUnload: async () => { + if (import.meta.env.DEV) console.log('Automations unloaded'); + } +}; diff --git a/src/core/PageManager/data/pages/connections.ts b/src/core/PageManager/data/pages/connections.ts index 3af8f48..e335902 100644 --- a/src/core/PageManager/data/pages/connections.ts +++ b/src/core/PageManager/data/pages/connections.ts @@ -111,13 +111,13 @@ const createConnectionsHook = () => { }; export const connectionsPageData: GenericPageData = { - id: 'administration-connections', - path: 'administration/connections', + id: 'basedata-connections', + path: 'basedata/connections', name: 'connections.title', description: 'connections.title', // Parent page - parentPath: 'administration', + parentPath: 'basedata', // Visual icon: FaLink, diff --git a/src/core/PageManager/data/pages/dashboard.ts b/src/core/PageManager/data/pages/dashboard.ts index 1cd1df6..c38e8d7 100644 --- a/src/core/PageManager/data/pages/dashboard.ts +++ b/src/core/PageManager/data/pages/dashboard.ts @@ -6,18 +6,18 @@ import { HiOutlineCollection } from 'react-icons/hi'; import { createDashboardHook } from '../../../../hooks/usePlayground'; export const dashboardPageData: GenericPageData = { - id: 'start-dashboard', - path: 'start/dashboard', - name: 'Dashboard', - description: 'Main dashboard with overview and quick actions', + id: 'workflows-playground', + path: 'workflows/playground', + name: 'chatPlayground.title', + description: 'chatPlayground.description', - // Parent page - parentPath: 'start', + // Parent page - now under 'workflows' group + parentPath: 'workflows', // Visual icon: LuTicket, - title: 'Dashboard', - subtitle: 'Welcome to your workspace', + title: 'chatPlayground.title', + subtitle: 'chatPlayground.subtitle', // Header buttons headerButtons: [ diff --git a/src/core/PageManager/data/pages/files.ts b/src/core/PageManager/data/pages/files.ts index b61b1fb..d4a5698 100644 --- a/src/core/PageManager/data/pages/files.ts +++ b/src/core/PageManager/data/pages/files.ts @@ -161,13 +161,13 @@ const createFilesHook = () => { }; export const filesPageData: GenericPageData = { - id: 'administration-files', - path: 'administration/files', + id: 'basedata-files', + path: 'basedata/files', name: 'files.title', - description: 'files.title', + description: 'files.description', - // Parent page - parentPath: 'administration', + // Parent page - now under 'basedata' group (formerly 'administration') + parentPath: 'basedata', // Visual icon: FaRegFileAlt, diff --git a/src/core/PageManager/data/pages/index.ts b/src/core/PageManager/data/pages/index.ts index e4c4043..23d2509 100644 --- a/src/core/PageManager/data/pages/index.ts +++ b/src/core/PageManager/data/pages/index.ts @@ -2,6 +2,7 @@ export { dashboardPageData } from './dashboard'; export { filesPageData } from './files'; export { workflowsPageData } from './workflows'; +export { automationsPageData } from './automations'; export { connectionsPageData } from './connections'; export { teamMembersPageData } from './admin/team-members'; export { promptsPageData } from './prompts'; @@ -28,6 +29,7 @@ export { import { dashboardPageData } from './dashboard'; import { filesPageData } from './files'; import { workflowsPageData } from './workflows'; +import { automationsPageData } from './automations'; import { connectionsPageData } from './connections'; import { teamMembersPageData } from './admin/team-members'; import { promptsPageData } from './prompts'; @@ -43,19 +45,23 @@ import { trusteePages } from './trustee'; // Array of all page data export const allPageData = [ - dashboardPageData, + // Workflows group + dashboardPageData, // Chat Playground + workflowsPageData, // Workflows list + automationsPageData, // Automations + // Basedata group filesPageData, - workflowsPageData, - connectionsPageData, promptsPageData, + // Other pages + connectionsPageData, speechPageData, settingsPageData, pekPageData, pekTablesPageData, chatbotPageData, - // Trustee pages (before Administration) + // Trustee pages ...trusteePages, - // Administration pages + // Admin pages teamMembersPageData, mandatesPageData, rbacRulesPageData, diff --git a/src/core/PageManager/data/pages/prompts.ts b/src/core/PageManager/data/pages/prompts.ts index ffe1b0b..87e37c9 100644 --- a/src/core/PageManager/data/pages/prompts.ts +++ b/src/core/PageManager/data/pages/prompts.ts @@ -128,13 +128,13 @@ const createPromptsHook = () => { export const promptsPageData: GenericPageData = { - id: 'administration-prompts', - path: 'administration/prompts', + id: 'basedata-prompts', + path: 'basedata/prompts', name: 'prompts.title', description: 'prompts.description', - // Parent page - parentPath: 'administration', + // Parent page - now under 'basedata' group (formerly 'administration') + parentPath: 'basedata', // Visual icon: FaLightbulb, diff --git a/src/core/PageManager/data/pages/workflows.ts b/src/core/PageManager/data/pages/workflows.ts index e605ef7..9065db0 100644 --- a/src/core/PageManager/data/pages/workflows.ts +++ b/src/core/PageManager/data/pages/workflows.ts @@ -145,13 +145,13 @@ const createWorkflowsHook = () => { }; export const workflowsPageData: GenericPageData = { - id: 'administration-workflows', - path: 'administration/workflows', + id: 'workflows-list', + path: 'workflows/list', name: 'workflows.title', - description: 'workflows.title', + description: 'workflows.description', - // Parent page - parentPath: 'administration', + // Parent page - now under 'workflows' group + parentPath: 'workflows', // Visual icon: FaProjectDiagram, diff --git a/src/core/PageManager/index.ts b/src/core/PageManager/index.ts index 070d48b..c0dbbc5 100644 --- a/src/core/PageManager/index.ts +++ b/src/core/PageManager/index.ts @@ -1,8 +1,30 @@ -// Export the page management system +/** + * @deprecated This PageManager system is deprecated. + * + * New pages should be created in src/pages/ and use: + * - src/components/Navigation/MandateNavigation.tsx for navigation + * - src/App.tsx for routing + * + * Migration targets (new location): + * - workflows → /workflows/list + * - automations → /workflows/automations + * - playground → /workflows/playground + * - prompts → /basedata/prompts + * - files → /basedata/files + * - connections → /basedata/connections + * - chatbot → /chatbot (migrate to feature) + * - pek → /pek (migrate to feature) + * - speech → /speech (migrate to feature) + * + * This module is kept for backward compatibility with Sidebar.tsx + * and will be fully removed in a future release. + */ + +// Export the page management system (DEPRECATED) export { default as PageManager } from './PageManager'; export { default as PageRenderer } from './PageRenderer'; export { default as SidebarProvider } from './SidebarProvider'; -// Export data and interfaces +// Export data and interfaces (DEPRECATED) export * from './data'; export * from './pageInterface'; \ No newline at end of file diff --git a/src/hooks/useAutomations.ts b/src/hooks/useAutomations.ts new file mode 100644 index 0000000..6f20195 --- /dev/null +++ b/src/hooks/useAutomations.ts @@ -0,0 +1,399 @@ +import { useState, useCallback } from 'react'; +import { useApiRequest } from './useApi'; +import api from '../api'; +import { usePermissions, type UserPermissions } from './usePermissions'; +import { + fetchAutomations as fetchAutomationsApi, + fetchAutomation as fetchAutomationApi, + createAutomationApi, + updateAutomationApi, + deleteAutomationApi, + executeAutomationApi, + fetchAutomationTemplates as fetchTemplatesApi, + type Automation, + type AutomationTemplate, + type CreateAutomationRequest, + type UpdateAutomationRequest +} from '../api/automationApi'; + +// Re-export types +export type { Automation, AutomationTemplate, CreateAutomationRequest, UpdateAutomationRequest }; + +// Attribute definition interface +export interface AttributeDefinition { + name: string; + type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea'; + label: string; + description?: string; + required?: boolean; + default?: any; + options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string; + validation?: any; + readonly?: boolean; + editable?: boolean; + visible?: boolean; + order?: number; + sortable?: boolean; + filterable?: boolean; + searchable?: boolean; + width?: number; + minWidth?: number; + maxWidth?: number; + filterOptions?: string[]; +} + +// Automations list hook +export function useAutomations() { + const [automations, setAutomations] = useState([]); + const [attributes, setAttributes] = useState([]); + const [permissions, setPermissions] = useState(null); + const [pagination, setPagination] = useState<{ + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + } | null>(null); + const { request, isLoading: loading, error } = useApiRequest(); + const { checkPermission } = usePermissions(); + + // Fetch attributes from backend + const fetchAttributes = useCallback(async () => { + try { + const response = await api.get('/api/attributes/AutomationDefinition'); + + let attrs: AttributeDefinition[] = []; + if (response.data?.attributes && Array.isArray(response.data.attributes)) { + attrs = response.data.attributes; + } else if (Array.isArray(response.data)) { + attrs = response.data; + } else if (response.data && typeof response.data === 'object') { + const keys = Object.keys(response.data); + for (const key of keys) { + if (Array.isArray(response.data[key])) { + attrs = response.data[key]; + break; + } + } + } + + setAttributes(attrs); + return attrs; + } catch (error: any) { + console.error('Error fetching automation attributes:', error); + setAttributes([]); + return []; + } + }, []); + + // Fetch permissions from backend + const fetchPermissions = useCallback(async () => { + try { + const perms = await checkPermission('DATA', 'AutomationDefinition'); + setPermissions(perms); + return perms; + } catch (error: any) { + console.error('Error fetching automation permissions:', error); + const defaultPerms: UserPermissions = { + view: false, + read: 'n', + create: 'n', + update: 'n', + delete: 'n', + }; + setPermissions(defaultPerms); + return defaultPerms; + } + }, [checkPermission]); + + const fetchAutomations = useCallback(async () => { + try { + const data = await fetchAutomationsApi(request); + + // Handle paginated response + if (data && typeof data === 'object' && 'items' in data) { + const items = Array.isArray((data as any).items) ? (data as any).items : []; + setAutomations(items); + if ((data as any).pagination) { + setPagination((data as any).pagination); + } + } else { + const items = Array.isArray(data) ? data : []; + setAutomations(items); + setPagination(null); + } + } catch (error: any) { + setAutomations([]); + setPagination(null); + } + }, [request]); + + // Optimistically remove an automation from the local state + const removeOptimistically = (automationId: string) => { + setAutomations(prev => prev.filter(a => a.id !== automationId)); + }; + + // Optimistically update an automation in the local state + const updateOptimistically = (automationId: string, updateData: Partial) => { + setAutomations(prev => + prev.map(a => a.id === automationId ? { ...a, ...updateData } : a) + ); + }; + + // Fetch a single automation by ID + const fetchAutomationById = useCallback(async (automationId: string): Promise => { + try { + return await fetchAutomationApi(request, automationId); + } catch (error) { + console.error('Error fetching automation by ID:', error); + return null; + } + }, [request]); + + // Generate edit fields from attributes dynamically + const generateEditFieldsFromAttributes = useCallback((): Array<{ + key: string; + label: string; + type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly'; + editable?: boolean; + required?: boolean; + minRows?: number; + maxRows?: number; + options?: Array<{ value: string | number; label: string }>; + }> => { + if (!attributes || attributes.length === 0) { + return []; + } + + // Fields to show in edit form + const editableFields = ['label', 'schedule', 'template', 'placeholders', 'active']; + + return attributes + .filter(attr => editableFields.includes(attr.name) && attr.editable !== false) + .map(attr => { + let fieldType: 'string' | 'boolean' | 'textarea' | 'enum' | 'readonly' = 'string'; + + if (attr.type === 'checkbox') { + fieldType = 'boolean'; + } else if (attr.type === 'textarea' || attr.name === 'template' || attr.name === 'placeholders') { + fieldType = 'textarea'; + } else if (attr.type === 'select' && attr.options) { + fieldType = 'enum'; + } + + const field: any = { + key: attr.name, + label: attr.label || attr.name, + type: fieldType, + editable: attr.editable !== false, + required: attr.required || false, + }; + + if (fieldType === 'textarea') { + field.minRows = 3; + field.maxRows = 15; + } + + if (fieldType === 'enum' && attr.options) { + field.options = Array.isArray(attr.options) + ? attr.options.map(opt => ({ + value: typeof opt === 'object' ? opt.value : opt, + label: typeof opt === 'object' + ? (typeof opt.label === 'object' ? opt.label['en'] || opt.label['de'] : opt.label) + : opt + })) + : []; + } + + return field; + }); + }, [attributes]); + + // Generate create fields from attributes + const generateCreateFieldsFromAttributes = useCallback(() => { + return generateEditFieldsFromAttributes(); + }, [generateEditFieldsFromAttributes]); + + // Ensure attributes are loaded + const ensureAttributesLoaded = useCallback(async () => { + if (attributes.length === 0) { + await fetchAttributes(); + } + }, [attributes.length, fetchAttributes]); + + // Initial data fetch + const refetch = useCallback(async () => { + await Promise.all([ + fetchAutomations(), + fetchAttributes(), + fetchPermissions() + ]); + }, [fetchAutomations, fetchAttributes, fetchPermissions]); + + return { + automations, + data: automations, // Alias for FormGenerator compatibility + loading, + error, + refetch, + removeOptimistically, + updateOptimistically, + attributes, + permissions, + pagination, + fetchAutomationById, + generateEditFieldsFromAttributes, + generateCreateFieldsFromAttributes, + ensureAttributesLoaded + }; +} + +// Automation operations hook +export function useAutomationOperations() { + const { request } = useApiRequest(); + const [deletingAutomations, setDeletingAutomations] = useState>(new Set()); + const [creatingAutomation, setCreatingAutomation] = useState(false); + const [executingAutomations, setExecutingAutomations] = useState>(new Set()); + const [deleteError, setDeleteError] = useState(null); + const [createError, setCreateError] = useState(null); + const [updateError, setUpdateError] = useState(null); + + // Create a new automation + const handleAutomationCreate = useCallback(async (data: CreateAutomationRequest): Promise => { + setCreatingAutomation(true); + setCreateError(null); + + try { + // Get mandateId from session storage + const currentUserJson = sessionStorage.getItem('currentUser'); + if (currentUserJson) { + const currentUser = JSON.parse(currentUserJson); + if (currentUser.mandateId) { + data.mandateId = currentUser.mandateId; + } + } + + const newAutomation = await createAutomationApi(request, data); + return newAutomation; + } catch (error: any) { + console.error('Error creating automation:', error); + setCreateError(error.message || 'Failed to create automation'); + return null; + } finally { + setCreatingAutomation(false); + } + }, [request]); + + // Update an existing automation + const handleAutomationUpdate = useCallback(async ( + automationId: string, + data: UpdateAutomationRequest + ): Promise => { + setUpdateError(null); + + try { + await updateAutomationApi(request, automationId, data); + return true; + } catch (error: any) { + console.error('Error updating automation:', error); + setUpdateError(error.message || 'Failed to update automation'); + return false; + } + }, [request]); + + // Delete an automation + const handleAutomationDelete = useCallback(async (automationId: string): Promise => { + setDeletingAutomations(prev => new Set(prev).add(automationId)); + setDeleteError(null); + + try { + await deleteAutomationApi(request, automationId); + return true; + } catch (error: any) { + console.error('Error deleting automation:', error); + setDeleteError(error.message || 'Failed to delete automation'); + return false; + } finally { + setDeletingAutomations(prev => { + const newSet = new Set(prev); + newSet.delete(automationId); + return newSet; + }); + } + }, [request]); + + // Execute an automation + const handleAutomationExecute = useCallback(async (automationId: string): Promise => { + setExecutingAutomations(prev => new Set(prev).add(automationId)); + + try { + const result = await executeAutomationApi(request, automationId); + return result; + } catch (error: any) { + console.error('Error executing automation:', error); + throw error; + } finally { + setExecutingAutomations(prev => { + const newSet = new Set(prev); + newSet.delete(automationId); + return newSet; + }); + } + }, [request]); + + // Toggle automation active status + const handleAutomationToggleActive = useCallback(async ( + automationId: string, + currentActive: boolean + ): Promise => { + try { + await updateAutomationApi(request, automationId, { active: !currentActive }); + return true; + } catch (error: any) { + console.error('Error toggling automation active status:', error); + return false; + } + }, [request]); + + // Generic inline update handler for FormGeneratorTable + const handleInlineUpdate = useCallback(async ( + automationId: string, + changes: Partial, + existingRow?: any + ) => { + if (!existingRow) { + throw new Error('Existing row data required for inline update'); + } + + const result = await handleAutomationUpdate(automationId, changes); + if (!result) { + throw new Error(updateError || 'Failed to update'); + } + return { success: true }; + }, [handleAutomationUpdate, updateError]); + + // Fetch templates + const fetchTemplates = useCallback(async (): Promise => { + try { + return await fetchTemplatesApi(request); + } catch (error: any) { + console.error('Error fetching templates:', error); + return []; + } + }, [request]); + + return { + handleAutomationCreate, + handleAutomationUpdate, + handleAutomationDelete, + handleAutomationExecute, + handleAutomationToggleActive, + handleInlineUpdate, + fetchTemplates, + deletingAutomations, + creatingAutomation, + executingAutomations, + deleteError, + createError, + updateError + }; +} diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts index 7fc93c1..ec702a1 100644 --- a/src/hooks/useConnections.ts +++ b/src/hooks/useConnections.ts @@ -548,9 +548,48 @@ export function useConnections() { fetchConnections(); }, [fetchConnections]); + // Optimistically update a connection in local state + const updateOptimistically = useCallback((connectionId: string, updateData: Partial) => { + setConnections(prev => + prev.map(conn => conn.id === connectionId ? { ...conn, ...updateData } : conn) + ); + }, []); + + // Generic inline update handler for FormGeneratorTable + const handleInlineUpdate = useCallback(async (connectionId: string, changes: Partial, existingRow?: any) => { + if (!existingRow) { + throw new Error('Existing row data required for inline update'); + } + + try { + const result = await updateConnection(connectionId, changes); + return { success: true, data: result }; + } catch (error: any) { + throw new Error(error.message || 'Failed to update'); + } + }, [updateConnection]); + + // Fetch connection by ID + const fetchConnectionById = useCallback(async (connectionId: string): Promise => { + try { + // Since there's no individual connection endpoint, find from current list or fetch all + const existing = connections.find(c => c.id === connectionId); + if (existing) return existing; + + const data = await fetchConnectionsApi(request); + const items = Array.isArray(data) ? data : (data?.items || []); + return items.find((c: Connection) => c.id === connectionId) || null; + } catch (error) { + console.error('Error fetching connection by ID:', error); + return null; + } + }, [connections, request]); + return { connections, + data: connections, // Alias for FormGenerator compatibility fetchConnections, + refetch: fetchConnections, // Alias for FormGenerator compatibility createConnection, updateConnection, connectService, @@ -562,6 +601,7 @@ export function useConnections() { createGoogleConnectionAndAuth, createMicrosoftConnectionAndAuth, isLoading, + loading: isLoading, // Alias for FormGenerator compatibility isConnecting, error: error || connectError, // Attributes and permissions for dynamic column/button generation @@ -571,7 +611,11 @@ export function useConnections() { generateEditFieldsFromAttributes, ensureAttributesLoaded, fetchAttributes, - fetchPermissions + fetchPermissions, + // Additional methods for FormGenerator + updateOptimistically, + handleInlineUpdate, + fetchConnectionById }; } diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index 5813aa5..d961dbc 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -914,6 +914,19 @@ export function useFileOperations() { } }; + // Generic inline update handler for FormGeneratorTable + const handleInlineUpdate = async (fileId: string, changes: Partial<{ fileName: string }>, existingRow?: any) => { + if (!existingRow) { + throw new Error('Existing row data required for inline update'); + } + + const result = await handleFileUpdate(fileId, changes, existingRow); + if (!result.success) { + throw new Error(result.error || 'Failed to update'); + } + return result; + }; + return { downloadingFiles, deletingFiles, @@ -930,6 +943,7 @@ export function useFileOperations() { handleFileUpload, handleFileUpdate, handleFilePreview, + handleInlineUpdate, isLoading }; } \ No newline at end of file diff --git a/src/hooks/useResizablePanels.ts b/src/hooks/useResizablePanels.ts new file mode 100644 index 0000000..9c881d8 --- /dev/null +++ b/src/hooks/useResizablePanels.ts @@ -0,0 +1,185 @@ +/** + * useResizablePanels + * + * Hook for creating resizable panel layouts with drag-divider. + * Supports LocalStorage persistence and min/max constraints. + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; + +interface UseResizablePanelsOptions { + /** Key for LocalStorage persistence */ + storageKey: string; + /** Default width of left panel in percent (0-100) */ + defaultLeftWidth: number; + /** Minimum width of left panel in percent */ + minLeftWidth: number; + /** Maximum width of left panel in percent */ + maxLeftWidth: number; + /** Direction of resize - horizontal or vertical */ + direction?: 'horizontal' | 'vertical'; +} + +interface UseResizablePanelsReturn { + /** Current width/height of left/top panel in percent */ + leftWidth: number; + /** Whether user is currently dragging the divider */ + isDragging: boolean; + /** Handler for mouse down on divider */ + handleMouseDown: (e: React.MouseEvent) => void; + /** Programmatically set the left width */ + setLeftWidth: (width: number) => void; + /** Reset to default width */ + resetToDefault: () => void; + /** Container ref to attach to the parent container */ + containerRef: React.RefObject; +} + +export function useResizablePanels({ + storageKey, + defaultLeftWidth, + minLeftWidth, + maxLeftWidth, + direction = 'horizontal', +}: UseResizablePanelsOptions): UseResizablePanelsReturn { + // Initialize from LocalStorage or default + const [leftWidth, setLeftWidthState] = useState(() => { + try { + const stored = localStorage.getItem(storageKey); + if (stored) { + const parsed = parseFloat(stored); + if (!isNaN(parsed) && parsed >= minLeftWidth && parsed <= maxLeftWidth) { + return parsed; + } + } + } catch { + // Ignore localStorage errors + } + return defaultLeftWidth; + }); + + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + + // Store start position and width for drag calculation + const dragStartRef = useRef<{ + startPos: number; + startWidth: number; + containerSize: number; + } | null>(null); + + // Set width with clamping and persistence + const setLeftWidth = useCallback((width: number) => { + const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, width)); + setLeftWidthState(clampedWidth); + + // Persist to LocalStorage + try { + localStorage.setItem(storageKey, clampedWidth.toString()); + } catch { + // Ignore localStorage errors + } + }, [storageKey, minLeftWidth, maxLeftWidth]); + + // Reset to default + const resetToDefault = useCallback(() => { + setLeftWidth(defaultLeftWidth); + }, [defaultLeftWidth, setLeftWidth]); + + // Mouse down handler for starting drag + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const container = containerRef.current; + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const containerSize = direction === 'horizontal' + ? containerRect.width + : containerRect.height; + + const startPos = direction === 'horizontal' ? e.clientX : e.clientY; + + dragStartRef.current = { + startPos, + startWidth: leftWidth, + containerSize, + }; + + setIsDragging(true); + }, [leftWidth, direction]); + + // Handle mouse move during drag + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!dragStartRef.current || !containerRef.current) return; + + const { startPos, startWidth, containerSize } = dragStartRef.current; + const currentPos = direction === 'horizontal' ? e.clientX : e.clientY; + + // Calculate delta in pixels and convert to percent + const deltaPixels = currentPos - startPos; + const deltaPercent = (deltaPixels / containerSize) * 100; + + // Calculate new width + const newWidth = startWidth + deltaPercent; + + // Clamp between min and max + const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, newWidth)); + setLeftWidthState(clampedWidth); + }; + + const handleMouseUp = () => { + setIsDragging(false); + dragStartRef.current = null; + + // Persist final width to LocalStorage + try { + localStorage.setItem(storageKey, leftWidth.toString()); + } catch { + // Ignore localStorage errors + } + }; + + // Add event listeners to document for capturing mouse events outside container + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + // Add cursor style to body during drag + document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize'; + document.body.style.userSelect = 'none'; + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [isDragging, leftWidth, direction, storageKey, minLeftWidth, maxLeftWidth]); + + // Save to localStorage when leftWidth changes (debounced by drag end) + useEffect(() => { + // Only save when not dragging (save happens on mouse up) + if (!isDragging) { + try { + localStorage.setItem(storageKey, leftWidth.toString()); + } catch { + // Ignore localStorage errors + } + } + }, [leftWidth, isDragging, storageKey]); + + return { + leftWidth, + isDragging, + handleMouseDown, + setLeftWidth, + resetToDefault, + containerRef, + }; +} + +export default useResizablePanels; diff --git a/src/hooks/useTrusteeOptions.ts b/src/hooks/useTrusteeOptions.ts new file mode 100644 index 0000000..9569816 --- /dev/null +++ b/src/hooks/useTrusteeOptions.ts @@ -0,0 +1,287 @@ +/** + * useTrusteeOptions Hook + * + * Zentraler Hook für Trustee-Options (Dropdowns, Label-Auflösung). + * Lädt Options von den entsprechenden /options Endpoints und cached sie. + * Unterstützt dynamische Filterung (z.B. Contracts nach Organisation). + */ + +import { useState, useCallback, useEffect, useMemo } from 'react'; +import api from '../api'; +import { useInstanceId } from './useCurrentInstance'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface TrusteeOption { + value: string; + label: string; +} + +export interface TrusteeOptionsMap { + users: TrusteeOption[]; + organisations: TrusteeOption[]; + roles: TrusteeOption[]; + contracts: TrusteeOption[]; + documents: TrusteeOption[]; + positions: TrusteeOption[]; +} + +export type TrusteeOptionEntity = keyof TrusteeOptionsMap; + +interface LoadOptionsParams { + organisationId?: string; + contractId?: string; +} + +// ============================================================================ +// HOOK +// ============================================================================ + +/** + * Hook für Trustee-Options. + * + * @param autoLoad - Array von Entity-Namen, die automatisch beim Mount geladen werden sollen + * @returns Options-Map, Lade-Funktion, Label-Getter + * + * @example + * ```tsx + * // Auto-load users, organisations und roles + * const { options, getLabel, loading } = useTrusteeOptions(['users', 'organisations', 'roles']); + * + * // Label für eine userId auflösen + * const userName = getLabel('users', access.userId); + * + * // Contracts für spezifische Organisation nachladen + * await loadOptions(['contracts'], { organisationId: 'org-123' }); + * ``` + */ +export function useTrusteeOptions(autoLoad: TrusteeOptionEntity[] = []) { + const instanceId = useInstanceId(); + + const [options, setOptions] = useState>({ + users: [], + organisations: [], + roles: [], + contracts: [], + documents: [], + positions: [], + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [loadedEntities, setLoadedEntities] = useState>(new Set()); + + /** + * Lädt Options für angegebene Entities. + * + * @param entities - Array von Entity-Namen + * @param filters - Optionale Filter (z.B. organisationId für Contracts) + */ + const loadOptions = useCallback(async ( + entities: TrusteeOptionEntity[], + filters?: LoadOptionsParams + ): Promise => { + if (!instanceId && entities.some(e => e !== 'users')) { + console.warn('useTrusteeOptions: No instanceId available, skipping load for trustee entities'); + return; + } + + setLoading(true); + setError(null); + + try { + const promises = entities.map(async (entity) => { + let url: string; + + if (entity === 'users') { + // Users kommen aus dem globalen API-Endpoint + url = '/api/users/options'; + } else { + // Trustee-Entities kommen aus dem Feature-API mit instanceId + url = `/api/trustee/${instanceId}/${entity}/options`; + + // Dynamische Filterung für Contracts nach Organisation + if (filters?.organisationId && entity === 'contracts') { + url += `?organisationId=${encodeURIComponent(filters.organisationId)}`; + } + + // Dynamische Filterung für Documents/Positions nach Contract + if (filters?.contractId && (entity === 'documents' || entity === 'positions')) { + url += `?contractId=${encodeURIComponent(filters.contractId)}`; + } + } + + const response = await api.get(url); + return { entity, data: response.data as TrusteeOption[] }; + }); + + const results = await Promise.all(promises); + + const newOptions: Partial = {}; + results.forEach(({ entity, data }) => { + newOptions[entity] = Array.isArray(data) ? data : []; + }); + + setOptions(prev => ({ ...prev, ...newOptions })); + + // Merke geladene Entities (nur ohne Filter) + if (!filters) { + setLoadedEntities(prev => { + const newSet = new Set(prev); + entities.forEach(e => newSet.add(e)); + return newSet; + }); + } + } catch (err: any) { + const errorMessage = err.response?.data?.detail || err.message || 'Failed to load options'; + setError(errorMessage); + console.error('useTrusteeOptions: Error loading options:', err); + } finally { + setLoading(false); + } + }, [instanceId]); + + /** + * Gibt das Label für einen Wert zurück. + * Falls nicht gefunden, wird der Wert selbst zurückgegeben. + */ + const getLabel = useCallback((entity: TrusteeOptionEntity, value: string | null | undefined): string => { + if (value === null || value === undefined || value === '') { + return '-'; + } + + const entityOptions = options[entity]; + if (!entityOptions || entityOptions.length === 0) { + return value; + } + + const found = entityOptions.find(o => o.value === value); + return found?.label || value; + }, [options]); + + /** + * Gibt Options für eine Entity zurück. + */ + const getOptions = useCallback((entity: TrusteeOptionEntity): TrusteeOption[] => { + return options[entity] || []; + }, [options]); + + /** + * Prüft ob Options für eine Entity geladen wurden. + */ + const isLoaded = useCallback((entity: TrusteeOptionEntity): boolean => { + return loadedEntities.has(entity); + }, [loadedEntities]); + + /** + * Lädt Options für Contracts einer spezifischen Organisation. + * Nützlich für abhängige Dropdowns. + */ + const loadContractsForOrganisation = useCallback(async (organisationId: string): Promise => { + if (!instanceId || !organisationId) { + return []; + } + + try { + const url = `/api/trustee/${instanceId}/contracts/options?organisationId=${encodeURIComponent(organisationId)}`; + const response = await api.get(url); + const contractOptions = Array.isArray(response.data) ? response.data : []; + + // Update Options-State + setOptions(prev => ({ ...prev, contracts: contractOptions })); + + return contractOptions; + } catch (err) { + console.error('useTrusteeOptions: Error loading contracts for organisation:', err); + return []; + } + }, [instanceId]); + + /** + * Erstellt eine Lookup-Map für schnelle Label-Auflösung. + */ + const createLookupMap = useCallback((entity: TrusteeOptionEntity): Map => { + const map = new Map(); + const entityOptions = options[entity] || []; + entityOptions.forEach(opt => { + map.set(opt.value, opt.label); + }); + return map; + }, [options]); + + // Memoized Lookup-Maps für Performance + const lookupMaps = useMemo(() => ({ + users: createLookupMap('users'), + organisations: createLookupMap('organisations'), + roles: createLookupMap('roles'), + contracts: createLookupMap('contracts'), + documents: createLookupMap('documents'), + positions: createLookupMap('positions'), + }), [createLookupMap]); + + /** + * Schnelle Label-Auflösung via Lookup-Map. + */ + const getLabelFast = useCallback((entity: TrusteeOptionEntity, value: string | null | undefined): string => { + if (value === null || value === undefined || value === '') { + return '-'; + } + return lookupMaps[entity].get(value) || value; + }, [lookupMaps]); + + // Auto-Load beim Mount + useEffect(() => { + if (autoLoad.length > 0) { + // Nur laden wenn instanceId verfügbar (oder nur 'users' geladen werden soll) + const needsInstance = autoLoad.some(e => e !== 'users'); + if (!needsInstance || instanceId) { + loadOptions(autoLoad); + } + } + }, [instanceId, autoLoad.join(',')]); // autoLoad als String-Join für Dependency-Vergleich + + return { + // State + options, + loading, + error, + + // Actions + loadOptions, + loadContractsForOrganisation, + + // Getters + getLabel, + getLabelFast, + getOptions, + isLoaded, + createLookupMap, + + // Context + instanceId, + }; +} + +// ============================================================================ +// CONVENIENCE EXPORTS +// ============================================================================ + +/** + * Hook speziell für TrusteeAccessView. + * Lädt automatisch users, organisations und roles. + */ +export function useTrusteeAccessOptions() { + return useTrusteeOptions(['users', 'organisations', 'roles']); +} + +/** + * Hook speziell für Views mit Organisation+Contract Dropdowns. + * Lädt automatisch organisations und contracts. + */ +export function useTrusteeOrgContractOptions() { + return useTrusteeOptions(['organisations', 'contracts']); +} + +export default useTrusteeOptions; diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts index d0ad589..942d1e5 100644 --- a/src/hooks/useWorkflows.ts +++ b/src/hooks/useWorkflows.ts @@ -607,6 +607,26 @@ export function useWorkflowOperations() { } }; + // Generic inline update handler for FormGeneratorTable + // Must merge changes with existing row data because backend requires full object + const handleInlineUpdate = async (workflowId: string, changes: Partial, existingRow?: any) => { + if (!existingRow) { + throw new Error(`Existing row data required for inline update`); + } + + // Merge changes with existing row data + const mergedData = { + name: existingRow.name, + ...changes + }; + + const result = await handleWorkflowUpdate(workflowId, mergedData); + if (!result.success) { + throw new Error(result.error || 'Failed to update'); + } + return result; + }; + return { // Loading states startingWorkflow, @@ -628,6 +648,7 @@ export function useWorkflowOperations() { handleWorkflowDelete, handleWorkflowDeleteMultiple, handleWorkflowUpdate, + handleInlineUpdate, deleteMessage, deleteFileFromMessage }; diff --git a/src/locales/de.ts b/src/locales/de.ts index fbc6384..1f0c637 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -718,7 +718,28 @@ export default { 'warning.duplicate_file.title': 'Datei bereits vorhanden', 'warning.duplicate_file.message': 'Die Datei "{fileName}" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.', - // Administration + // Chat Playground Page + 'chatPlayground.title': 'Chat Playground', + 'chatPlayground.description': 'Workflow-Ausführung und Chat-Interaktion', + 'chatPlayground.subtitle': 'Chat-basierte Workflow-Steuerung', + + // Automations Page + 'automations.title': 'Automatisierungen', + 'automations.description': 'Workflow-Automatisierungen verwalten', + 'automations.subtitle': 'Geplante und automatisierte Workflows', + 'automations.new_button': 'Neue Automatisierung', + 'automations.action.execute': 'Ausführen', + 'automations.action.edit': 'Bearbeiten', + 'automations.action.delete': 'Löschen', + 'automations.modal.create.title': 'Neue Automatisierung erstellen', + 'automations.create.success': 'Automatisierung erfolgreich erstellt', + 'automations.create.error': 'Fehler beim Erstellen der Automatisierung', + + // Basedata Group (formerly Administration) + 'basedata.title': 'Basisdaten', + 'basedata.description': 'Grundlegende Daten und Ressourcen', + + // Administration (legacy, kept for compatibility) 'administration.title': 'Werkzeuge', 'administration.description': 'Werkzeuge und Hilfsmittel', 'administration.subtitle': 'Verwaltungs- und Management-Tools', diff --git a/src/locales/en.ts b/src/locales/en.ts index e753ebf..6d9bbe0 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -718,7 +718,28 @@ export default { 'warning.duplicate_file.title': 'File Already Exists', 'warning.duplicate_file.message': 'The file "{fileName}" already exists with identical content. The existing file will be reused.', - // Administration + // Chat Playground Page + 'chatPlayground.title': 'Chat Playground', + 'chatPlayground.description': 'Workflow execution and chat interaction', + 'chatPlayground.subtitle': 'Chat-based workflow control', + + // Automations Page + 'automations.title': 'Automations', + 'automations.description': 'Manage workflow automations', + 'automations.subtitle': 'Scheduled and automated workflows', + 'automations.new_button': 'New Automation', + 'automations.action.execute': 'Execute', + 'automations.action.edit': 'Edit', + 'automations.action.delete': 'Delete', + 'automations.modal.create.title': 'Create New Automation', + 'automations.create.success': 'Automation created successfully', + 'automations.create.error': 'Error creating automation', + + // Basedata Group (formerly Administration) + 'basedata.title': 'Base Data', + 'basedata.description': 'Basic data and resources', + + // Administration (legacy, kept for compatibility) 'administration.title': 'Utils', 'administration.description': 'Utilities and tools', 'administration.subtitle': 'Administration and management tools', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 3eaf437..83f8b06 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -718,7 +718,28 @@ export default { 'warning.duplicate_file.title': 'Fichier Déjà Existant', 'warning.duplicate_file.message': 'Le fichier "{fileName}" existe déjà avec un contenu identique. Le fichier existant sera réutilisé.', - // Administration + // Chat Playground Page + 'chatPlayground.title': 'Chat Playground', + 'chatPlayground.description': 'Exécution de workflow et interaction chat', + 'chatPlayground.subtitle': 'Contrôle des workflows par chat', + + // Automations Page + 'automations.title': 'Automatisations', + 'automations.description': 'Gérer les automatisations de workflow', + 'automations.subtitle': 'Workflows planifiés et automatisés', + 'automations.new_button': 'Nouvelle Automatisation', + 'automations.action.execute': 'Exécuter', + 'automations.action.edit': 'Modifier', + 'automations.action.delete': 'Supprimer', + 'automations.modal.create.title': 'Créer une Nouvelle Automatisation', + 'automations.create.success': 'Automatisation créée avec succès', + 'automations.create.error': 'Erreur lors de la création de l\'automatisation', + + // Basedata Group (formerly Administration) + 'basedata.title': 'Données de Base', + 'basedata.description': 'Données et ressources de base', + + // Administration (legacy, kept for compatibility) 'administration.title': 'Outils', 'administration.description': 'Outils et utilitaires', 'administration.subtitle': 'Outils d\'administration et de gestion', diff --git a/src/pages/InvitePage.tsx b/src/pages/InvitePage.tsx index c17822b..dabed73 100644 --- a/src/pages/InvitePage.tsx +++ b/src/pages/InvitePage.tsx @@ -12,15 +12,17 @@ import React, { useState, useEffect } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useInvitations, type InvitationValidation, type RegisterAndAcceptData } from '../hooks/useInvitations'; -import { useAuth } from '../hooks/useAuthentication'; +// Note: useAuth not needed for InvitePage import { FaCheckCircle, FaTimesCircle, FaSpinner, FaEnvelope, FaUser, FaLock } from 'react-icons/fa'; import styles from './InvitePage.module.css'; export const InvitePage: React.FC = () => { const { token } = useParams<{ token: string }>(); const navigate = useNavigate(); - const { isAuthenticated, user } = useAuth(); - const { validateInvitation, acceptInvitation, registerAndAccept, loading } = useInvitations(); + const { validateInvitation, acceptInvitation, registerAndAccept } = useInvitations(); + + // Check if user has auth token (simplified check) + const isAuthenticated = !!sessionStorage.getItem('auth_authority'); // State const [validation, setValidation] = useState(null); @@ -185,8 +187,8 @@ export const InvitePage: React.FC = () => {
- Angemeldet als: - {user?.email || user?.username} + Status: + Angemeldet
{validation.roleLabels && validation.roleLabels.length > 0 && (
diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx index 20e1fcb..881e340 100644 --- a/src/pages/admin/AdminInvitationsPage.tsx +++ b/src/pages/admin/AdminInvitationsPage.tsx @@ -5,8 +5,8 @@ * Allows creating, viewing, and revoking invitations. */ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { useInvitations, type Invitation, type InvitationCreate, type PaginationParams } from '../../hooks/useInvitations'; +import React, { useState, useEffect, useMemo } from 'react'; +import { useInvitations, type Invitation, type InvitationCreate } from '../../hooks/useInvitations'; import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; @@ -35,7 +35,7 @@ export const AdminInvitationsPage: React.FC = () => { const [showUrlModal, setShowUrlModal] = useState(null); const [showExpired, setShowExpired] = useState(false); const [showUsed, setShowUsed] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); + const [_isSubmitting, setIsSubmitting] = useState(false); // Prefixed with _ to suppress warning const [copySuccess, setCopySuccess] = useState(false); const [backendAttributes, setBackendAttributes] = useState([]); @@ -92,7 +92,7 @@ export const AdminInvitationsPage: React.FC = () => { { key: 'roleIds', label: 'Rollen', - type: 'array' as const, + type: 'string', // Array rendered as string sortable: false, filterable: false, width: 150, @@ -103,14 +103,14 @@ export const AdminInvitationsPage: React.FC = () => { return role?.roleLabel || roleId; }).join(', '); } - }, + } as any, { key: 'expiresAt', label: 'Gültig bis', type: 'number' as const, sortable: true, width: 150, - render: (value: number, row: Invitation) => { + render: (value: number) => { const text = formatDate(value); const isExpired = value < Date.now() / 1000; return ( @@ -155,8 +155,8 @@ export const AdminInvitationsPage: React.FC = () => { // Add helper field expiresInHours if not in model but fields exist if (fields.length > 0 && !fields.find(f => f.name === 'expiresInHours')) { - fields.push({ name: 'expiresInHours', label: 'Gültigkeitsdauer (Stunden)', type: 'number' as any, - required: true, default: 72, min: 1, max: 720 }); + fields.push({ name: 'expiresInHours', label: 'Gültigkeitsdauer (Stunden)', type: 'number', + required: true, default: 72 } as any); } return fields; }, [roles, backendAttributes]); diff --git a/src/pages/basedata/BasedataPages.module.css b/src/pages/basedata/BasedataPages.module.css new file mode 100644 index 0000000..82ba4dd --- /dev/null +++ b/src/pages/basedata/BasedataPages.module.css @@ -0,0 +1,352 @@ +/* BasedataPages.module.css - Shared styles for basedata pages */ + +.page { + padding: 2rem; + max-width: 1400px; + margin: 0 auto; +} + +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; +} + +.header h1 { + font-size: 1.75rem; + font-weight: 600; + color: var(--color-text-primary, #1a1a2e); + margin: 0 0 0.5rem 0; +} + +.subtitle { + color: var(--color-text-secondary, #6b7280); + margin: 0; +} + +.headerButtons { + display: flex; + gap: 0.5rem; +} + +.createButton, +.googleButton, +.microsoftButton { + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + border: none; + transition: all 0.2s; +} + +.createButton { + background: var(--color-primary, #4f46e5); + color: white; +} + +.createButton:hover:not(:disabled) { + background: var(--color-primary-dark, #4338ca); +} + +.googleButton { + background: #ea4335; + color: white; +} + +.googleButton:hover:not(:disabled) { + background: #c53929; +} + +.microsoftButton { + background: #00a4ef; + color: white; +} + +.microsoftButton:hover:not(:disabled) { + background: #0078d4; +} + +.content { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* Loading, Error, Empty states */ +.loading, +.error, +.empty { + text-align: center; + padding: 3rem; + color: var(--color-text-secondary, #6b7280); + background: var(--color-surface, #ffffff); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 8px; +} + +.error { + color: var(--color-error, #dc2626); +} + +/* Table styles */ +.tableContainer { + overflow-x: auto; + background: var(--color-surface, #ffffff); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 8px; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.table th, +.table td { + text-align: left; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-border, #e5e7eb); +} + +.table th { + font-weight: 600; + color: var(--color-text-secondary, #6b7280); + background: var(--color-surface-secondary, #f9fafb); +} + +.table tbody tr:hover { + background: var(--color-surface-hover, #f3f4f6); +} + +.table tbody tr:last-child td { + border-bottom: none; +} + +.contentCell { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Badge styles */ +.badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.badge.active { + background: var(--color-success-bg, #dcfce7); + color: var(--color-success, #16a34a); +} + +.badge.inactive { + background: var(--color-warning-bg, #fef3c7); + color: var(--color-warning, #d97706); +} + +.provider { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + background: var(--color-surface-secondary, #f3f4f6); +} + +.provider.google { + background: #fce8e6; + color: #ea4335; +} + +.provider.msft { + background: #e8f4fd; + color: #00a4ef; +} + +/* Button styles */ +.actions { + display: flex; + gap: 0.5rem; +} + +.deleteButton, +.downloadButton, +.connectButton, +.refreshButton { + padding: 0.375rem 0.75rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.2s; +} + +.deleteButton { + background: var(--color-error-bg, #fee2e2); + color: var(--color-error, #dc2626); + border-color: var(--color-error, #dc2626); +} + +.deleteButton:hover:not(:disabled) { + background: var(--color-error, #dc2626); + color: white; +} + +.downloadButton { + background: var(--color-info-bg, #dbeafe); + color: var(--color-info, #2563eb); + border-color: var(--color-info, #2563eb); +} + +.downloadButton:hover:not(:disabled) { + background: var(--color-info, #2563eb); + color: white; +} + +.connectButton { + background: var(--color-success-bg, #dcfce7); + color: var(--color-success, #16a34a); + border-color: var(--color-success, #16a34a); +} + +.connectButton:hover:not(:disabled) { + background: var(--color-success, #16a34a); + color: white; +} + +.refreshButton { + background: var(--color-warning-bg, #fef3c7); + color: var(--color-warning, #d97706); + border-color: var(--color-warning, #d97706); +} + +.refreshButton:hover:not(:disabled) { + background: var(--color-warning, #d97706); + color: white; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Modal styles */ +.modalOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background: var(--color-surface, #ffffff); + border-radius: 8px; + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow: auto; +} + +.modalHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--color-border, #e5e7eb); +} + +.modalHeader h2 { + margin: 0; + font-size: 1.25rem; +} + +.modalHeader button { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--color-text-secondary, #6b7280); +} + +.modalBody { + padding: 1.5rem; +} + +.formGroup { + margin-bottom: 1rem; +} + +.formGroup label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--color-text-primary, #1a1a2e); +} + +.formGroup input, +.formGroup textarea { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 6px; + font-size: 0.875rem; +} + +.formGroup textarea { + resize: vertical; + min-height: 100px; +} + +.formGroup input:focus, +.formGroup textarea:focus { + outline: none; + border-color: var(--color-primary, #4f46e5); + box-shadow: 0 0 0 3px var(--color-primary-light, rgba(79, 70, 229, 0.1)); +} + +.modalFooter { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border, #e5e7eb); + margin-top: 1rem; +} + +.modalFooter button { + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.875rem; + cursor: pointer; +} + +.modalFooter button[type="button"] { + background: var(--color-surface-secondary, #f3f4f6); + border: 1px solid var(--color-border, #e5e7eb); + color: var(--color-text-primary, #1a1a2e); +} + +.modalFooter button[type="submit"] { + background: var(--color-primary, #4f46e5); + border: none; + color: white; +} + +.modalFooter button[type="submit"]:hover:not(:disabled) { + background: var(--color-primary-dark, #4338ca); +} diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx new file mode 100644 index 0000000..632be31 --- /dev/null +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -0,0 +1,348 @@ +/** + * ConnectionsPage + * + * Page for managing OAuth connections (Google, Microsoft) using FormGeneratorTable. + * Follows the pattern established in AdminUsersPage/WorkflowsPage. + */ + +import React, { useState, useMemo, useEffect } from 'react'; +import { useConnections } from '../../hooks/useConnections'; +import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; +import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; +import { FaSync, FaPlug, FaGoogle, FaMicrosoft, FaLink, FaRedo } from 'react-icons/fa'; +import styles from '../admin/Admin.module.css'; + +interface Connection { + id: string; + name?: string; + email?: string; + authority: 'google' | 'msft' | string; + status: 'active' | 'inactive' | string; + [key: string]: any; +} + +export const ConnectionsPage: React.FC = () => { + // Use the consolidated hook + const { + data: connections, + attributes, + permissions, + pagination, + loading, + error, + refetch, + fetchConnectionById, + updateOptimistically, + deleteConnection, + handleInlineUpdate, + createGoogleConnectionAndAuth, + createMicrosoftConnectionAndAuth, + connectWithPopup, + refreshMicrosoftToken, + refreshGoogleToken, + isConnecting, + } = useConnections(); + + const [editingConnection, setEditingConnection] = useState(null); + const [deletingConnections, setDeletingConnections] = useState>(new Set()); + const [refreshingConnections, setRefreshingConnections] = useState>(new Set()); + + // Initial fetch + useEffect(() => { + refetch(); + }, []); + + // Generate columns from attributes + const columns = useMemo(() => { + return (attributes || []).map(attr => ({ + key: attr.name, + label: attr.label || attr.name, + type: attr.type as any, + sortable: attr.sortable !== false, + filterable: attr.filterable !== false, + searchable: attr.searchable !== false, + width: attr.width || 150, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + })); + }, [attributes]); + + // Check permissions + const canCreate = permissions?.create !== 'n'; + const canUpdate = permissions?.update !== 'n'; + const canDelete = permissions?.delete !== 'n'; + + // Handle edit click + const handleEditClick = async (connection: Connection) => { + const fullConnection = await fetchConnectionById(connection.id); + if (fullConnection) { + setEditingConnection(fullConnection as Connection); + } + }; + + // Handle edit submit + const handleEditSubmit = async (data: Partial) => { + if (!editingConnection) return; + // Note: updateConnection is handled through the hook + try { + await handleInlineUpdate(editingConnection.id, data, editingConnection); + setEditingConnection(null); + refetch(); + } catch (error) { + console.error('Error updating connection:', error); + } + }; + + // Handle delete + const handleDelete = async (connection: Connection) => { + if (window.confirm(`Möchten Sie die Verbindung "${connection.name || connection.email || connection.id}" wirklich löschen?`)) { + setDeletingConnections(prev => new Set(prev).add(connection.id)); + try { + await deleteConnection(connection.id); + refetch(); + } catch (error) { + console.error('Error deleting connection:', error); + } finally { + setDeletingConnections(prev => { + const newSet = new Set(prev); + newSet.delete(connection.id); + return newSet; + }); + } + } + }; + + // Handle connect + const handleConnect = async (connection: Connection) => { + try { + await connectWithPopup(connection.id); + refetch(); + } catch (error) { + console.error('Error connecting:', error); + } + }; + + // Handle refresh token + const handleRefresh = async (connection: Connection) => { + setRefreshingConnections(prev => new Set(prev).add(connection.id)); + try { + if (connection.authority === 'msft') { + await refreshMicrosoftToken(connection.id); + } else if (connection.authority === 'google') { + await refreshGoogleToken(connection.id); + } + refetch(); + } catch (error) { + console.error('Error refreshing token:', error); + } finally { + setRefreshingConnections(prev => { + const newSet = new Set(prev); + newSet.delete(connection.id); + return newSet; + }); + } + }; + + // Handle create Google connection + const handleCreateGoogle = async () => { + try { + await createGoogleConnectionAndAuth(); + refetch(); + } catch (error) { + console.error('Error creating Google connection:', error); + } + }; + + // Handle create Microsoft connection + const handleCreateMicrosoft = async () => { + try { + await createMicrosoftConnectionAndAuth(); + refetch(); + } catch (error) { + console.error('Error creating Microsoft connection:', error); + } + }; + + // Form attributes for edit modal + const formAttributes = useMemo(() => { + const excludedFields = ['id', 'mandateId', 'userId', '_createdBy', '_createdAt', '_modifiedAt', 'connectedAt', 'lastChecked']; + return (attributes || []) + .filter(attr => !excludedFields.includes(attr.name)); + }, [attributes]); + + if (error) { + return ( +
+
+ ⚠️ +

Fehler beim Laden der Verbindungen: {error}

+ +
+
+ ); + } + + return ( +
+
+
+

Verbindungen

+

OAuth-Verbindungen verwalten

+
+
+ + {canCreate && ( + <> + + + + )} +
+
+ +
+ {loading && (!connections || connections.length === 0) ? ( +
+
+ Lade Verbindungen... +
+ ) : !connections || connections.length === 0 ? ( +
+ +

Keine Verbindungen vorhanden

+

+ Verbinden Sie Ihr Google- oder Microsoft-Konto, um loszulegen. +

+ {canCreate && ( +
+ + +
+ )} +
+ ) : ( + deletingConnections.has(row.id), + }] : []), + ]} + customActions={[ + { + id: 'connect', + icon: , + onClick: handleConnect, + title: 'Verbinden', + visible: (row: Connection) => row.status !== 'active', + loading: () => isConnecting, + }, + { + id: 'refresh', + icon: , + onClick: handleRefresh, + title: 'Token erneuern', + visible: (row: Connection) => row.status === 'active', + loading: (row: Connection) => refreshingConnections.has(row.id), + }, + ]} + onDelete={handleDelete} + hookData={{ + refetch, + permissions, + pagination, + handleDelete: deleteConnection, + handleInlineUpdate, + updateOptimistically, + }} + emptyMessage="Keine Verbindungen gefunden" + /> + )} +
+ + {/* Edit Modal */} + {editingConnection && ( +
setEditingConnection(null)}> +
e.stopPropagation()}> +
+

Verbindung bearbeiten

+ +
+
+ {formAttributes.length === 0 ? ( +
+
+ Lade Formular... +
+ ) : ( + setEditingConnection(null)} + submitButtonText="Speichern" + cancelButtonText="Abbrechen" + /> + )} +
+
+
+ )} +
+ ); +}; + +export default ConnectionsPage; diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx new file mode 100644 index 0000000..f725e69 --- /dev/null +++ b/src/pages/basedata/FilesPage.tsx @@ -0,0 +1,328 @@ +/** + * FilesPage + * + * Page for file management using FormGeneratorTable. + * Follows the pattern established in AdminUsersPage/WorkflowsPage. + */ + +import React, { useState, useMemo, useEffect, useRef } from 'react'; +import { useUserFiles, useFileOperations } from '../../hooks/useFiles'; +import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; +import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; +import { FaSync, FaFolder, FaUpload, FaDownload, FaEye } from 'react-icons/fa'; +import styles from '../admin/Admin.module.css'; + +interface UserFile { + id: string; + fileName: string; + mimeType?: string; + fileSize?: number; + [key: string]: any; +} + +export const FilesPage: React.FC = () => { + const fileInputRef = useRef(null); + + // Data hook + const { + data: files, + attributes, + permissions, + pagination, + loading, + error, + refetch, + fetchFileById, + updateFileOptimistically, + } = useUserFiles(); + + // Operations hook + const { + handleFileDownload, + handleFileDelete, + handleFileDeleteMultiple, + handleFileUpload, + handleFileUpdate, + handleFilePreview, + handleInlineUpdate, + deletingFiles, + downloadingFiles, + uploadingFile, + previewingFiles, + } = useFileOperations(); + + const [editingFile, setEditingFile] = useState(null); + + // Initial fetch + useEffect(() => { + refetch(); + }, []); + + // Generate columns from attributes + const columns = useMemo(() => { + return (attributes || []).map(attr => ({ + key: attr.name, + label: attr.label || attr.name, + type: attr.type as any, + sortable: attr.sortable !== false, + filterable: attr.filterable !== false, + searchable: attr.searchable !== false, + width: attr.width || 150, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + })); + }, [attributes]); + + // Check permissions + const canCreate = permissions?.create !== 'n'; + const canUpdate = permissions?.update !== 'n'; + const canDelete = permissions?.delete !== 'n'; + + // Handle edit click + const handleEditClick = async (file: UserFile) => { + const fullFile = await fetchFileById(file.id); + if (fullFile) { + setEditingFile(fullFile as UserFile); + } + }; + + // Handle edit submit + const handleEditSubmit = async (data: Partial) => { + if (!editingFile) return; + const result = await handleFileUpdate(editingFile.id, { + fileName: data.fileName || editingFile.fileName + }, editingFile); + if (result.success) { + setEditingFile(null); + refetch(); + } + }; + + // Handle delete single file + const handleDelete = async (file: UserFile) => { + if (window.confirm(`Möchten Sie die Datei "${file.fileName}" wirklich löschen?`)) { + const success = await handleFileDelete(file.id); + if (success) { + refetch(); + } + } + }; + + // Handle delete multiple files + const handleDeleteMultiple = async (filesToDelete: UserFile[]) => { + const count = filesToDelete.length; + if (window.confirm(`Möchten Sie ${count} Datei(en) wirklich löschen?`)) { + const ids = filesToDelete.map(f => f.id); + const success = await handleFileDeleteMultiple(ids); + if (success) { + refetch(); + } + } + }; + + // Handle download + const handleDownload = async (file: UserFile) => { + await handleFileDownload(file.id, file.fileName); + }; + + // Handle preview + const handlePreview = async (file: UserFile) => { + const result = await handleFilePreview(file.id, file.fileName, file.mimeType); + if (result.success && result.previewUrl) { + window.open(result.previewUrl, '_blank'); + } + }; + + // Handle upload click + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + // Handle file selection + const handleFileSelect = async (e: React.ChangeEvent) => { + const selectedFiles = e.target.files; + if (selectedFiles) { + for (const file of Array.from(selectedFiles)) { + await handleFileUpload(file); + } + refetch(); + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + // Form attributes for edit modal + const formAttributes = useMemo(() => { + const excludedFields = ['id', 'mandateId', 'fileHash', '_createdBy', '_createdAt', '_modifiedAt', 'creationDate', 'source']; + return (attributes || []) + .filter(attr => !excludedFields.includes(attr.name)); + }, [attributes]); + + if (error) { + return ( +
+
+ ⚠️ +

Fehler beim Laden der Dateien: {error}

+ +
+
+ ); + } + + return ( +
+ {/* Hidden file input */} + + +
+
+

Dateien

+

Dateiverwaltung

+
+
+ + {canCreate && ( + + )} +
+
+ +
+ {loading && (!files || files.length === 0) ? ( +
+
+ Lade Dateien... +
+ ) : !files || files.length === 0 ? ( +
+ +

Keine Dateien vorhanden

+

+ Laden Sie eine Datei hoch, um loszulegen. +

+ {canCreate && ( + + )} +
+ ) : ( + deletingFiles.has(row.id), + }] : []), + ]} + customActions={[ + { + id: 'download', + icon: , + onClick: handleDownload, + title: 'Herunterladen', + loading: (row: UserFile) => downloadingFiles.has(row.id), + }, + { + id: 'preview', + icon: , + onClick: handlePreview, + title: 'Vorschau', + loading: (row: UserFile) => previewingFiles.has(row.id), + }, + ]} + onDelete={handleDelete} + onDeleteMultiple={handleDeleteMultiple} + hookData={{ + refetch, + permissions, + pagination, + handleDelete: handleFileDelete, + handleInlineUpdate, + updateOptimistically: updateFileOptimistically, + }} + emptyMessage="Keine Dateien gefunden" + /> + )} +
+ + {/* Edit Modal */} + {editingFile && ( +
setEditingFile(null)}> +
e.stopPropagation()}> +
+

Datei bearbeiten

+ +
+
+ {formAttributes.length === 0 ? ( +
+
+ Lade Formular... +
+ ) : ( + setEditingFile(null)} + submitButtonText="Speichern" + cancelButtonText="Abbrechen" + /> + )} +
+
+
+ )} +
+ ); +}; + +export default FilesPage; diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx new file mode 100644 index 0000000..47849ff --- /dev/null +++ b/src/pages/basedata/PromptsPage.tsx @@ -0,0 +1,295 @@ +/** + * PromptsPage + * + * Page for managing prompt templates using FormGeneratorTable. + * Follows the pattern established in AdminUsersPage/WorkflowsPage. + */ + +import React, { useState, useMemo, useEffect } from 'react'; +import { usePrompts, usePromptOperations } from '../../hooks/usePrompts'; +import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; +import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; +import { FaSync, FaFileAlt, FaPlus } from 'react-icons/fa'; +import styles from '../admin/Admin.module.css'; + +interface Prompt { + id: string; + name: string; + content: string; + [key: string]: any; +} + +export const PromptsPage: React.FC = () => { + // Data hook + const { + prompts, + attributes, + permissions, + pagination, + loading, + error, + refetch, + fetchPromptById, + updateOptimistically, + } = usePrompts(); + + // Operations hook + const { + handlePromptCreate, + handlePromptUpdate, + handlePromptDelete, + handleInlineUpdate, + deletingPrompts, + creatingPrompt, + } = usePromptOperations(); + + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingPrompt, setEditingPrompt] = useState(null); + + // Initial fetch + useEffect(() => { + refetch(); + }, []); + + // Generate columns from attributes + const columns = useMemo(() => { + return (attributes || []).map(attr => ({ + key: attr.name, + label: attr.label || attr.name, + type: attr.type as any, + sortable: attr.sortable !== false, + filterable: attr.filterable !== false, + searchable: attr.searchable !== false, + width: attr.name === 'content' ? 300 : attr.width || 150, + minWidth: attr.minWidth || 100, + maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400, + })); + }, [attributes]); + + // Check permissions + const canCreate = permissions?.create !== 'n'; + const canUpdate = permissions?.update !== 'n'; + const canDelete = permissions?.delete !== 'n'; + + // Handle edit click + const handleEditClick = async (prompt: Prompt) => { + const fullPrompt = await fetchPromptById(prompt.id); + if (fullPrompt) { + setEditingPrompt(fullPrompt as Prompt); + } + }; + + // Handle create submit + const handleCreateSubmit = async (data: Partial) => { + const result = await handlePromptCreate({ + name: data.name || '', + content: data.content || '' + }); + if (result?.success) { + setShowCreateModal(false); + refetch(); + } + }; + + // Handle edit submit + const handleEditSubmit = async (data: Partial) => { + if (!editingPrompt) return; + const result = await handlePromptUpdate(editingPrompt.id, { + name: data.name || editingPrompt.name, + content: data.content || editingPrompt.content + }); + if (result.success) { + setEditingPrompt(null); + refetch(); + } + }; + + // Handle delete single prompt + const handleDelete = async (prompt: Prompt) => { + if (window.confirm(`Möchten Sie den Prompt "${prompt.name}" wirklich löschen?`)) { + const success = await handlePromptDelete(prompt.id); + if (success) { + refetch(); + } + } + }; + + // Form attributes for create/edit modal + const formAttributes = useMemo(() => { + const excludedFields = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete']; + return (attributes || []) + .filter(attr => !excludedFields.includes(attr.name)); + }, [attributes]); + + if (error) { + return ( +
+
+ ⚠️ +

Fehler beim Laden der Prompts: {error}

+ +
+
+ ); + } + + return ( +
+
+
+

Prompts

+

Prompt-Templates verwalten

+
+
+ + {canCreate && ( + + )} +
+
+ +
+ {loading && (!prompts || prompts.length === 0) ? ( +
+
+ Lade Prompts... +
+ ) : !prompts || prompts.length === 0 ? ( +
+ +

Keine Prompts vorhanden

+

+ Erstellen Sie einen neuen Prompt, um loszulegen. +

+ {canCreate && ( + + )} +
+ ) : ( + deletingPrompts.has(row.id), + }] : []), + ]} + onDelete={handleDelete} + hookData={{ + refetch, + permissions, + pagination, + handleDelete: handlePromptDelete, + handleInlineUpdate, + updateOptimistically, + }} + emptyMessage="Keine Prompts gefunden" + /> + )} +
+ + {/* Create Modal */} + {showCreateModal && ( +
setShowCreateModal(false)}> +
e.stopPropagation()}> +
+

Neuer Prompt

+ +
+
+ {formAttributes.length === 0 ? ( +
+
+ Lade Formular... +
+ ) : ( + setShowCreateModal(false)} + submitButtonText="Erstellen" + cancelButtonText="Abbrechen" + /> + )} +
+
+
+ )} + + {/* Edit Modal */} + {editingPrompt && ( +
setEditingPrompt(null)}> +
e.stopPropagation()}> +
+

Prompt bearbeiten

+ +
+
+ {formAttributes.length === 0 ? ( +
+
+ Lade Formular... +
+ ) : ( + setEditingPrompt(null)} + submitButtonText="Speichern" + cancelButtonText="Abbrechen" + /> + )} +
+
+
+ )} +
+ ); +}; + +export default PromptsPage; diff --git a/src/pages/basedata/index.ts b/src/pages/basedata/index.ts new file mode 100644 index 0000000..6a9e325 --- /dev/null +++ b/src/pages/basedata/index.ts @@ -0,0 +1,3 @@ +export { PromptsPage } from './PromptsPage'; +export { FilesPage } from './FilesPage'; +export { ConnectionsPage } from './ConnectionsPage'; diff --git a/src/pages/migrate/ChatbotPage.tsx b/src/pages/migrate/ChatbotPage.tsx new file mode 100644 index 0000000..131e445 --- /dev/null +++ b/src/pages/migrate/ChatbotPage.tsx @@ -0,0 +1,130 @@ +/** + * ChatbotPage + * + * Simple chatbot interface - temporary global page. + * TODO: Migrate to feature instance. + */ + +import React, { useState, useEffect, useRef } from 'react'; +import styles from './MigratePages.module.css'; + +interface Message { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp: Date; +} + +export const ChatbotPage: React.FC = () => { + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const messagesEndRef = useRef(null); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!inputValue.trim() || isLoading) return; + + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content: inputValue, + timestamp: new Date() + }; + + setMessages(prev => [...prev, userMessage]); + setInputValue(''); + setIsLoading(true); + + // Simulate API call - replace with actual chatbot API + try { + // TODO: Replace with actual chatbot API call + await new Promise(resolve => setTimeout(resolve, 1000)); + + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: 'Dies ist eine Platzhalter-Antwort. Der Chatbot wird zu einer Feature-Instanz migriert.', + timestamp: new Date() + }; + + setMessages(prev => [...prev, assistantMessage]); + } catch (error) { + console.error('Error:', error); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

Chatbot

+

+ MIGRATE TO FEATURE + Einfache Chat-Oberfläche +

+
+ +
+
+ {messages.length === 0 ? ( +
+

Noch keine Nachrichten. Starten Sie eine Konversation!

+
+ ) : ( + messages.map(message => ( +
+
+ {message.content} +
+
+ {message.timestamp.toLocaleTimeString('de-DE')} +
+
+ )) + )} + {isLoading && ( +
+
+ +
+
+ )} +
+
+ +
+ setInputValue(e.target.value)} + placeholder="Nachricht eingeben..." + disabled={isLoading} + className={styles.chatInput} + /> + +
+
+
+ ); +}; + +export default ChatbotPage; diff --git a/src/pages/migrate/MigratePages.module.css b/src/pages/migrate/MigratePages.module.css new file mode 100644 index 0000000..95b409f --- /dev/null +++ b/src/pages/migrate/MigratePages.module.css @@ -0,0 +1,223 @@ +/* MigratePages.module.css - Styles for migrate-to-feature pages */ + +.page { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; + height: calc(100vh - 4rem); + display: flex; + flex-direction: column; +} + +.header { + margin-bottom: 1.5rem; +} + +.header h1 { + font-size: 1.75rem; + font-weight: 600; + color: var(--color-text-primary, #1a1a2e); + margin: 0 0 0.5rem 0; +} + +.subtitle { + color: var(--color-text-secondary, #6b7280); + margin: 0; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.migrateTag { + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--color-warning-bg, #fef3c7); + color: var(--color-warning, #d97706); + font-size: 0.65rem; + font-weight: 700; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.content { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} + +/* Placeholder for migrate pages */ +.placeholder { + text-align: center; + padding: 3rem; + background: var(--color-surface, #ffffff); + border: 2px dashed var(--color-border, #e5e7eb); + border-radius: 12px; + max-width: 500px; +} + +.placeholderIcon { + font-size: 4rem; + margin-bottom: 1rem; +} + +.placeholder h2 { + margin: 0 0 1rem 0; + color: var(--color-text-primary, #1a1a2e); +} + +.placeholder p { + color: var(--color-text-secondary, #6b7280); + margin: 0 0 0.5rem 0; +} + +.hint { + font-size: 0.875rem; + color: var(--color-text-tertiary, #9ca3af); + margin-top: 1rem !important; +} + +/* Chat container for ChatbotPage */ +.chatContainer { + flex: 1; + display: flex; + flex-direction: column; + background: var(--color-surface, #ffffff); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 8px; + overflow: hidden; +} + +.messagesArea { + flex: 1; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.emptyChat { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-secondary, #6b7280); +} + +.message { + max-width: 70%; + padding: 0.75rem 1rem; + border-radius: 12px; +} + +.message.user { + align-self: flex-end; + background: var(--color-primary, #4f46e5); + color: white; + border-bottom-right-radius: 4px; +} + +.message.assistant { + align-self: flex-start; + background: var(--color-surface-secondary, #f3f4f6); + color: var(--color-text-primary, #1a1a2e); + border-bottom-left-radius: 4px; +} + +.message.system { + align-self: center; + background: var(--color-warning-bg, #fef3c7); + color: var(--color-warning, #d97706); + font-size: 0.875rem; +} + +.messageContent { + word-wrap: break-word; +} + +.messageTime { + font-size: 0.7rem; + opacity: 0.7; + margin-top: 0.25rem; +} + +/* Typing indicator */ +.typing { + display: flex; + gap: 4px; + padding: 0.5rem 0; +} + +.typing span { + width: 8px; + height: 8px; + background: var(--color-text-secondary, #6b7280); + border-radius: 50%; + animation: typing 1s infinite; +} + +.typing span:nth-child(2) { + animation-delay: 0.2s; +} + +.typing span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes typing { + 0%, 100% { + opacity: 0.3; + transform: scale(0.8); + } + 50% { + opacity: 1; + transform: scale(1); + } +} + +/* Input area */ +.inputArea { + display: flex; + gap: 0.5rem; + padding: 1rem; + border-top: 1px solid var(--color-border, #e5e7eb); + background: var(--color-surface-secondary, #f9fafb); +} + +.chatInput { + flex: 1; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 24px; + font-size: 0.875rem; + background: var(--color-surface, #ffffff); +} + +.chatInput:focus { + outline: none; + border-color: var(--color-primary, #4f46e5); + box-shadow: 0 0 0 3px var(--color-primary-light, rgba(79, 70, 229, 0.1)); +} + +.sendButton { + padding: 0.75rem 1.5rem; + background: var(--color-primary, #4f46e5); + color: white; + border: none; + border-radius: 24px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.sendButton:hover:not(:disabled) { + background: var(--color-primary-dark, #4338ca); +} + +.sendButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/src/pages/migrate/PekPage.tsx b/src/pages/migrate/PekPage.tsx new file mode 100644 index 0000000..797b8b8 --- /dev/null +++ b/src/pages/migrate/PekPage.tsx @@ -0,0 +1,39 @@ +/** + * PekPage + * + * PEK (Projekt-Entwicklungs-Koordination) page - temporary global page. + * TODO: Migrate to feature instance. + */ + +import React from 'react'; +import styles from './MigratePages.module.css'; + +export const PekPage: React.FC = () => { + return ( +
+
+

PEK

+

+ MIGRATE TO FEATURE + Projekt-Entwicklungs-Koordination +

+
+ +
+
+
📊
+

PEK-Modul

+

+ Dieses Modul wird zu einer Feature-Instanz migriert. +

+

+ Nach der Migration wird PEK als Feature pro Mandant verfĂĽgbar sein, + mit instanz-spezifischen Daten und Berechtigungen. +

+
+
+
+ ); +}; + +export default PekPage; diff --git a/src/pages/migrate/SpeechPage.tsx b/src/pages/migrate/SpeechPage.tsx new file mode 100644 index 0000000..9bfda2c --- /dev/null +++ b/src/pages/migrate/SpeechPage.tsx @@ -0,0 +1,39 @@ +/** + * SpeechPage + * + * Speech recognition and transcription page - temporary global page. + * TODO: Migrate to feature instance. + */ + +import React from 'react'; +import styles from './MigratePages.module.css'; + +export const SpeechPage: React.FC = () => { + return ( +
+
+

Speech

+

+ MIGRATE TO FEATURE + Spracherkennung und Transkription +

+
+ +
+
+
🎤
+

Speech-Modul

+

+ Dieses Modul wird zu einer Feature-Instanz migriert. +

+

+ Nach der Migration wird Speech als Feature pro Mandant verfĂĽgbar sein, + mit instanz-spezifischen Transkriptionen und Einstellungen. +

+
+
+
+ ); +}; + +export default SpeechPage; diff --git a/src/pages/migrate/index.ts b/src/pages/migrate/index.ts new file mode 100644 index 0000000..55e9747 --- /dev/null +++ b/src/pages/migrate/index.ts @@ -0,0 +1,3 @@ +export { ChatbotPage } from './ChatbotPage'; +export { PekPage } from './PekPage'; +export { SpeechPage } from './SpeechPage'; diff --git a/src/pages/views/trustee/TrusteeAccessView.tsx b/src/pages/views/trustee/TrusteeAccessView.tsx index 1538476..4685ea5 100644 --- a/src/pages/views/trustee/TrusteeAccessView.tsx +++ b/src/pages/views/trustee/TrusteeAccessView.tsx @@ -1,20 +1,77 @@ /** * TrusteeAccessView * - * Zugriffs-Verwaltung für eine Trustee-Instanz + * Zugriffs-Verwaltung für eine Trustee-Instanz. + * Zeigt User-Zuweisungen zu Organisationen mit Rollen. */ -import React from 'react'; -import { useTrusteeAccess, useTrusteeAccessOperations } from '../../../hooks/useTrustee'; +import React, { useState, useMemo, useEffect } from 'react'; +import { useTrusteeAccess, useTrusteeAccessOperations, TrusteeAccess } from '../../../hooks/useTrustee'; +import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions'; import { useTablePermission } from '../../../hooks/useInstancePermissions'; +import { Popup } from '../../../components/UiComponents/Popup/Popup'; +import { TrusteeEditForm, FieldConfig } from './components'; import styles from './TrusteeViews.module.css'; export const TrusteeAccessView: React.FC = () => { const { items: accessList, loading, error, refetch } = useTrusteeAccess(); - const { handleDelete, deletingItems } = useTrusteeAccessOperations(); + const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteeAccessOperations(); const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeAccess'); - if (loading) { + // Options für Label-Auflösung und Dropdowns + const { getLabelFast, loading: optionsLoading, loadOptions, getOptions, loadContractsForOrganisation } = useTrusteeOptions(['users', 'organisations', 'roles']); + + // Modal State + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingAccess, setEditingAccess] = useState(null); + const [formError, setFormError] = useState(null); + const [contractOptions, setContractOptions] = useState>([]); + + // Lade Contracts wenn Organisation ausgewählt + const handleOrganisationChange = async (organisationId: string) => { + if (organisationId) { + const contracts = await loadContractsForOrganisation(organisationId); + setContractOptions(contracts); + } else { + setContractOptions([]); + } + }; + + // Feld-Konfiguration für das Formular + const fields: FieldConfig[] = useMemo(() => [ + { + key: 'userId', + label: 'Benutzer', + type: 'enum', + required: true, + optionsReference: 'users', + }, + { + key: 'organisationId', + label: 'Organisation', + type: 'enum', + required: true, + optionsReference: 'organisations', + }, + { + key: 'roleId', + label: 'Rolle', + type: 'enum', + required: true, + optionsReference: 'roles', + }, + { + key: 'contractId', + label: 'Vertrag (optional)', + type: 'enum', + required: false, + options: contractOptions, + dependsOn: 'organisationId', + helpText: 'Leer = Zugriff auf alle Verträge der Organisation', + }, + ], [contractOptions]); + + if (loading || optionsLoading) { return
Lade Zugriffe...
; } @@ -31,12 +88,68 @@ export const TrusteeAccessView: React.FC = () => { } }; + const onEdit = async (access: TrusteeAccess) => { + setEditingAccess(access); + setFormError(null); + // Lade Contracts fĂĽr die Organisation + if (access.organisationId) { + const contracts = await loadContractsForOrganisation(access.organisationId); + setContractOptions(contracts); + } + setIsModalOpen(true); + }; + + const onCreate = () => { + setEditingAccess(null); + setFormError(null); + setContractOptions([]); + setIsModalOpen(true); + }; + + const onCloseModal = () => { + setIsModalOpen(false); + setEditingAccess(null); + setFormError(null); + setContractOptions([]); + }; + + const onSave = async (data: Partial) => { + setFormError(null); + + // Konvertiere leeren String zu null fĂĽr contractId + const processedData = { + ...data, + contractId: data.contractId || null, + }; + + try { + if (editingAccess) { + const result = await handleUpdate(editingAccess.id, processedData); + if (!result.success) { + setFormError(result.error || 'Fehler beim Aktualisieren'); + return; + } + } else { + const result = await handleCreate(processedData); + if (!result.success) { + setFormError(result.error || 'Fehler beim Erstellen'); + return; + } + } + + onCloseModal(); + refetch(); + } catch (err: any) { + setFormError(err.message || 'Ein Fehler ist aufgetreten'); + } + }; + return (
{/* Toolbar */}
{canCreate && ( - )} @@ -54,7 +167,7 @@ export const TrusteeAccessView: React.FC = () => { - + @@ -64,13 +177,27 @@ export const TrusteeAccessView: React.FC = () => { {accessList.map((access) => ( - - - - + + + +
UserBenutzer Organisation Rolle Vertrag
{access.userId}{access.organisationId}{access.roleId}{access.contractId || '-'}{getLabelFast('users', access.userId)}{getLabelFast('organisations', access.organisationId)} + + {getLabelFast('roles', access.roleId)} + + + {access.contractId ? ( + getLabelFast('contracts', access.contractId) + ) : ( + Alle + )} + {canUpdate && ( - )} @@ -90,6 +217,29 @@ export const TrusteeAccessView: React.FC = () => {
)} + + {/* Create/Edit Modal */} + + {formError && ( +
+ {formError} +
+ )} + + initialData={editingAccess || {}} + fields={fields} + onSave={onSave} + onCancel={onCloseModal} + isSaving={creatingItem} + isEdit={!!editingAccess} + saveLabel={editingAccess ? 'Aktualisieren' : 'Erstellen'} + /> +
); }; diff --git a/src/pages/views/trustee/TrusteeContractsView.tsx b/src/pages/views/trustee/TrusteeContractsView.tsx index eca7387..b28ea91 100644 --- a/src/pages/views/trustee/TrusteeContractsView.tsx +++ b/src/pages/views/trustee/TrusteeContractsView.tsx @@ -1,20 +1,58 @@ /** * TrusteeContractsView * - * Vertrags-Verwaltung für eine Trustee-Instanz + * Vertrags-Verwaltung für eine Trustee-Instanz. + * Zeigt Kundenverträge mit Organisation-Zuordnung. */ -import React from 'react'; -import { useTrusteeContracts, useTrusteeContractOperations } from '../../../hooks/useTrustee'; +import React, { useState, useMemo } from 'react'; +import { useTrusteeContracts, useTrusteeContractOperations, TrusteeContract } from '../../../hooks/useTrustee'; +import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions'; import { useTablePermission } from '../../../hooks/useInstancePermissions'; +import { Popup } from '../../../components/UiComponents/Popup/Popup'; +import { TrusteeEditForm, FieldConfig } from './components'; import styles from './TrusteeViews.module.css'; export const TrusteeContractsView: React.FC = () => { const { items: contracts, loading, error, refetch } = useTrusteeContracts(); - const { handleDelete, deletingItems } = useTrusteeContractOperations(); + const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteeContractOperations(); const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeContract'); - if (loading) { + // Options für Label-Auflösung + const { getLabelFast, loading: optionsLoading } = useTrusteeOptions(['organisations']); + + // Modal State + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingContract, setEditingContract] = useState(null); + const [formError, setFormError] = useState(null); + + // Feld-Konfiguration für das Formular + const fields: FieldConfig[] = useMemo(() => [ + { + key: 'organisationId', + label: 'Organisation', + type: 'enum', + required: true, + optionsReference: 'organisations', + editable: !editingContract, // Nicht änderbar nach Erstellung + helpText: editingContract ? 'Organisation kann nicht geändert werden' : undefined, + }, + { + key: 'label', + label: 'Bezeichnung', + type: 'string', + required: true, + placeholder: 'z.B. Kunde AG 2026', + }, + { + key: 'enabled', + label: 'Status', + type: 'boolean', + helpText: 'Vertrag ist aktiv', + }, + ], [editingContract]); + + if (loading || optionsLoading) { return
Lade Verträge...
; } @@ -31,12 +69,57 @@ export const TrusteeContractsView: React.FC = () => { } }; + const onEdit = (contract: TrusteeContract) => { + setEditingContract(contract); + setFormError(null); + setIsModalOpen(true); + }; + + const onCreate = () => { + setEditingContract(null); + setFormError(null); + setIsModalOpen(true); + }; + + const onCloseModal = () => { + setIsModalOpen(false); + setEditingContract(null); + setFormError(null); + }; + + const onSave = async (data: Partial) => { + setFormError(null); + + try { + if (editingContract) { + // Bei Update: organisationId nicht mitsenden (ist immutable) + const { organisationId, ...updateData } = data; + const result = await handleUpdate(editingContract.id, updateData); + if (!result.success) { + setFormError(result.error || 'Fehler beim Aktualisieren'); + return; + } + } else { + const result = await handleCreate(data); + if (!result.success) { + setFormError(result.error || 'Fehler beim Erstellen'); + return; + } + } + + onCloseModal(); + refetch(); + } catch (err: any) { + setFormError(err.message || 'Ein Fehler ist aufgetreten'); + } + }; + return (
{/* Toolbar */}
{canCreate && ( - )} @@ -54,7 +137,7 @@ export const TrusteeContractsView: React.FC = () => { - + @@ -64,7 +147,7 @@ export const TrusteeContractsView: React.FC = () => { {contracts.map((contract) => ( - +
LabelBezeichnung Organisation Status Aktionen
{contract.label}{contract.organisationId}{getLabelFast('organisations', contract.organisationId)} {contract.enabled ? 'Aktiv' : 'Inaktiv'} @@ -72,7 +155,11 @@ export const TrusteeContractsView: React.FC = () => { {canUpdate && ( - )} @@ -92,6 +179,29 @@ export const TrusteeContractsView: React.FC = () => {
)} + + {/* Create/Edit Modal */} + + {formError && ( +
+ {formError} +
+ )} + + initialData={editingContract || { enabled: true }} + fields={fields} + onSave={onSave} + onCancel={onCloseModal} + isSaving={creatingItem} + isEdit={!!editingContract} + saveLabel={editingContract ? 'Aktualisieren' : 'Erstellen'} + /> +
); }; diff --git a/src/pages/views/trustee/TrusteeDocumentsView.tsx b/src/pages/views/trustee/TrusteeDocumentsView.tsx index b51504f..664b46d 100644 --- a/src/pages/views/trustee/TrusteeDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteeDocumentsView.tsx @@ -1,20 +1,78 @@ /** * TrusteeDocumentsView * - * Dokument-Verwaltung für eine Trustee-Instanz + * Dokument-Verwaltung für eine Trustee-Instanz. + * Zeigt Belege und Dokumente mit Vertragszuordnung. */ -import React from 'react'; -import { useTrusteeDocuments, useTrusteeDocumentOperations } from '../../../hooks/useTrustee'; +import React, { useState, useMemo } from 'react'; +import { useTrusteeDocuments, useTrusteeDocumentOperations, TrusteeDocument } from '../../../hooks/useTrustee'; +import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions'; import { useTablePermission } from '../../../hooks/useInstancePermissions'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import { Popup } from '../../../components/UiComponents/Popup/Popup'; +import { TrusteeEditForm, FieldConfig } from './components'; +import api from '../../../api'; import styles from './TrusteeViews.module.css'; export const TrusteeDocumentsView: React.FC = () => { + const instanceId = useInstanceId(); const { items: documents, loading, error, refetch } = useTrusteeDocuments(); - const { handleDelete, deletingItems } = useTrusteeDocumentOperations(); + const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteeDocumentOperations(); const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeDocument'); - if (loading) { + // Options für Label-Auflösung + const { getLabelFast, loading: optionsLoading, loadContractsForOrganisation } = useTrusteeOptions(['organisations', 'contracts']); + + // Modal State + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingDoc, setEditingDoc] = useState(null); + const [formError, setFormError] = useState(null); + const [downloading, setDownloading] = useState(null); + const [contractOptions, setContractOptions] = useState>([]); + + // MIME-Type Options + const mimeTypeOptions = [ + { value: 'application/pdf', label: 'PDF' }, + { value: 'image/jpeg', label: 'JPEG' }, + { value: 'image/png', label: 'PNG' }, + { value: 'application/octet-stream', label: 'Andere' }, + ]; + + // Feld-Konfiguration für das Formular + const fields: FieldConfig[] = useMemo(() => [ + { + key: 'organisationId', + label: 'Organisation', + type: 'enum', + required: true, + optionsReference: 'organisations', + }, + { + key: 'contractId', + label: 'Vertrag', + type: 'enum', + required: true, + options: contractOptions, + dependsOn: 'organisationId', + }, + { + key: 'documentName', + label: 'Dokumentname', + type: 'string', + required: true, + placeholder: 'z.B. Rechnung_2026.pdf', + }, + { + key: 'documentMimeType', + label: 'Dateityp', + type: 'enum', + required: true, + options: mimeTypeOptions, + }, + ], [contractOptions]); + + if (loading || optionsLoading) { return
Lade Dokumente...
; } @@ -31,12 +89,96 @@ export const TrusteeDocumentsView: React.FC = () => { } }; + const onEdit = async (doc: TrusteeDocument) => { + setEditingDoc(doc); + setFormError(null); + // Lade Contracts fĂĽr die Organisation + if (doc.organisationId) { + const contracts = await loadContractsForOrganisation(doc.organisationId); + setContractOptions(contracts); + } + setIsModalOpen(true); + }; + + const onCreate = () => { + setEditingDoc(null); + setFormError(null); + setContractOptions([]); + setIsModalOpen(true); + }; + + const onCloseModal = () => { + setIsModalOpen(false); + setEditingDoc(null); + setFormError(null); + setContractOptions([]); + }; + + const onSave = async (data: Partial) => { + setFormError(null); + + try { + if (editingDoc) { + const result = await handleUpdate(editingDoc.id, data); + if (!result.success) { + setFormError(result.error || 'Fehler beim Aktualisieren'); + return; + } + } else { + const result = await handleCreate(data); + if (!result.success) { + setFormError(result.error || 'Fehler beim Erstellen'); + return; + } + } + + onCloseModal(); + refetch(); + } catch (err: any) { + setFormError(err.message || 'Ein Fehler ist aufgetreten'); + } + }; + + const onDownload = async (doc: TrusteeDocument) => { + if (!instanceId) return; + + setDownloading(doc.id); + try { + const response = await api.get( + `/api/trustee/${instanceId}/documents/${doc.id}/data`, + { responseType: 'blob' } + ); + + // Blob-Download + const blob = new Blob([response.data], { type: doc.documentMimeType }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = doc.documentName || 'document'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (err) { + console.error('Download error:', err); + alert('Fehler beim Herunterladen des Dokuments.'); + } finally { + setDownloading(null); + } + }; + + // MIME-Type zu lesbarem Text + const getMimeTypeLabel = (mimeType: string) => { + const found = mimeTypeOptions.find(o => o.value === mimeType); + return found?.label || mimeType?.split('/')[1]?.toUpperCase() || 'Unbekannt'; + }; + return (
{/* Toolbar */}
{canCreate && ( - )} @@ -64,14 +206,27 @@ export const TrusteeDocumentsView: React.FC = () => { {documents.map((doc) => ( {doc.documentName} - {doc.documentMimeType} - {doc.contractId} + + + {getMimeTypeLabel(doc.documentMimeType)} + + + {getLabelFast('contracts', doc.contractId)} - {canUpdate && ( - )} @@ -91,6 +246,29 @@ export const TrusteeDocumentsView: React.FC = () => { )} + + {/* Create/Edit Modal */} + + {formError && ( +
+ {formError} +
+ )} + + initialData={editingDoc || { documentMimeType: 'application/pdf' }} + fields={fields} + onSave={onSave} + onCancel={onCloseModal} + isSaving={creatingItem} + isEdit={!!editingDoc} + saveLabel={editingDoc ? 'Aktualisieren' : 'Erstellen'} + /> +
); }; diff --git a/src/pages/views/trustee/TrusteeOrganisationsView.tsx b/src/pages/views/trustee/TrusteeOrganisationsView.tsx index 16d88c4..0c80e63 100644 --- a/src/pages/views/trustee/TrusteeOrganisationsView.tsx +++ b/src/pages/views/trustee/TrusteeOrganisationsView.tsx @@ -1,19 +1,53 @@ /** * TrusteeOrganisationsView * - * Organisations-Verwaltung fĂĽr eine Trustee-Instanz + * Organisations-Verwaltung fĂĽr eine Trustee-Instanz. + * Zeigt Kunden-Organisationen des TreuhandbĂĽros. */ -import React from 'react'; -import { useTrusteeOrganisations, useTrusteeOrganisationOperations } from '../../../hooks/useTrustee'; +import React, { useState, useMemo } from 'react'; +import { useTrusteeOrganisations, useTrusteeOrganisationOperations, TrusteeOrganisation } from '../../../hooks/useTrustee'; import { useTablePermission } from '../../../hooks/useInstancePermissions'; +import { Popup } from '../../../components/UiComponents/Popup/Popup'; +import { TrusteeEditForm, FieldConfig } from './components'; import styles from './TrusteeViews.module.css'; export const TrusteeOrganisationsView: React.FC = () => { - const { items: organisations, loading, error, refetch } = useTrusteeOrganisations(); - const { handleDelete, deletingItems } = useTrusteeOrganisationOperations(); + const { items: organisations, loading, error, refetch, generateCreateFieldsFromAttributes, generateEditFieldsFromAttributes, ensureAttributesLoaded } = useTrusteeOrganisations(); + const { handleDelete, handleCreate, handleUpdate, deletingItems, createError, updateError, creatingItem } = useTrusteeOrganisationOperations(); const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeOrganisation'); + // Modal State + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingOrg, setEditingOrg] = useState(null); + const [formError, setFormError] = useState(null); + + // Feld-Konfiguration fĂĽr das Formular + const fields: FieldConfig[] = useMemo(() => [ + { + key: 'id', + label: 'ID', + type: 'string', + required: true, + editable: !editingOrg, // Nur bei Create editierbar + placeholder: 'z.B. kunde-ag', + helpText: 'Eindeutige ID (alphanumerisch, Bindestrich, Unterstrich)', + }, + { + key: 'label', + label: 'Bezeichnung', + type: 'string', + required: true, + placeholder: 'z.B. Kunde AG', + }, + { + key: 'enabled', + label: 'Status', + type: 'boolean', + helpText: 'Organisation ist aktiv', + }, + ], [editingOrg]); + if (loading) { return
Lade Organisationen...
; } @@ -31,12 +65,73 @@ export const TrusteeOrganisationsView: React.FC = () => { } }; + const onEdit = (org: TrusteeOrganisation) => { + setEditingOrg(org); + setFormError(null); + setIsModalOpen(true); + }; + + const onCreate = () => { + setEditingOrg(null); + setFormError(null); + setIsModalOpen(true); + }; + + const onCloseModal = () => { + setIsModalOpen(false); + setEditingOrg(null); + setFormError(null); + }; + + const onSave = async (data: Partial) => { + setFormError(null); + + try { + if (editingOrg) { + // Update + const result = await handleUpdate(editingOrg.id, data); + if (!result.success) { + setFormError(result.error || 'Fehler beim Aktualisieren'); + return; + } + } else { + // Create + const result = await handleCreate(data); + if (!result.success) { + setFormError(result.error || 'Fehler beim Erstellen'); + return; + } + } + + onCloseModal(); + refetch(); + } catch (err: any) { + setFormError(err.message || 'Ein Fehler ist aufgetreten'); + } + }; + + // Validierung + const validateOrganisation = (data: Partial): Record | null => { + const errors: Record = {}; + + // ID-Format prĂĽfen (nur bei Create) + if (!editingOrg && data.id) { + if (data.id.length < 3 || data.id.length > 50) { + errors.id = 'ID muss zwischen 3 und 50 Zeichen lang sein'; + } else if (!/^[a-zA-Z0-9_-]+$/.test(data.id)) { + errors.id = 'ID darf nur Buchstaben, Zahlen, Bindestrich und Unterstrich enthalten'; + } + } + + return Object.keys(errors).length > 0 ? errors : null; + }; + return (
{/* Toolbar */}
{canCreate && ( - )} @@ -54,7 +149,8 @@ export const TrusteeOrganisationsView: React.FC = () => { - + + @@ -62,6 +158,7 @@ export const TrusteeOrganisationsView: React.FC = () => { {organisations.map((org) => ( +
LabelIDBezeichnung Status Aktionen
{org.id} {org.label} @@ -70,7 +167,11 @@ export const TrusteeOrganisationsView: React.FC = () => { {canUpdate && ( - )} @@ -90,6 +191,30 @@ export const TrusteeOrganisationsView: React.FC = () => {
)} + + {/* Create/Edit Modal */} + + {formError && ( +
+ {formError} +
+ )} + + initialData={editingOrg || { enabled: true }} + fields={fields} + onSave={onSave} + onCancel={onCloseModal} + isSaving={creatingItem} + validate={validateOrganisation} + isEdit={!!editingOrg} + saveLabel={editingOrg ? 'Aktualisieren' : 'Erstellen'} + /> +
); }; diff --git a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx new file mode 100644 index 0000000..cf58187 --- /dev/null +++ b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx @@ -0,0 +1,206 @@ +/** + * TrusteePositionDocumentsView + * + * Verknüpfungs-Verwaltung zwischen Positionen und Dokumenten. + * Ermöglicht das Zuweisen von Belegen zu Buchungspositionen. + */ + +import React, { useState, useMemo } from 'react'; +import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations, TrusteePositionDocument } from '../../../hooks/useTrustee'; +import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions'; +import { useTablePermission } from '../../../hooks/useInstancePermissions'; +import { Popup } from '../../../components/UiComponents/Popup/Popup'; +import { TrusteeEditForm, FieldConfig } from './components'; +import styles from './TrusteeViews.module.css'; + +export const TrusteePositionDocumentsView: React.FC = () => { + const { items: links, loading, error, refetch } = useTrusteePositionDocuments(); + const { handleDelete, handleCreate, deletingItems, creatingItem } = useTrusteePositionDocumentOperations(); + const { canCreate, canDelete } = useTablePermission('TrusteePositionDocument'); + + // Options für Label-Auflösung + const { getLabelFast, loading: optionsLoading, loadContractsForOrganisation } = useTrusteeOptions(['organisations', 'contracts', 'positions', 'documents']); + + // Modal State + const [isModalOpen, setIsModalOpen] = useState(false); + const [formError, setFormError] = useState(null); + const [contractOptions, setContractOptions] = useState>([]); + + // Feld-Konfiguration für das Formular + const fields: FieldConfig[] = useMemo(() => [ + { + key: 'organisationId', + label: 'Organisation', + type: 'enum', + required: true, + optionsReference: 'organisations', + }, + { + key: 'contractId', + label: 'Vertrag', + type: 'enum', + required: true, + options: contractOptions, + dependsOn: 'organisationId', + }, + { + key: 'positionId', + label: 'Position', + type: 'enum', + required: true, + optionsReference: 'positions', + helpText: 'Die Buchungsposition, der ein Beleg zugewiesen werden soll', + }, + { + key: 'documentId', + label: 'Dokument', + type: 'enum', + required: true, + optionsReference: 'documents', + helpText: 'Der Beleg, der der Position zugewiesen werden soll', + }, + ], [contractOptions]); + + if (loading || optionsLoading) { + return
Lade VerknĂĽpfungen...
; + } + + if (error) { + return
Fehler: {error}
; + } + + const onDelete = async (linkId: string) => { + if (window.confirm('VerknĂĽpfung wirklich entfernen?')) { + const success = await handleDelete(linkId); + if (success) { + refetch(); + } + } + }; + + const onCreate = () => { + setFormError(null); + setContractOptions([]); + setIsModalOpen(true); + }; + + const onCloseModal = () => { + setIsModalOpen(false); + setFormError(null); + setContractOptions([]); + }; + + const onSave = async (data: Partial) => { + setFormError(null); + + try { + const result = await handleCreate(data); + if (!result.success) { + setFormError(result.error || 'Fehler beim Erstellen'); + return; + } + + onCloseModal(); + refetch(); + } catch (err: any) { + setFormError(err.message || 'Ein Fehler ist aufgetreten'); + } + }; + + // Gruppiere nach Position fĂĽr bessere Ăśbersicht + const groupedByPosition = useMemo(() => { + const grouped: Record = {}; + links.forEach(link => { + if (!grouped[link.positionId]) { + grouped[link.positionId] = []; + } + grouped[link.positionId].push(link); + }); + return grouped; + }, [links]); + + return ( +
+ {/* Toolbar */} +
+ {canCreate && ( + + )} + +
+ + {/* Info */} +
+ Hier verknĂĽpfen Sie Belege (Dokumente) mit Buchungspositionen. +
+ + {/* Tabelle */} + {links.length === 0 ? ( +
+

Keine VerknĂĽpfungen vorhanden.

+
+ ) : ( + + + + + + + + + + + {links.map((link) => ( + + + + + + + ))} + +
PositionDokumentVertragAktionen
{getLabelFast('positions', link.positionId)}{getLabelFast('documents', link.documentId)}{getLabelFast('contracts', link.contractId)} + {canDelete && ( + + )} +
+ )} + + {/* Create Modal */} + + {formError && ( +
+ {formError} +
+ )} + + initialData={{}} + fields={fields} + onSave={onSave} + onCancel={onCloseModal} + isSaving={creatingItem} + isEdit={false} + saveLabel="VerknĂĽpfung erstellen" + /> +
+
+ ); +}; + +export default TrusteePositionDocumentsView; diff --git a/src/pages/views/trustee/TrusteePositionsView.tsx b/src/pages/views/trustee/TrusteePositionsView.tsx index 37fc6ef..2b87571 100644 --- a/src/pages/views/trustee/TrusteePositionsView.tsx +++ b/src/pages/views/trustee/TrusteePositionsView.tsx @@ -1,20 +1,124 @@ /** * TrusteePositionsView * - * Positions-Verwaltung für eine Trustee-Instanz + * Positions-Verwaltung für eine Trustee-Instanz. + * Zeigt Buchungspositionen (Speseneinträge) mit Beträgen. */ -import React from 'react'; -import { useTrusteePositions, useTrusteePositionOperations } from '../../../hooks/useTrustee'; +import React, { useState, useMemo, useCallback } from 'react'; +import { useTrusteePositions, useTrusteePositionOperations, TrusteePosition } from '../../../hooks/useTrustee'; +import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions'; import { useTablePermission } from '../../../hooks/useInstancePermissions'; +import { Popup } from '../../../components/UiComponents/Popup/Popup'; +import { TrusteeEditForm, FieldConfig } from './components'; import styles from './TrusteeViews.module.css'; export const TrusteePositionsView: React.FC = () => { const { items: positions, loading, error, refetch } = useTrusteePositions(); - const { handleDelete, deletingItems } = useTrusteePositionOperations(); + const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteePositionOperations(); const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteePosition'); - if (loading) { + // Options für Label-Auflösung + const { getLabelFast, loading: optionsLoading, loadContractsForOrganisation } = useTrusteeOptions(['organisations', 'contracts']); + + // Modal State + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingPosition, setEditingPosition] = useState(null); + const [formError, setFormError] = useState(null); + const [contractOptions, setContractOptions] = useState>([]); + + // Währungs-Options + const currencyOptions = [ + { value: 'CHF', label: 'CHF' }, + { value: 'EUR', label: 'EUR' }, + { value: 'USD', label: 'USD' }, + { value: 'GBP', label: 'GBP' }, + ]; + + // Feld-Konfiguration für das Formular + const fields: FieldConfig[] = useMemo(() => [ + { + key: 'organisationId', + label: 'Organisation', + type: 'enum', + required: true, + optionsReference: 'organisations', + }, + { + key: 'contractId', + label: 'Vertrag', + type: 'enum', + required: true, + options: contractOptions, + dependsOn: 'organisationId', + }, + { + key: 'valuta', + label: 'Valutadatum', + type: 'date', + required: true, + }, + { + key: 'company', + label: 'Firma', + type: 'string', + placeholder: 'Name des Lieferanten/Empfängers', + }, + { + key: 'desc', + label: 'Beschreibung', + type: 'textarea', + placeholder: 'Beschreibung der Position', + }, + { + key: 'tags', + label: 'Tags', + type: 'string', + placeholder: 'Komma-getrennte Tags', + helpText: 'z.B. Reise, Spesen, IT', + }, + { + key: 'bookingCurrency', + label: 'Buchungswährung', + type: 'enum', + required: true, + options: currencyOptions, + }, + { + key: 'bookingAmount', + label: 'Buchungsbetrag', + type: 'number', + required: true, + }, + { + key: 'originalCurrency', + label: 'Originalwährung', + type: 'enum', + required: true, + options: currencyOptions, + }, + { + key: 'originalAmount', + label: 'Originalbetrag', + type: 'number', + required: true, + helpText: 'Betrag in Originalwährung (keine automatische Umrechnung)', + }, + { + key: 'vatPercentage', + label: 'MwSt %', + type: 'number', + helpText: 'MwSt-Satz in Prozent (z.B. 8.1)', + }, + { + key: 'vatAmount', + label: 'MwSt Betrag', + type: 'number', + helpText: 'Wird automatisch berechnet (kann manuell überschrieben werden)', + }, + ], [contractOptions]); + + if (loading || optionsLoading) { return
Lade Positionen...
; } @@ -31,6 +135,62 @@ export const TrusteePositionsView: React.FC = () => { } }; + const onEdit = async (pos: TrusteePosition) => { + setEditingPosition(pos); + setFormError(null); + // Lade Contracts fĂĽr die Organisation + if (pos.organisationId) { + const contracts = await loadContractsForOrganisation(pos.organisationId); + setContractOptions(contracts); + } + setIsModalOpen(true); + }; + + const onCreate = () => { + setEditingPosition(null); + setFormError(null); + setContractOptions([]); + setIsModalOpen(true); + }; + + const onCloseModal = () => { + setIsModalOpen(false); + setEditingPosition(null); + setFormError(null); + setContractOptions([]); + }; + + const onSave = async (data: Partial) => { + setFormError(null); + + // MwSt automatisch berechnen wenn nicht gesetzt + const processedData = { ...data }; + if (processedData.bookingAmount && processedData.vatPercentage && !processedData.vatAmount) { + processedData.vatAmount = processedData.bookingAmount * (processedData.vatPercentage / 100); + } + + try { + if (editingPosition) { + const result = await handleUpdate(editingPosition.id, processedData); + if (!result.success) { + setFormError(result.error || 'Fehler beim Aktualisieren'); + return; + } + } else { + const result = await handleCreate(processedData); + if (!result.success) { + setFormError(result.error || 'Fehler beim Erstellen'); + return; + } + } + + onCloseModal(); + refetch(); + } catch (err: any) { + setFormError(err.message || 'Ein Fehler ist aufgetreten'); + } + }; + // Formatiere Betrag const formatAmount = (amount: number, currency: string) => { return new Intl.NumberFormat('de-CH', { @@ -39,12 +199,22 @@ export const TrusteePositionsView: React.FC = () => { }).format(amount); }; + // Formatiere Datum + const formatDate = (dateStr: string | null | undefined) => { + if (!dateStr) return '-'; + try { + return new Date(dateStr).toLocaleDateString('de-CH'); + } catch { + return dateStr; + } + }; + return (
{/* Toolbar */}
{canCreate && ( - )} @@ -62,21 +232,43 @@ export const TrusteePositionsView: React.FC = () => { - + - + + + + {positions.map((pos) => ( - - - + + + + + +
BeschreibungValuta FirmaBetragBeschreibungVertragBetragMwSt Aktionen
{pos.desc}{pos.company}{formatAmount(pos.bookingAmount, pos.bookingCurrency)}{formatDate(pos.valuta)}{pos.company || '-'} + {pos.desc || '-'} + {getLabelFast('contracts', pos.contractId)} + {formatAmount(pos.bookingAmount, pos.bookingCurrency)} + + {pos.vatPercentage > 0 ? ( + + {pos.vatPercentage}% + + ) : ( + - + )} + {canUpdate && ( - )} @@ -96,6 +288,36 @@ export const TrusteePositionsView: React.FC = () => {
)} + + {/* Create/Edit Modal */} + + {formError && ( +
+ {formError} +
+ )} + + initialData={editingPosition || { + bookingCurrency: 'CHF', + originalCurrency: 'CHF', + bookingAmount: 0, + originalAmount: 0, + vatPercentage: 0, + vatAmount: 0, + }} + fields={fields} + onSave={onSave} + onCancel={onCloseModal} + isSaving={creatingItem} + isEdit={!!editingPosition} + saveLabel={editingPosition ? 'Aktualisieren' : 'Erstellen'} + /> +
); }; diff --git a/src/pages/views/trustee/TrusteeRolesView.tsx b/src/pages/views/trustee/TrusteeRolesView.tsx index beb6079..70febec 100644 --- a/src/pages/views/trustee/TrusteeRolesView.tsx +++ b/src/pages/views/trustee/TrusteeRolesView.tsx @@ -1,19 +1,48 @@ /** * TrusteeRolesView * - * Rollen-Verwaltung für eine Trustee-Instanz + * Rollen-Verwaltung für eine Trustee-Instanz. + * Rollen definieren Berechtigungen (admin, operate, userreport). + * Hinweis: Nur SysAdmin kann Rollen verwalten. */ -import React from 'react'; -import { useTrusteeRoles, useTrusteeRoleOperations } from '../../../hooks/useTrustee'; +import React, { useState, useMemo } from 'react'; +import { useTrusteeRoles, useTrusteeRoleOperations, TrusteeRole } from '../../../hooks/useTrustee'; import { useTablePermission } from '../../../hooks/useInstancePermissions'; +import { Popup } from '../../../components/UiComponents/Popup/Popup'; +import { TrusteeEditForm, FieldConfig } from './components'; import styles from './TrusteeViews.module.css'; export const TrusteeRolesView: React.FC = () => { const { items: roles, loading, error, refetch } = useTrusteeRoles(); - const { handleDelete, deletingItems } = useTrusteeRoleOperations(); + const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteeRoleOperations(); const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeRole'); + // Modal State + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingRole, setEditingRole] = useState(null); + const [formError, setFormError] = useState(null); + + // Feld-Konfiguration für das Formular + const fields: FieldConfig[] = useMemo(() => [ + { + key: 'id', + label: 'Rollen-ID', + type: 'string', + required: true, + editable: !editingRole, // Nur bei Create editierbar + placeholder: 'z.B. admin, operate, userreport', + helpText: 'Eindeutige Rollen-ID (nicht änderbar nach Erstellung)', + }, + { + key: 'desc', + label: 'Beschreibung', + type: 'textarea', + required: true, + placeholder: 'Beschreibung der Rolle und ihrer Berechtigungen', + }, + ], [editingRole]); + if (loading) { return
Lade Rollen...
; } @@ -23,7 +52,7 @@ export const TrusteeRolesView: React.FC = () => { } const onDelete = async (roleId: string) => { - if (window.confirm('Rolle wirklich löschen?')) { + if (window.confirm('Rolle wirklich löschen? Dies ist nur möglich, wenn die Rolle nicht in Verwendung ist.')) { const success = await handleDelete(roleId); if (success) { refetch(); @@ -31,12 +60,55 @@ export const TrusteeRolesView: React.FC = () => { } }; + const onEdit = (role: TrusteeRole) => { + setEditingRole(role); + setFormError(null); + setIsModalOpen(true); + }; + + const onCreate = () => { + setEditingRole(null); + setFormError(null); + setIsModalOpen(true); + }; + + const onCloseModal = () => { + setIsModalOpen(false); + setEditingRole(null); + setFormError(null); + }; + + const onSave = async (data: Partial) => { + setFormError(null); + + try { + if (editingRole) { + const result = await handleUpdate(editingRole.id, data); + if (!result.success) { + setFormError(result.error || 'Fehler beim Aktualisieren'); + return; + } + } else { + const result = await handleCreate(data); + if (!result.success) { + setFormError(result.error || 'Fehler beim Erstellen'); + return; + } + } + + onCloseModal(); + refetch(); + } catch (err: any) { + setFormError(err.message || 'Ein Fehler ist aufgetreten'); + } + }; + return (
{/* Toolbar */}
{canCreate && ( - )} @@ -45,6 +117,11 @@ export const TrusteeRolesView: React.FC = () => {
+ {/* Info */} +
+ Rollen definieren Berechtigungen fĂĽr Trustee-Zugriffe. Standard-Rollen: admin, operate, userreport. +
+ {/* Tabelle */} {roles.length === 0 ? (
@@ -62,11 +139,15 @@ export const TrusteeRolesView: React.FC = () => { {roles.map((role) => ( - {role.id} + {role.id} {role.desc} {canUpdate && ( - )} @@ -86,6 +167,29 @@ export const TrusteeRolesView: React.FC = () => { )} + + {/* Create/Edit Modal */} + + {formError && ( +
+ {formError} +
+ )} + + initialData={editingRole || {}} + fields={fields} + onSave={onSave} + onCancel={onCloseModal} + isSaving={creatingItem} + isEdit={!!editingRole} + saveLabel={editingRole ? 'Aktualisieren' : 'Erstellen'} + /> +
); }; diff --git a/src/pages/views/trustee/TrusteeViews.module.css b/src/pages/views/trustee/TrusteeViews.module.css index 17541ce..394f6ea 100644 --- a/src/pages/views/trustee/TrusteeViews.module.css +++ b/src/pages/views/trustee/TrusteeViews.module.css @@ -310,3 +310,177 @@ :global(.dark-theme) .infoLabel { color: var(--text-tertiary-dark, #888); } + +/* Modal */ +.modalOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease-out; +} + +.modal { + background: var(--bg-primary, #ffffff); + border-radius: 12px; + padding: 1.5rem; + min-width: 400px; + max-width: 90vw; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + animation: slideIn 0.2s ease-out; +} + +.modal h3 { + margin: 0 0 1rem; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Utility Classes */ +.muted { + color: var(--text-tertiary, #888); + font-style: italic; +} + +.monospace { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; +} + +.truncate { + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.alignRight { + text-align: right; +} + +.alignCenter { + text-align: center; +} + +/* Form Styles (für Phase 3) */ +.form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.formField { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.formField label { + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary, #666); +} + +.formField input, +.formField select, +.formField textarea { + padding: 0.625rem 0.75rem; + border: 1px solid var(--border-color, #d0d0d0); + border-radius: 6px; + font-size: 0.9375rem; + color: var(--text-primary, #1a1a1a); + background: var(--bg-primary, #ffffff); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.formField input:focus, +.formField select:focus, +.formField textarea:focus { + outline: none; + border-color: var(--primary-color, #2563eb); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.formField input:disabled, +.formField select:disabled { + background: var(--surface-color, #f5f5f5); + cursor: not-allowed; +} + +.formActions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color, #e0e0e0); +} + +.formError { + padding: 0.75rem; + background: var(--error-light, #fee2e2); + border: 1px solid var(--error-color, #dc2626); + border-radius: 6px; + color: var(--error-color, #dc2626); + font-size: 0.875rem; +} + +/* Dark Theme - Modal */ +:global(.dark-theme) .modal { + background: var(--surface-dark, #1a1a1a); + border: 1px solid var(--border-dark, #333); +} + +:global(.dark-theme) .modal h3 { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .muted { + color: var(--text-tertiary-dark, #666); +} + +:global(.dark-theme) .formField label { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .formField input, +:global(.dark-theme) .formField select, +:global(.dark-theme) .formField textarea { + background: var(--surface-dark, #2a2a2a); + border-color: var(--border-dark, #444); + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .formField input:disabled, +:global(.dark-theme) .formField select:disabled { + background: var(--surface-dark, #1a1a1a); +} + +:global(.dark-theme) .formActions { + border-top-color: var(--border-dark, #333); +} diff --git a/src/pages/views/trustee/components/TrusteeEditForm.tsx b/src/pages/views/trustee/components/TrusteeEditForm.tsx new file mode 100644 index 0000000..b1b119f --- /dev/null +++ b/src/pages/views/trustee/components/TrusteeEditForm.tsx @@ -0,0 +1,329 @@ +/** + * TrusteeEditForm + * + * Generisches Formular für Create/Edit von Trustee-Entities. + * Verwendet Feld-Definitionen aus Backend-Attributen oder manuelle Konfiguration. + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { useTrusteeOptions, TrusteeOption, TrusteeOptionEntity } from '../../../../hooks/useTrusteeOptions'; +import styles from '../TrusteeViews.module.css'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface FieldConfig { + key: string; + label: string; + type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'number' | 'readonly'; + editable?: boolean; + required?: boolean; + options?: Array<{ value: string | number; label: string }>; + optionsReference?: string; // z.B. 'organisations', 'roles', 'contracts' + dependsOn?: string; // Feld-Key, von dem dieses Feld abhängt + placeholder?: string; + helpText?: string; +} + +export interface TrusteeEditFormProps> { + /** Aktuelle Daten (leer für Create) */ + initialData: Partial; + /** Feld-Konfigurationen */ + fields: FieldConfig[]; + /** Callback beim Speichern */ + onSave: (data: T) => Promise; + /** Callback beim Abbrechen */ + onCancel: () => void; + /** Speichern-Button Text */ + saveLabel?: string; + /** Abbrechen-Button Text */ + cancelLabel?: string; + /** Ist das Formular gerade am Speichern? */ + isSaving?: boolean; + /** Validierungs-Funktion */ + validate?: (data: Partial) => Record | null; + /** Ist es ein Edit (vs Create)? */ + isEdit?: boolean; +} + +// ============================================================================ +// COMPONENT +// ============================================================================ + +export function TrusteeEditForm>({ + initialData, + fields, + onSave, + onCancel, + saveLabel = 'Speichern', + cancelLabel = 'Abbrechen', + isSaving = false, + validate, + isEdit = false, +}: TrusteeEditFormProps) { + // Form State + const [formData, setFormData] = useState>(initialData); + const [errors, setErrors] = useState>({}); + const [touched, setTouched] = useState>(new Set()); + + // Options für Dropdowns + const { loadOptions, getOptions, loadContractsForOrganisation } = useTrusteeOptions(); + const [dynamicOptions, setDynamicOptions] = useState>({}); + const [loadingOptions, setLoadingOptions] = useState>(new Set()); + + // Reset form when initialData changes + useEffect(() => { + setFormData(initialData); + setErrors({}); + setTouched(new Set()); + }, [initialData]); + + // Lade Options für alle optionsReference-Felder + useEffect(() => { + const optionEntities = fields + .filter(f => f.optionsReference && ['organisations', 'roles', 'contracts', 'users', 'documents', 'positions'].includes(f.optionsReference)) + .map(f => f.optionsReference as TrusteeOptionEntity); + + const uniqueEntities = [...new Set(optionEntities)]; + if (uniqueEntities.length > 0) { + loadOptions(uniqueEntities); + } + }, [fields, loadOptions]); + + // Feld-Wert ändern + const handleChange = useCallback(async (fieldKey: string, value: any) => { + setFormData(prev => ({ ...prev, [fieldKey]: value })); + setTouched(prev => new Set(prev).add(fieldKey)); + + // Dynamische Abhängigkeiten behandeln + const dependentFields = fields.filter(f => f.dependsOn === fieldKey); + + for (const depField of dependentFields) { + // Reset dependent field value + setFormData(prev => ({ ...prev, [depField.key]: '' })); + + // Lade neue Options wenn es ein Contract-Dropdown ist, das von Organisation abhängt + if (depField.optionsReference === 'contracts' && fieldKey === 'organisationId' && value) { + setLoadingOptions(prev => new Set(prev).add(depField.key)); + try { + const contractOptions = await loadContractsForOrganisation(value); + setDynamicOptions(prev => ({ ...prev, [depField.key]: contractOptions })); + } finally { + setLoadingOptions(prev => { + const newSet = new Set(prev); + newSet.delete(depField.key); + return newSet; + }); + } + } + } + }, [fields, loadContractsForOrganisation]); + + // Validierung + const validateForm = useCallback((): boolean => { + const newErrors: Record = {}; + + // Required-Felder prüfen + fields.forEach(field => { + if (field.required && field.editable !== false) { + const value = formData[field.key]; + if (value === undefined || value === null || value === '') { + newErrors[field.key] = `${field.label} ist erforderlich`; + } + } + }); + + // Custom Validierung + if (validate) { + const customErrors = validate(formData); + if (customErrors) { + Object.assign(newErrors, customErrors); + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }, [fields, formData, validate]); + + // Speichern + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Alle Felder als touched markieren + setTouched(new Set(fields.map(f => f.key))); + + if (!validateForm()) { + return; + } + + try { + await onSave(formData as T); + } catch (err: any) { + setErrors({ _form: err.message || 'Fehler beim Speichern' }); + } + }; + + // Options für ein Feld holen + const getFieldOptions = (field: FieldConfig): TrusteeOption[] => { + // Statische Options + if (field.options) { + return field.options.map(o => ({ + value: String(o.value), + label: o.label + })); + } + + // Dynamische Options (z.B. nach Organisation gefilterte Contracts) + if (dynamicOptions[field.key]) { + return dynamicOptions[field.key]; + } + + // Options aus useTrusteeOptions + if (field.optionsReference) { + return getOptions(field.optionsReference as TrusteeOptionEntity); + } + + return []; + }; + + // Feld rendern + const renderField = (field: FieldConfig) => { + const value = formData[field.key] ?? ''; + const error = touched.has(field.key) ? errors[field.key] : undefined; + const isReadonly = field.editable === false || (isEdit && field.key === 'id'); + const isLoading = loadingOptions.has(field.key); + + // Prüfe ob abhängiges Feld disabled sein soll + const isDependentDisabled = field.dependsOn && !formData[field.dependsOn]; + + return ( +
+ + + {field.type === 'boolean' ? ( + + ) : field.type === 'enum' ? ( + + ) : field.type === 'textarea' ? ( +