refactored pages ui access with saas mandates

This commit is contained in:
ValueOn AG 2026-01-23 21:05:36 +01:00
parent b207c0cc5b
commit dc4b475728
51 changed files with 7000 additions and 118 deletions

View file

@ -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() {
<LanguageProvider>
<AuthProvider>
<ToastProvider>
<WorkflowSelectionProvider>
<FileProvider>
<Router>
<Routes>
{/* ================================================== */}
@ -91,6 +104,31 @@ function App() {
{/* System-Seiten (ohne Instanz-Kontext) */}
<Route path="settings" element={<SettingsPage />} />
{/* ============================================== */}
{/* WORKFLOWS ROUTES (global) */}
{/* ============================================== */}
<Route path="workflows">
<Route path="playground" element={<PlaygroundPage />} />
<Route path="list" element={<WorkflowsPage />} />
<Route path="automations" element={<AutomationsPage />} />
</Route>
{/* ============================================== */}
{/* BASISDATEN ROUTES (global) */}
{/* ============================================== */}
<Route path="basedata">
<Route path="prompts" element={<PromptsPage />} />
<Route path="files" element={<FilesPage />} />
<Route path="connections" element={<ConnectionsPage />} />
</Route>
{/* ============================================== */}
{/* MIGRATE TO FEATURES (temporary) */}
{/* ============================================== */}
<Route path="chatbot" element={<ChatbotPage />} />
<Route path="pek" element={<PekPage />} />
<Route path="speech" element={<SpeechPage />} />
{/* ============================================== */}
{/* FEATURE-INSTANZ ROUTES */}
{/* /mandates/:mandateId/:featureCode/:instanceId */}
@ -141,6 +179,8 @@ function App() {
} />
</Routes>
</Router>
</FileProvider>
</WorkflowSelectionProvider>
</ToastProvider>
</AuthProvider>
</LanguageProvider>

238
src/api/automationApi.ts Normal file
View file

@ -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<string, string>;
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<string, any>;
}
export interface CreateAutomationRequest {
label: string;
template: string;
placeholders?: Record<string, string>;
schedule?: string;
active?: boolean;
mandateId?: string;
}
export interface UpdateAutomationRequest {
label?: string;
template?: string;
placeholders?: Record<string, string>;
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<any>) => Promise<any>;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Fetch all automations for the current mandate
* Endpoint: GET /api/automations
*/
export async function fetchAutomations(request: ApiRequestFunction): Promise<Automation[]> {
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<Automation> {
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<Automation> {
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<Automation> {
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<void> {
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<ExecuteAutomationResponse> {
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<AutomationTemplate[]> {
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<any[]> {
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 [];
}

View file

@ -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: <FaPlay />,
path: '/workflows/playground',
},
{
id: 'workflows-list',
label: 'Workflows',
icon: <FaListAlt />,
path: '/workflows/list',
},
{
id: 'workflows-automations',
label: 'Automations',
icon: <FaCogs />,
path: '/workflows/automations',
},
],
});
// Basisdaten section (global pages)
items.push({
type: 'section',
title: 'BASISDATEN',
children: [
{
id: 'basedata-prompts',
label: 'Prompts',
icon: <FaLightbulb />,
path: '/basedata/prompts',
},
{
id: 'basedata-files',
label: 'Files',
icon: <FaRegFileAlt />,
path: '/basedata/files',
},
{
id: 'basedata-connections',
label: 'Connections',
icon: <FaLink />,
path: '/basedata/connections',
},
],
});
// Migrate to Feature Instances section (temporary)
items.push({
type: 'section',
title: 'MIGRATE TO FEATURES',
children: [
{
id: 'migrate-chatbot',
label: 'Chatbot',
icon: <FaComments />,
path: '/chatbot',
},
{
id: 'migrate-pek',
label: 'PEK',
icon: <FaChartBar />,
path: '/pek',
},
{
id: 'migrate-speech',
label: 'Speech',
icon: <FaMicrophone />,
path: '/speech',
},
],
});
// Separator
items.push({ type: 'separator' });

View file

@ -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<RbacExportImportProps> = ({
exporting,
importing,
error,
lastExport,
lastImportResult,
exportMandateRbac,
exportGlobalRbac,

View file

@ -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<string, {
icon: FaHome,
defaultOrder: 1
},
'trustee': {
icon: FaBriefcase,
'workflows': {
icon: FaProjectDiagram,
defaultOrder: 2
},
'administration': {
icon: RiFolderSettingsFill,
'trustee': {
icon: FaBriefcase,
defaultOrder: 3
},
'basedata': {
icon: RiFolderSettingsFill,
defaultOrder: 4
},
'admin': {
icon: FaHatWizard,
defaultOrder: 4
defaultOrder: 5
}
};

View file

@ -0,0 +1,282 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../pageInterface';
import { FaCog, FaPlus } from 'react-icons/fa';
import { useAutomations, useAutomationOperations } from '../../../../hooks/useAutomations';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
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<string, string> = {
'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');
}
};

View file

@ -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,

View file

@ -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: [

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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';

399
src/hooks/useAutomations.ts Normal file
View file

@ -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<Automation[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [pagination, setPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, Automation[]>();
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<Automation>) => {
setAutomations(prev =>
prev.map(a => a.id === automationId ? { ...a, ...updateData } : a)
);
};
// Fetch a single automation by ID
const fetchAutomationById = useCallback(async (automationId: string): Promise<Automation | null> => {
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<Set<string>>(new Set());
const [creatingAutomation, setCreatingAutomation] = useState(false);
const [executingAutomations, setExecutingAutomations] = useState<Set<string>>(new Set());
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
// Create a new automation
const handleAutomationCreate = useCallback(async (data: CreateAutomationRequest): Promise<Automation | null> => {
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<boolean> => {
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<boolean> => {
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<any> => {
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<boolean> => {
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<Automation>,
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<AutomationTemplate[]> => {
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
};
}

View file

@ -548,9 +548,48 @@ export function useConnections() {
fetchConnections();
}, [fetchConnections]);
// Optimistically update a connection in local state
const updateOptimistically = useCallback((connectionId: string, updateData: Partial<Connection>) => {
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<Connection>, 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<Connection | null> => {
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
};
}

View file

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

View file

@ -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<HTMLDivElement>;
}
export function useResizablePanels({
storageKey,
defaultLeftWidth,
minLeftWidth,
maxLeftWidth,
direction = 'horizontal',
}: UseResizablePanelsOptions): UseResizablePanelsReturn {
// Initialize from LocalStorage or default
const [leftWidth, setLeftWidthState] = useState<number>(() => {
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<HTMLDivElement>(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;

View file

@ -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<Partial<TrusteeOptionsMap>>({
users: [],
organisations: [],
roles: [],
contracts: [],
documents: [],
positions: [],
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadedEntities, setLoadedEntities] = useState<Set<string>>(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<void> => {
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<TrusteeOptionsMap> = {};
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<TrusteeOption[]> => {
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<string, string> => {
const map = new Map<string, string>();
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;

View file

@ -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<UserWorkflow>, 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
};

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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<InvitationValidation | null>(null);
@ -185,8 +187,8 @@ export const InvitePage: React.FC = () => {
<div className={styles.inviteInfo}>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Angemeldet als:</span>
<span className={styles.infoValue}>{user?.email || user?.username}</span>
<span className={styles.infoLabel}>Status:</span>
<span className={styles.infoValue}>Angemeldet</span>
</div>
{validation.roleLabels && validation.roleLabels.length > 0 && (
<div className={styles.infoRow}>

View file

@ -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<Invitation | null>(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<AttributeDefinition[]>([]);
@ -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]);

View file

@ -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);
}

View file

@ -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<Connection | null>(null);
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(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<Connection>) => {
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 (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Verbindungen: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Verbindungen</h1>
<p className={styles.pageSubtitle}>OAuth-Verbindungen verwalten</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<>
<button
className={styles.secondaryButton}
onClick={handleCreateGoogle}
disabled={isConnecting}
>
<FaGoogle /> Google
</button>
<button
className={styles.primaryButton}
onClick={handleCreateMicrosoft}
disabled={isConnecting}
>
<FaMicrosoft /> Microsoft
</button>
</>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!connections || connections.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Verbindungen...</span>
</div>
) : !connections || connections.length === 0 ? (
<div className={styles.emptyState}>
<FaPlug className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Verbindungen vorhanden</h3>
<p className={styles.emptyDescription}>
Verbinden Sie Ihr Google- oder Microsoft-Konto, um loszulegen.
</p>
{canCreate && (
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
<button
className={styles.secondaryButton}
onClick={handleCreateGoogle}
disabled={isConnecting}
>
<FaGoogle /> Mit Google verbinden
</button>
<button
className={styles.primaryButton}
onClick={handleCreateMicrosoft}
disabled={isConnecting}
>
<FaMicrosoft /> Mit Microsoft verbinden
</button>
</div>
)}
</div>
) : (
<FormGeneratorTable
data={connections}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: Connection) => deletingConnections.has(row.id),
}] : []),
]}
customActions={[
{
id: 'connect',
icon: <FaLink />,
onClick: handleConnect,
title: 'Verbinden',
visible: (row: Connection) => row.status !== 'active',
loading: () => isConnecting,
},
{
id: 'refresh',
icon: <FaRedo />,
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"
/>
)}
</div>
{/* Edit Modal */}
{editingConnection && (
<div className={styles.modalOverlay} onClick={() => setEditingConnection(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Verbindung bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => setEditingConnection(null)}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingConnection}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingConnection(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default ConnectionsPage;

View file

@ -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<HTMLInputElement>(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<UserFile | null>(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<UserFile>) => {
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<HTMLInputElement>) => {
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 (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Dateien: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Dateien</h1>
<p className={styles.pageSubtitle}>Dateiverwaltung</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={handleUploadClick}
disabled={uploadingFile}
>
<FaUpload /> {uploadingFile ? 'Uploading...' : 'Datei hochladen'}
</button>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!files || files.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Dateien...</span>
</div>
) : !files || files.length === 0 ? (
<div className={styles.emptyState}>
<FaFolder className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Dateien vorhanden</h3>
<p className={styles.emptyDescription}>
Laden Sie eine Datei hoch, um loszulegen.
</p>
{canCreate && (
<button
className={styles.primaryButton}
onClick={handleUploadClick}
disabled={uploadingFile}
>
<FaUpload /> Erste Datei hochladen
</button>
)}
</div>
) : (
<FormGeneratorTable
data={files}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: UserFile) => deletingFiles.has(row.id),
}] : []),
]}
customActions={[
{
id: 'download',
icon: <FaDownload />,
onClick: handleDownload,
title: 'Herunterladen',
loading: (row: UserFile) => downloadingFiles.has(row.id),
},
{
id: 'preview',
icon: <FaEye />,
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"
/>
)}
</div>
{/* Edit Modal */}
{editingFile && (
<div className={styles.modalOverlay} onClick={() => setEditingFile(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Datei bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => setEditingFile(null)}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingFile}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingFile(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default FilesPage;

View file

@ -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<Prompt | null>(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<Prompt>) => {
const result = await handlePromptCreate({
name: data.name || '',
content: data.content || ''
});
if (result?.success) {
setShowCreateModal(false);
refetch();
}
};
// Handle edit submit
const handleEditSubmit = async (data: Partial<Prompt>) => {
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 (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Prompts: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Prompts</h1>
<p className={styles.pageSubtitle}>Prompt-Templates verwalten</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Neuer Prompt
</button>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!prompts || prompts.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Prompts...</span>
</div>
) : !prompts || prompts.length === 0 ? (
<div className={styles.emptyState}>
<FaFileAlt className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Prompts vorhanden</h3>
<p className={styles.emptyDescription}>
Erstellen Sie einen neuen Prompt, um loszulegen.
</p>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Ersten Prompt erstellen
</button>
)}
</div>
) : (
<FormGeneratorTable
data={prompts}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: Prompt) => deletingPrompts.has(row.id),
}] : []),
]}
onDelete={handleDelete}
hookData={{
refetch,
permissions,
pagination,
handleDelete: handlePromptDelete,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Prompts gefunden"
/>
)}
</div>
{/* Create Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neuer Prompt</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
submitButtonText="Erstellen"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingPrompt && (
<div className={styles.modalOverlay} onClick={() => setEditingPrompt(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Prompt bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => setEditingPrompt(null)}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingPrompt}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingPrompt(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default PromptsPage;

View file

@ -0,0 +1,3 @@
export { PromptsPage } from './PromptsPage';
export { FilesPage } from './FilesPage';
export { ConnectionsPage } from './ConnectionsPage';

View file

@ -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<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(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 (
<div className={styles.page}>
<header className={styles.header}>
<h1>Chatbot</h1>
<p className={styles.subtitle}>
<span className={styles.migrateTag}>MIGRATE TO FEATURE</span>
Einfache Chat-Oberfläche
</p>
</header>
<main className={styles.chatContainer}>
<div className={styles.messagesArea}>
{messages.length === 0 ? (
<div className={styles.emptyChat}>
<p>Noch keine Nachrichten. Starten Sie eine Konversation!</p>
</div>
) : (
messages.map(message => (
<div
key={message.id}
className={`${styles.message} ${styles[message.role]}`}
>
<div className={styles.messageContent}>
{message.content}
</div>
<div className={styles.messageTime}>
{message.timestamp.toLocaleTimeString('de-DE')}
</div>
</div>
))
)}
{isLoading && (
<div className={`${styles.message} ${styles.assistant}`}>
<div className={styles.typing}>
<span></span><span></span><span></span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSubmit} className={styles.inputArea}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Nachricht eingeben..."
disabled={isLoading}
className={styles.chatInput}
/>
<button
type="submit"
disabled={!inputValue.trim() || isLoading}
className={styles.sendButton}
>
Senden
</button>
</form>
</main>
</div>
);
};
export default ChatbotPage;

View file

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

View file

@ -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 (
<div className={styles.page}>
<header className={styles.header}>
<h1>PEK</h1>
<p className={styles.subtitle}>
<span className={styles.migrateTag}>MIGRATE TO FEATURE</span>
Projekt-Entwicklungs-Koordination
</p>
</header>
<main className={styles.content}>
<div className={styles.placeholder}>
<div className={styles.placeholderIcon}>📊</div>
<h2>PEK-Modul</h2>
<p>
Dieses Modul wird zu einer Feature-Instanz migriert.
</p>
<p className={styles.hint}>
Nach der Migration wird PEK als Feature pro Mandant verfügbar sein,
mit instanz-spezifischen Daten und Berechtigungen.
</p>
</div>
</main>
</div>
);
};
export default PekPage;

View file

@ -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 (
<div className={styles.page}>
<header className={styles.header}>
<h1>Speech</h1>
<p className={styles.subtitle}>
<span className={styles.migrateTag}>MIGRATE TO FEATURE</span>
Spracherkennung und Transkription
</p>
</header>
<main className={styles.content}>
<div className={styles.placeholder}>
<div className={styles.placeholderIcon}>🎤</div>
<h2>Speech-Modul</h2>
<p>
Dieses Modul wird zu einer Feature-Instanz migriert.
</p>
<p className={styles.hint}>
Nach der Migration wird Speech als Feature pro Mandant verfügbar sein,
mit instanz-spezifischen Transkriptionen und Einstellungen.
</p>
</div>
</main>
</div>
);
};
export default SpeechPage;

View file

@ -0,0 +1,3 @@
export { ChatbotPage } from './ChatbotPage';
export { PekPage } from './PekPage';
export { SpeechPage } from './SpeechPage';

View file

@ -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<TrusteeAccess | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [contractOptions, setContractOptions] = useState<Array<{value: string, label: string}>>([]);
// 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 <div className={styles.loading}>Lade Zugriffe...</div>;
}
@ -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<TrusteeAccess>) => {
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 (
<div className={styles.listView}>
{/* Toolbar */}
<div className={styles.toolbar}>
{canCreate && (
<button className={styles.primaryButton}>
<button className={styles.primaryButton} onClick={onCreate}>
+ Neuer Zugriff
</button>
)}
@ -54,7 +167,7 @@ export const TrusteeAccessView: React.FC = () => {
<table className={styles.dataTable}>
<thead>
<tr>
<th>User</th>
<th>Benutzer</th>
<th>Organisation</th>
<th>Rolle</th>
<th>Vertrag</th>
@ -64,13 +177,27 @@ export const TrusteeAccessView: React.FC = () => {
<tbody>
{accessList.map((access) => (
<tr key={access.id}>
<td>{access.userId}</td>
<td>{access.organisationId}</td>
<td>{access.roleId}</td>
<td>{access.contractId || '-'}</td>
<td>{getLabelFast('users', access.userId)}</td>
<td>{getLabelFast('organisations', access.organisationId)}</td>
<td>
<span className={styles.badge}>
{getLabelFast('roles', access.roleId)}
</span>
</td>
<td>
{access.contractId ? (
getLabelFast('contracts', access.contractId)
) : (
<span className={styles.muted}>Alle</span>
)}
</td>
<td className={styles.actions}>
{canUpdate && (
<button className={styles.iconButton} title="Bearbeiten">
<button
className={styles.iconButton}
title="Bearbeiten"
onClick={() => onEdit(access)}
>
</button>
)}
@ -90,6 +217,29 @@ export const TrusteeAccessView: React.FC = () => {
</tbody>
</table>
)}
{/* Create/Edit Modal */}
<Popup
isOpen={isModalOpen}
title={editingAccess ? 'Zugriff bearbeiten' : 'Neuer Zugriff'}
onClose={onCloseModal}
size="medium"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div>
)}
<TrusteeEditForm<TrusteeAccess>
initialData={editingAccess || {}}
fields={fields}
onSave={onSave}
onCancel={onCloseModal}
isSaving={creatingItem}
isEdit={!!editingAccess}
saveLabel={editingAccess ? 'Aktualisieren' : 'Erstellen'}
/>
</Popup>
</div>
);
};

View file

@ -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<TrusteeContract | null>(null);
const [formError, setFormError] = useState<string | null>(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 <div className={styles.loading}>Lade Verträge...</div>;
}
@ -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<TrusteeContract>) => {
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 (
<div className={styles.listView}>
{/* Toolbar */}
<div className={styles.toolbar}>
{canCreate && (
<button className={styles.primaryButton}>
<button className={styles.primaryButton} onClick={onCreate}>
+ Neuer Vertrag
</button>
)}
@ -54,7 +137,7 @@ export const TrusteeContractsView: React.FC = () => {
<table className={styles.dataTable}>
<thead>
<tr>
<th>Label</th>
<th>Bezeichnung</th>
<th>Organisation</th>
<th>Status</th>
<th>Aktionen</th>
@ -64,7 +147,7 @@ export const TrusteeContractsView: React.FC = () => {
{contracts.map((contract) => (
<tr key={contract.id}>
<td>{contract.label}</td>
<td>{contract.organisationId}</td>
<td>{getLabelFast('organisations', contract.organisationId)}</td>
<td>
<span className={`${styles.badge} ${contract.enabled ? styles.badgeSuccess : styles.badgeWarning}`}>
{contract.enabled ? 'Aktiv' : 'Inaktiv'}
@ -72,7 +155,11 @@ export const TrusteeContractsView: React.FC = () => {
</td>
<td className={styles.actions}>
{canUpdate && (
<button className={styles.iconButton} title="Bearbeiten">
<button
className={styles.iconButton}
title="Bearbeiten"
onClick={() => onEdit(contract)}
>
</button>
)}
@ -92,6 +179,29 @@ export const TrusteeContractsView: React.FC = () => {
</tbody>
</table>
)}
{/* Create/Edit Modal */}
<Popup
isOpen={isModalOpen}
title={editingContract ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
onClose={onCloseModal}
size="medium"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div>
)}
<TrusteeEditForm<TrusteeContract>
initialData={editingContract || { enabled: true }}
fields={fields}
onSave={onSave}
onCancel={onCloseModal}
isSaving={creatingItem}
isEdit={!!editingContract}
saveLabel={editingContract ? 'Aktualisieren' : 'Erstellen'}
/>
</Popup>
</div>
);
};

View file

@ -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<TrusteeDocument | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [downloading, setDownloading] = useState<string | null>(null);
const [contractOptions, setContractOptions] = useState<Array<{value: string, label: string}>>([]);
// 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 <div className={styles.loading}>Lade Dokumente...</div>;
}
@ -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<TrusteeDocument>) => {
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 (
<div className={styles.listView}>
{/* Toolbar */}
<div className={styles.toolbar}>
{canCreate && (
<button className={styles.primaryButton}>
<button className={styles.primaryButton} onClick={onCreate}>
+ Neues Dokument
</button>
)}
@ -64,14 +206,27 @@ export const TrusteeDocumentsView: React.FC = () => {
{documents.map((doc) => (
<tr key={doc.id}>
<td>{doc.documentName}</td>
<td>{doc.documentMimeType}</td>
<td>{doc.contractId}</td>
<td>
<span className={styles.badge}>
{getMimeTypeLabel(doc.documentMimeType)}
</span>
</td>
<td>{getLabelFast('contracts', doc.contractId)}</td>
<td className={styles.actions}>
<button className={styles.iconButton} title="Herunterladen">
<button
className={styles.iconButton}
title="Herunterladen"
onClick={() => onDownload(doc)}
disabled={downloading === doc.id}
>
{downloading === doc.id ? '...' : '⬇️'}
</button>
{canUpdate && (
<button className={styles.iconButton} title="Bearbeiten">
<button
className={styles.iconButton}
title="Bearbeiten"
onClick={() => onEdit(doc)}
>
</button>
)}
@ -91,6 +246,29 @@ export const TrusteeDocumentsView: React.FC = () => {
</tbody>
</table>
)}
{/* Create/Edit Modal */}
<Popup
isOpen={isModalOpen}
title={editingDoc ? 'Dokument bearbeiten' : 'Neues Dokument'}
onClose={onCloseModal}
size="medium"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div>
)}
<TrusteeEditForm<TrusteeDocument>
initialData={editingDoc || { documentMimeType: 'application/pdf' }}
fields={fields}
onSave={onSave}
onCancel={onCloseModal}
isSaving={creatingItem}
isEdit={!!editingDoc}
saveLabel={editingDoc ? 'Aktualisieren' : 'Erstellen'}
/>
</Popup>
</div>
);
};

View file

@ -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<TrusteeOrganisation | null>(null);
const [formError, setFormError] = useState<string | null>(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 <div className={styles.loading}>Lade Organisationen...</div>;
}
@ -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<TrusteeOrganisation>) => {
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<TrusteeOrganisation>): Record<string, string> | null => {
const errors: Record<string, string> = {};
// 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 (
<div className={styles.listView}>
{/* Toolbar */}
<div className={styles.toolbar}>
{canCreate && (
<button className={styles.primaryButton}>
<button className={styles.primaryButton} onClick={onCreate}>
+ Neue Organisation
</button>
)}
@ -54,7 +149,8 @@ export const TrusteeOrganisationsView: React.FC = () => {
<table className={styles.dataTable}>
<thead>
<tr>
<th>Label</th>
<th>ID</th>
<th>Bezeichnung</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
@ -62,6 +158,7 @@ export const TrusteeOrganisationsView: React.FC = () => {
<tbody>
{organisations.map((org) => (
<tr key={org.id}>
<td className={styles.monospace}>{org.id}</td>
<td>{org.label}</td>
<td>
<span className={`${styles.badge} ${org.enabled ? styles.badgeSuccess : styles.badgeWarning}`}>
@ -70,7 +167,11 @@ export const TrusteeOrganisationsView: React.FC = () => {
</td>
<td className={styles.actions}>
{canUpdate && (
<button className={styles.iconButton} title="Bearbeiten">
<button
className={styles.iconButton}
title="Bearbeiten"
onClick={() => onEdit(org)}
>
</button>
)}
@ -90,6 +191,30 @@ export const TrusteeOrganisationsView: React.FC = () => {
</tbody>
</table>
)}
{/* Create/Edit Modal */}
<Popup
isOpen={isModalOpen}
title={editingOrg ? 'Organisation bearbeiten' : 'Neue Organisation'}
onClose={onCloseModal}
size="medium"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div>
)}
<TrusteeEditForm<TrusteeOrganisation>
initialData={editingOrg || { enabled: true }}
fields={fields}
onSave={onSave}
onCancel={onCloseModal}
isSaving={creatingItem}
validate={validateOrganisation}
isEdit={!!editingOrg}
saveLabel={editingOrg ? 'Aktualisieren' : 'Erstellen'}
/>
</Popup>
</div>
);
};

View file

@ -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<string | null>(null);
const [contractOptions, setContractOptions] = useState<Array<{value: string, label: string}>>([]);
// 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 <div className={styles.loading}>Lade Verknüpfungen...</div>;
}
if (error) {
return <div className={styles.error}>Fehler: {error}</div>;
}
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<TrusteePositionDocument>) => {
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<string, TrusteePositionDocument[]> = {};
links.forEach(link => {
if (!grouped[link.positionId]) {
grouped[link.positionId] = [];
}
grouped[link.positionId].push(link);
});
return grouped;
}, [links]);
return (
<div className={styles.listView}>
{/* Toolbar */}
<div className={styles.toolbar}>
{canCreate && (
<button className={styles.primaryButton} onClick={onCreate}>
+ Neue Verknüpfung
</button>
)}
<button className={styles.secondaryButton} onClick={() => refetch()}>
Aktualisieren
</button>
</div>
{/* Info */}
<div className={styles.muted} style={{ fontSize: '0.8125rem', padding: '0.5rem 0' }}>
Hier verknüpfen Sie Belege (Dokumente) mit Buchungspositionen.
</div>
{/* Tabelle */}
{links.length === 0 ? (
<div className={styles.emptyState}>
<p>Keine Verknüpfungen vorhanden.</p>
</div>
) : (
<table className={styles.dataTable}>
<thead>
<tr>
<th>Position</th>
<th>Dokument</th>
<th>Vertrag</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{links.map((link) => (
<tr key={link.id}>
<td>{getLabelFast('positions', link.positionId)}</td>
<td>{getLabelFast('documents', link.documentId)}</td>
<td>{getLabelFast('contracts', link.contractId)}</td>
<td className={styles.actions}>
{canDelete && (
<button
className={styles.iconButton}
title="Verknüpfung entfernen"
onClick={() => onDelete(link.id)}
disabled={deletingItems.has(link.id)}
>
{deletingItems.has(link.id) ? '...' : '🗑️'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Create Modal */}
<Popup
isOpen={isModalOpen}
title="Neue Verknüpfung erstellen"
onClose={onCloseModal}
size="medium"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div>
)}
<TrusteeEditForm<TrusteePositionDocument>
initialData={{}}
fields={fields}
onSave={onSave}
onCancel={onCloseModal}
isSaving={creatingItem}
isEdit={false}
saveLabel="Verknüpfung erstellen"
/>
</Popup>
</div>
);
};
export default TrusteePositionDocumentsView;

View file

@ -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<TrusteePosition | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [contractOptions, setContractOptions] = useState<Array<{value: string, label: string}>>([]);
// 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 <div className={styles.loading}>Lade Positionen...</div>;
}
@ -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<TrusteePosition>) => {
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 (
<div className={styles.listView}>
{/* Toolbar */}
<div className={styles.toolbar}>
{canCreate && (
<button className={styles.primaryButton}>
<button className={styles.primaryButton} onClick={onCreate}>
+ Neue Position
</button>
)}
@ -62,21 +232,43 @@ export const TrusteePositionsView: React.FC = () => {
<table className={styles.dataTable}>
<thead>
<tr>
<th>Beschreibung</th>
<th>Valuta</th>
<th>Firma</th>
<th>Betrag</th>
<th>Beschreibung</th>
<th>Vertrag</th>
<th className={styles.alignRight}>Betrag</th>
<th className={styles.alignRight}>MwSt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{positions.map((pos) => (
<tr key={pos.id}>
<td>{pos.desc}</td>
<td>{pos.company}</td>
<td>{formatAmount(pos.bookingAmount, pos.bookingCurrency)}</td>
<td>{formatDate(pos.valuta)}</td>
<td>{pos.company || '-'}</td>
<td className={styles.truncate} title={pos.desc}>
{pos.desc || '-'}
</td>
<td>{getLabelFast('contracts', pos.contractId)}</td>
<td className={styles.alignRight}>
{formatAmount(pos.bookingAmount, pos.bookingCurrency)}
</td>
<td className={styles.alignRight}>
{pos.vatPercentage > 0 ? (
<span title={formatAmount(pos.vatAmount, pos.bookingCurrency)}>
{pos.vatPercentage}%
</span>
) : (
<span className={styles.muted}>-</span>
)}
</td>
<td className={styles.actions}>
{canUpdate && (
<button className={styles.iconButton} title="Bearbeiten">
<button
className={styles.iconButton}
title="Bearbeiten"
onClick={() => onEdit(pos)}
>
</button>
)}
@ -96,6 +288,36 @@ export const TrusteePositionsView: React.FC = () => {
</tbody>
</table>
)}
{/* Create/Edit Modal */}
<Popup
isOpen={isModalOpen}
title={editingPosition ? 'Position bearbeiten' : 'Neue Position'}
onClose={onCloseModal}
size="large"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div>
)}
<TrusteeEditForm<TrusteePosition>
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'}
/>
</Popup>
</div>
);
};

View file

@ -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<TrusteeRole | null>(null);
const [formError, setFormError] = useState<string | null>(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 <div className={styles.loading}>Lade Rollen...</div>;
}
@ -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<TrusteeRole>) => {
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 (
<div className={styles.listView}>
{/* Toolbar */}
<div className={styles.toolbar}>
{canCreate && (
<button className={styles.primaryButton}>
<button className={styles.primaryButton} onClick={onCreate}>
+ Neue Rolle
</button>
)}
@ -45,6 +117,11 @@ export const TrusteeRolesView: React.FC = () => {
</button>
</div>
{/* Info */}
<div className={styles.muted} style={{ fontSize: '0.8125rem', padding: '0.5rem 0' }}>
Rollen definieren Berechtigungen für Trustee-Zugriffe. Standard-Rollen: admin, operate, userreport.
</div>
{/* Tabelle */}
{roles.length === 0 ? (
<div className={styles.emptyState}>
@ -62,11 +139,15 @@ export const TrusteeRolesView: React.FC = () => {
<tbody>
{roles.map((role) => (
<tr key={role.id}>
<td><code>{role.id}</code></td>
<td className={styles.monospace}>{role.id}</td>
<td>{role.desc}</td>
<td className={styles.actions}>
{canUpdate && (
<button className={styles.iconButton} title="Bearbeiten">
<button
className={styles.iconButton}
title="Bearbeiten"
onClick={() => onEdit(role)}
>
</button>
)}
@ -86,6 +167,29 @@ export const TrusteeRolesView: React.FC = () => {
</tbody>
</table>
)}
{/* Create/Edit Modal */}
<Popup
isOpen={isModalOpen}
title={editingRole ? 'Rolle bearbeiten' : 'Neue Rolle'}
onClose={onCloseModal}
size="medium"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div>
)}
<TrusteeEditForm<TrusteeRole>
initialData={editingRole || {}}
fields={fields}
onSave={onSave}
onCancel={onCloseModal}
isSaving={creatingItem}
isEdit={!!editingRole}
saveLabel={editingRole ? 'Aktualisieren' : 'Erstellen'}
/>
</Popup>
</div>
);
};

View file

@ -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);
}

View file

@ -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<T = Record<string, any>> {
/** Aktuelle Daten (leer für Create) */
initialData: Partial<T>;
/** Feld-Konfigurationen */
fields: FieldConfig[];
/** Callback beim Speichern */
onSave: (data: T) => Promise<void>;
/** 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<T>) => Record<string, string> | null;
/** Ist es ein Edit (vs Create)? */
isEdit?: boolean;
}
// ============================================================================
// COMPONENT
// ============================================================================
export function TrusteeEditForm<T extends Record<string, any>>({
initialData,
fields,
onSave,
onCancel,
saveLabel = 'Speichern',
cancelLabel = 'Abbrechen',
isSaving = false,
validate,
isEdit = false,
}: TrusteeEditFormProps<T>) {
// Form State
const [formData, setFormData] = useState<Partial<T>>(initialData);
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Set<string>>(new Set());
// Options für Dropdowns
const { loadOptions, getOptions, loadContractsForOrganisation } = useTrusteeOptions();
const [dynamicOptions, setDynamicOptions] = useState<Record<string, TrusteeOption[]>>({});
const [loadingOptions, setLoadingOptions] = useState<Set<string>>(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<string, string> = {};
// 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 (
<div key={field.key} className={styles.formField}>
<label htmlFor={field.key}>
{field.label}
{field.required && <span style={{ color: 'var(--error-color, #dc2626)' }}> *</span>}
</label>
{field.type === 'boolean' ? (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
id={field.key}
checked={Boolean(value)}
onChange={(e) => handleChange(field.key, e.target.checked)}
disabled={isReadonly || isSaving}
/>
{field.helpText || 'Aktiviert'}
</label>
) : field.type === 'enum' ? (
<select
id={field.key}
value={String(value)}
onChange={(e) => handleChange(field.key, e.target.value)}
disabled={isReadonly || isSaving || isLoading || isDependentDisabled}
>
<option value="">
{isLoading ? 'Lade...' : isDependentDisabled ? `Bitte ${fields.find(f => f.key === field.dependsOn)?.label} wählen` : '-- Auswählen --'}
</option>
{getFieldOptions(field).map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : field.type === 'textarea' ? (
<textarea
id={field.key}
value={String(value)}
onChange={(e) => handleChange(field.key, e.target.value)}
disabled={isReadonly || isSaving}
placeholder={field.placeholder}
rows={3}
/>
) : field.type === 'number' ? (
<input
type="number"
id={field.key}
value={value === '' ? '' : Number(value)}
onChange={(e) => handleChange(field.key, e.target.value === '' ? '' : Number(e.target.value))}
disabled={isReadonly || isSaving}
placeholder={field.placeholder}
step="any"
/>
) : field.type === 'date' ? (
<input
type="date"
id={field.key}
value={String(value)}
onChange={(e) => handleChange(field.key, e.target.value)}
disabled={isReadonly || isSaving}
/>
) : field.type === 'readonly' ? (
<input
type="text"
id={field.key}
value={String(value)}
disabled
style={{ background: 'var(--surface-color, #f5f5f5)' }}
/>
) : (
<input
type={field.type === 'email' ? 'email' : 'text'}
id={field.key}
value={String(value)}
onChange={(e) => handleChange(field.key, e.target.value)}
disabled={isReadonly || isSaving}
placeholder={field.placeholder}
/>
)}
{error && (
<span style={{ color: 'var(--error-color, #dc2626)', fontSize: '0.75rem' }}>
{error}
</span>
)}
{field.helpText && !error && (
<span style={{ color: 'var(--text-tertiary, #888)', fontSize: '0.75rem' }}>
{field.helpText}
</span>
)}
</div>
);
};
return (
<form className={styles.form} onSubmit={handleSubmit}>
{/* Form-Level Error */}
{errors._form && (
<div className={styles.formError}>
{errors._form}
</div>
)}
{/* Fields */}
{fields.filter(f => f.type !== 'readonly' || isEdit).map(renderField)}
{/* Actions */}
<div className={styles.formActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={onCancel}
disabled={isSaving}
>
{cancelLabel}
</button>
<button
type="submit"
className={styles.primaryButton}
disabled={isSaving}
>
{isSaving ? 'Speichern...' : saveLabel}
</button>
</div>
</form>
);
}
export default TrusteeEditForm;

View file

@ -0,0 +1,6 @@
/**
* Trustee Components
*/
export { TrusteeEditForm } from './TrusteeEditForm';
export type { FieldConfig, TrusteeEditFormProps } from './TrusteeEditForm';

View file

@ -7,5 +7,6 @@ export { TrusteeContractsView } from './TrusteeContractsView';
export { TrusteeOrganisationsView } from './TrusteeOrganisationsView';
export { TrusteeDocumentsView } from './TrusteeDocumentsView';
export { TrusteePositionsView } from './TrusteePositionsView';
export { TrusteePositionDocumentsView } from './TrusteePositionDocumentsView';
export { TrusteeRolesView } from './TrusteeRolesView';
export { TrusteeAccessView } from './TrusteeAccessView';

View file

@ -0,0 +1,334 @@
/**
* AutomationsPage
*
* Page for viewing and managing workflow automations using FormGeneratorTable.
* Follows the pattern established in AdminUsersPage/WorkflowsPage.
*/
import React, { useState, useMemo, useEffect } from 'react';
import { useAutomations, useAutomationOperations } from '../../hooks/useAutomations';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaRobot, FaPlay, FaPlus, FaToggleOn, FaToggleOff } from 'react-icons/fa';
import styles from '../admin/Admin.module.css';
interface Automation {
id: string;
label: string;
schedule?: string;
active: boolean;
status?: string;
template?: string;
placeholders?: any;
[key: string]: any;
}
export const AutomationsPage: React.FC = () => {
// Data hook
const {
data: automations,
attributes,
permissions,
pagination,
loading,
error,
refetch,
fetchAutomationById,
updateOptimistically,
} = useAutomations();
// Operations hook
const {
handleAutomationCreate,
handleAutomationUpdate,
handleAutomationDelete,
handleAutomationExecute,
handleAutomationToggleActive,
handleInlineUpdate,
deletingAutomations,
executingAutomations,
creatingAutomation,
} = useAutomationOperations();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingAutomation, setEditingAutomation] = useState<Automation | null>(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 (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
if (fullAutomation) {
setEditingAutomation(fullAutomation as Automation);
}
};
// Handle create submit
const handleCreateSubmit = async (data: Partial<Automation>) => {
const result = await handleAutomationCreate(data as any);
if (result) {
setShowCreateModal(false);
refetch();
}
};
// Handle edit submit
const handleEditSubmit = async (data: Partial<Automation>) => {
if (!editingAutomation) return;
const success = await handleAutomationUpdate(editingAutomation.id, data);
if (success) {
setEditingAutomation(null);
refetch();
}
};
// Handle delete single automation
const handleDelete = async (automation: Automation) => {
if (window.confirm(`Möchten Sie die Automatisierung "${automation.label}" wirklich löschen?`)) {
const success = await handleAutomationDelete(automation.id);
if (success) {
refetch();
}
}
};
// Handle execute automation
const handleExecute = async (automation: Automation) => {
try {
await handleAutomationExecute(automation.id);
// Show success feedback (could use toast)
console.log('Automation started:', automation.label);
} catch (err: any) {
console.error('Error executing automation:', err);
}
};
// Handle toggle active
const handleToggleActive = async (automation: Automation) => {
// Optimistic update
updateOptimistically(automation.id, { active: !automation.active });
const success = await handleAutomationToggleActive(automation.id, automation.active);
if (!success) {
// Revert on failure
updateOptimistically(automation.id, { active: automation.active });
}
};
// Form attributes for create/edit modal
const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', 'status'];
return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Automatisierungen: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Automatisierungen</h1>
<p className={styles.pageSubtitle}>Geplante und automatisierte Workflows</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Neue Automatisierung
</button>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!automations || automations.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Automatisierungen...</span>
</div>
) : !automations || automations.length === 0 ? (
<div className={styles.emptyState}>
<FaRobot className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Automatisierungen vorhanden</h3>
<p className={styles.emptyDescription}>
Erstellen Sie eine neue Automatisierung, um Workflows zeitgesteuert auszuführen.
</p>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Erste Automatisierung erstellen
</button>
)}
</div>
) : (
<FormGeneratorTable
data={automations}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: Automation) => deletingAutomations.has(row.id),
}] : []),
]}
customActions={[
{
id: 'execute',
icon: <FaPlay />,
onClick: handleExecute,
title: 'Ausführen',
loading: (row: Automation) => executingAutomations.has(row.id),
},
{
id: 'toggleActive',
icon: (row: Automation) => row.active ? <FaToggleOn /> : <FaToggleOff />,
onClick: handleToggleActive,
title: (row: Automation) => row.active ? 'Deaktivieren' : 'Aktivieren',
} as any,
]}
onDelete={handleDelete}
hookData={{
refetch,
permissions,
pagination,
handleDelete: handleAutomationDelete,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Automatisierungen gefunden"
/>
)}
</div>
{/* Create Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neue Automatisierung</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
submitButtonText="Erstellen"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingAutomation && (
<div className={styles.modalOverlay} onClick={() => setEditingAutomation(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Automatisierung bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => setEditingAutomation(null)}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingAutomation}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingAutomation(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AutomationsPage;

View file

@ -0,0 +1,493 @@
/**
* PlaygroundPage Styles
*
* Resizable two-column layout for Chat Playground.
* Uses existing Nyla CSS variables and design patterns.
*/
/* Main container */
.playgroundContainer {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
padding: 1rem;
gap: 1rem;
}
/* Page header */
.pageHeader {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.pageTitle {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.pageSubtitle {
font-size: 0.875rem;
color: var(--text-secondary);
margin: 0.25rem 0 0 0;
}
.headerControls {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Main content area with resizable columns */
.mainContent {
flex: 1;
display: flex;
flex-direction: row;
overflow: hidden;
min-height: 0;
}
/* Left panel - Chat/Messages */
.leftPanel {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 300px;
}
/* Resizable divider between panels */
.resizeDivider {
width: 8px;
cursor: col-resize;
background-color: transparent;
position: relative;
flex-shrink: 0;
z-index: 10;
transition: background-color 0.15s ease;
}
.resizeDivider:hover,
.resizeDivider.dragging {
background-color: var(--border-color);
}
.dividerHandle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 4px;
height: 40px;
border-radius: 2px;
background-color: var(--text-secondary);
opacity: 0;
transition: opacity 0.15s ease;
}
.resizeDivider:hover .dividerHandle,
.resizeDivider.dragging .dividerHandle {
opacity: 0.5;
}
/* Right panel - Dashboard */
.rightPanel {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 200px;
background: var(--surface-color);
border-left: 1px solid var(--border-color);
border-radius: 0 8px 8px 0;
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
flex-shrink: 0;
}
.panelTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.panelContent {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
/* Content section */
.contentSection {
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
}
.contentHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
flex-shrink: 0;
}
.contentArea {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
/* Messages container */
.messagesContainer {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Empty state */
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-secondary);
text-align: center;
}
.emptyIcon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.emptyTitle {
font-size: 1.125rem;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 0.5rem 0;
}
.emptyDescription {
margin: 0;
max-width: 400px;
}
/* Footer / Input area */
.inputFooter {
flex-shrink: 0;
padding: 1rem;
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.inputRow {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.selectors {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.inputWrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.textareaWrapper {
position: relative;
}
.inputTextarea {
width: 100%;
min-height: 80px;
max-height: 200px;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.875rem;
resize: vertical;
transition: border-color 0.2s;
}
.inputTextarea:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.inputTextarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.inputControls {
display: flex;
justify-content: space-between;
align-items: center;
}
.fileButtons {
display: flex;
gap: 0.5rem;
}
.actionButtons {
display: flex;
gap: 0.5rem;
}
/* Buttons */
.iconButton {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--surface-color);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.iconButton:hover:not(:disabled) {
background: var(--bg-secondary);
color: var(--text-primary);
}
.iconButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.primaryButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--primary-color, #f25843);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.primaryButton:hover:not(:disabled) {
background: var(--primary-dark, #d94d3a);
}
.primaryButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.stopButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--danger-color, #e53e3e);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.stopButton:hover:not(:disabled) {
background: #c53030;
}
.secondaryButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--surface-color);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.secondaryButton:hover:not(:disabled) {
background: var(--bg-secondary);
}
/* Select/Dropdown */
.selector {
min-width: 150px;
}
.selectDropdown {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--surface-color);
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
min-width: 150px;
}
.selectDropdown:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
/* Statistics bar */
.statsBar {
display: flex;
gap: 1.5rem;
padding: 0.5rem 0;
font-size: 0.75rem;
color: var(--text-secondary);
}
.statItem {
display: flex;
align-items: center;
gap: 0.25rem;
}
.statLabel {
color: var(--text-tertiary);
}
.statValue {
font-weight: 500;
color: var(--text-secondary);
}
/* Pending files */
.pendingFiles {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0;
}
.pendingFile {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.75rem;
}
.pendingFileName {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.removeFileButton {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: 50%;
}
.removeFileButton:hover {
background: var(--danger-color, #e53e3e);
color: white;
}
/* Dragging state - prevent text selection */
.mainContent.dragging {
user-select: none;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.mainContent {
flex-direction: column;
}
.leftPanel,
.rightPanel {
width: 100% !important;
}
.resizeDivider {
display: none;
}
.rightPanel {
border-left: none;
border-top: 1px solid var(--border-color);
border-radius: 0 0 8px 8px;
max-height: 300px;
}
}
/* Loading spinner */
.loadingSpinner {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-color);
border-top-color: var(--primary-color, #f25843);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,445 @@
/**
* PlaygroundPage (Chat Playground)
*
* Global page for workflow execution and chat interaction.
* Features a resizable two-column layout with chat on the left and dashboard on the right.
*/
import React, { useRef } from 'react';
import { useDashboardInputForm } from '../../hooks/usePlayground';
import { useUserWorkflows } from '../../hooks/useWorkflows';
import { useResizablePanels } from '../../hooks/useResizablePanels';
import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus } from 'react-icons/fa';
import styles from './PlaygroundPage.module.css';
export const PlaygroundPage: React.FC = () => {
// Main hook for input form and data
const hookData = useDashboardInputForm();
const {
inputValue,
onInputChange,
isRunning,
handleSubmit,
isSubmitting,
messages,
dashboardTree,
onToggleOperationExpanded,
workflowId,
onWorkflowSelect,
workflowItems,
pendingFiles,
handleFileRemove,
latestStats,
playgroundUIPermission,
} = hookData;
const { data: workflows } = useUserWorkflows();
// Resizable panels hook
const {
leftWidth,
isDragging,
handleMouseDown,
containerRef,
} = useResizablePanels({
storageKey: 'playground-panel-width',
defaultLeftWidth: 70,
minLeftWidth: 40,
maxLeftWidth: 85,
});
// File input ref for hidden file input
const fileInputRef = useRef<HTMLInputElement>(null);
// Simple wrapper for workflow selection
const handleWorkflowChange = (id: string | null) => {
if (!id) {
onWorkflowSelect(null);
} else {
const item = workflowItems?.find((w: any) => w.id === id);
if (item) {
onWorkflowSelect(item);
}
}
};
// Handle file upload click
const handleFileClick = () => {
fileInputRef.current?.click();
};
// Handle file change
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && hookData.handleFileUpload) {
for (const file of Array.from(files)) {
await hookData.handleFileUpload(file);
}
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Format timestamp for messages
const formatTime = (timestamp: number | undefined) => {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
};
// Render messages
const renderMessages = () => {
if (!messages || messages.length === 0) {
return (
<div className={styles.emptyState}>
<FaComment className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Nachrichten</h3>
<p className={styles.emptyDescription}>
Starten Sie einen neuen Workflow oder wählen Sie einen bestehenden aus.
</p>
</div>
);
}
return (
<div className={styles.messagesContainer}>
{messages.map((msg: any, index: number) => (
<div
key={msg.id || index}
style={{
padding: '0.75rem 1rem',
borderRadius: '8px',
background: msg.role === 'user'
? 'var(--bg-secondary)'
: 'var(--surface-color)',
border: '1px solid var(--border-color)',
marginLeft: msg.role === 'user' ? '2rem' : '0',
marginRight: msg.role === 'assistant' ? '2rem' : '0',
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '0.5rem',
fontSize: '0.75rem',
color: 'var(--text-secondary)',
}}>
<span style={{ fontWeight: 500 }}>
{msg.role === 'user' ? 'Sie' : 'Assistent'}
</span>
<span>{formatTime(msg.publishedAt)}</span>
</div>
<div style={{
color: 'var(--text-primary)',
fontSize: '0.875rem',
whiteSpace: 'pre-wrap',
}}>
{msg.message || msg.content}
</div>
</div>
))}
</div>
);
};
// Render dashboard tree
const renderDashboard = () => {
if (!dashboardTree || dashboardTree.rootOperations.length === 0) {
return (
<div className={styles.emptyState} style={{ padding: '2rem' }}>
<FaTasks className={styles.emptyIcon} style={{ fontSize: '2rem' }} />
<p style={{ fontSize: '0.875rem', marginTop: '0.5rem' }}>
Keine aktiven Operationen
</p>
</div>
);
}
const renderOperation = (operationId: string, depth: number = 0) => {
const operation = dashboardTree.operations.get(operationId);
if (!operation) return null;
const childOps = Array.from(dashboardTree.operations.entries())
.filter(([_, op]) => op.parentId === operationId)
.map(([id]) => id);
return (
<div
key={operationId}
style={{
paddingLeft: `${depth * 1}rem`,
paddingTop: '0.5rem',
paddingBottom: '0.5rem',
borderBottom: depth === 0 ? '1px solid var(--border-color)' : 'none',
}}
>
<div
onClick={() => onToggleOperationExpanded(operationId)}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
cursor: childOps.length > 0 ? 'pointer' : 'default',
}}
>
{childOps.length > 0 && (
<span style={{
fontSize: '0.75rem',
color: 'var(--text-secondary)',
transform: operation.expanded ? 'rotate(90deg)' : 'none',
transition: 'transform 0.15s',
}}>
</span>
)}
<span style={{
flex: 1,
fontSize: '0.8125rem',
color: 'var(--text-primary)',
fontWeight: depth === 0 ? 500 : 400,
}}>
{operation.operationName || operationId.slice(0, 20)}
</span>
{operation.latestStatus && (
<span style={{
fontSize: '0.6875rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
background: operation.latestStatus === 'completed'
? 'var(--success-bg, #dcfce7)'
: operation.latestStatus === 'running'
? 'var(--info-bg, #dbeafe)'
: operation.latestStatus === 'error'
? 'var(--danger-bg, #fee2e2)'
: 'var(--bg-secondary)',
color: operation.latestStatus === 'completed'
? 'var(--success-color, #16a34a)'
: operation.latestStatus === 'running'
? 'var(--info-color, #2563eb)'
: operation.latestStatus === 'error'
? 'var(--danger-color, #dc2626)'
: 'var(--text-secondary)',
}}>
{operation.latestStatus}
</span>
)}
</div>
{operation.expanded && childOps.length > 0 && (
<div style={{ marginTop: '0.25rem' }}>
{childOps.map(childId => renderOperation(childId, depth + 1))}
</div>
)}
</div>
);
};
return (
<div>
{dashboardTree.rootOperations.map(opId => renderOperation(opId))}
</div>
);
};
// Permission check
if (!playgroundUIPermission) {
return (
<div className={styles.playgroundContainer}>
<div className={styles.emptyState}>
<h3 className={styles.emptyTitle}>Kein Zugriff</h3>
<p className={styles.emptyDescription}>
Sie haben keine Berechtigung für den Chat Playground.
</p>
</div>
</div>
);
}
return (
<div className={styles.playgroundContainer}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{/* Page Header */}
<header className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Chat Playground</h1>
<p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p>
</div>
<div className={styles.headerControls}>
<select
className={styles.selectDropdown}
value={workflowId || ''}
onChange={(e) => handleWorkflowChange(e.target.value || null)}
disabled={isRunning}
>
<option value="">Neuer Workflow</option>
{(workflowItems || workflows)?.map((item: any) => (
<option key={item.id} value={item.id}>
{item.label || item.name || item.id}
</option>
))}
</select>
</div>
</header>
{/* Main Content - Resizable Two-Column Layout */}
<div
ref={containerRef}
className={`${styles.mainContent} ${isDragging ? styles.dragging : ''}`}
>
{/* Left Panel - Chat Messages */}
<div
className={styles.leftPanel}
style={{ width: `${leftWidth}%` }}
>
<div className={styles.contentSection}>
<div className={styles.contentHeader}>
<h3 className={styles.panelTitle}>
<FaComment style={{ marginRight: '0.5rem' }} />
Nachrichten
</h3>
</div>
<div className={styles.contentArea}>
{renderMessages()}
</div>
</div>
</div>
{/* Resize Divider */}
<div
className={`${styles.resizeDivider} ${isDragging ? styles.dragging : ''}`}
onMouseDown={handleMouseDown}
>
<div className={styles.dividerHandle} />
</div>
{/* Right Panel - Dashboard */}
<div
className={styles.rightPanel}
style={{ width: `${100 - leftWidth}%` }}
>
<div className={styles.panelHeader}>
<h3 className={styles.panelTitle}>
<FaTasks style={{ marginRight: '0.5rem' }} />
Dashboard
</h3>
</div>
<div className={styles.panelContent}>
{renderDashboard()}
</div>
</div>
</div>
{/* Input Footer */}
<div className={styles.inputFooter}>
{/* Pending files */}
{pendingFiles && pendingFiles.length > 0 && (
<div className={styles.pendingFiles}>
{pendingFiles.map((file: any) => (
<div key={file.fileId} className={styles.pendingFile}>
<FaFile style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }} />
<span className={styles.pendingFileName}>{file.fileName}</span>
<button
className={styles.removeFileButton}
onClick={() => handleFileRemove(file)}
title="Entfernen"
>
×
</button>
</div>
))}
</div>
)}
{/* Stats bar */}
{latestStats && (
<div className={styles.statsBar}>
{latestStats.promptTokens !== undefined && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Tokens:</span>
<span className={styles.statValue}>
{latestStats.promptTokens + (latestStats.completionTokens || 0)}
</span>
</div>
)}
{latestStats.totalCost !== undefined && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Kosten:</span>
<span className={styles.statValue}>
${latestStats.totalCost.toFixed(4)}
</span>
</div>
)}
</div>
)}
{/* Input row */}
<div className={styles.inputRow}>
<div className={styles.inputWrapper}>
<div className={styles.textareaWrapper}>
<textarea
className={styles.inputTextarea}
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
placeholder="Geben Sie Ihre Nachricht ein..."
disabled={isRunning && !workflowId}
rows={3}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
/>
</div>
<div className={styles.inputControls}>
<div className={styles.fileButtons}>
<button
className={styles.iconButton}
onClick={handleFileClick}
disabled={isRunning}
title="Datei anhängen"
>
<FaPlus />
</button>
</div>
<div className={styles.actionButtons}>
{isRunning ? (
<button
type="button"
className={styles.stopButton}
onClick={handleSubmit}
disabled={isSubmitting}
>
<FaStop />
{isSubmitting ? 'Stoppt...' : 'Stoppen'}
</button>
) : (
<button
type="button"
className={styles.primaryButton}
onClick={handleSubmit}
disabled={!inputValue.trim() || isSubmitting}
>
<FaPaperPlane />
{isSubmitting ? 'Senden...' : 'Senden'}
</button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default PlaygroundPage;

View file

@ -0,0 +1,298 @@
/* WorkflowPages.module.css - Shared styles for workflow pages */
.page {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.header {
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;
}
.content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
padding: 1.5rem;
}
.section h2 {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary, #1a1a2e);
margin: 0 0 1rem 0;
}
/* Loading, Error, Empty states */
.loading,
.error,
.empty {
text-align: center;
padding: 3rem;
color: var(--color-text-secondary, #6b7280);
}
.error {
color: var(--color-error, #dc2626);
}
/* Table styles */
.tableContainer {
overflow-x: auto;
}
.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);
}
/* Badge styles */
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
background: var(--color-surface-secondary, #f3f4f6);
color: var(--color-text-secondary, #6b7280);
}
.badge.running,
.badge.active {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
}
.badge.completed {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
.badge.error,
.badge.failed {
background: var(--color-error-bg, #fee2e2);
color: var(--color-error, #dc2626);
}
.badge.stopped,
.badge.pending {
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #d97706);
}
/* Button styles */
.actions {
display: flex;
gap: 0.5rem;
}
.deleteButton,
.executeButton,
.submitButton,
.stopButton,
.toggleButton {
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;
}
.executeButton {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
border-color: var(--color-info, #2563eb);
}
.executeButton:hover:not(:disabled) {
background: var(--color-info, #2563eb);
color: white;
}
.submitButton {
background: var(--color-primary, #4f46e5);
color: white;
}
.submitButton:hover:not(:disabled) {
background: var(--color-primary-dark, #4338ca);
}
.stopButton {
background: var(--color-error, #dc2626);
color: white;
}
.stopButton:hover:not(:disabled) {
background: var(--color-error-dark, #b91c1c);
}
.toggleButton {
background: var(--color-surface-secondary, #f3f4f6);
color: var(--color-text-secondary, #6b7280);
border-color: var(--color-border, #e5e7eb);
}
.toggleButton.active {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
border-color: var(--color-success, #16a34a);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Form styles */
.select {
width: 100%;
max-width: 400px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
background: var(--color-surface, #ffffff);
font-size: 0.875rem;
}
.inputForm {
display: flex;
flex-direction: column;
gap: 1rem;
}
.textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
resize: vertical;
min-height: 100px;
}
.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));
}
.buttonGroup {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
/* Messages display */
.messagesContainer {
max-height: 400px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.message {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: var(--color-surface-secondary, #f9fafb);
border-radius: 6px;
}
.messageRole {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
}
.messageContent {
color: var(--color-text-primary, #1a1a2e);
white-space: pre-wrap;
}
.emptyMessage {
text-align: center;
color: var(--color-text-secondary, #6b7280);
padding: 2rem;
}
/* Log display */
.logContainer {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-family: monospace;
font-size: 0.8rem;
}
.logEntry {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
background: var(--color-surface-secondary, #f9fafb);
border-radius: 4px;
}
.logStatus {
font-weight: 600;
color: var(--color-info, #2563eb);
}
.logMessage {
color: var(--color-text-primary, #1a1a2e);
}

View file

@ -0,0 +1,255 @@
/**
* WorkflowsPage
*
* Page for viewing and managing workflows using FormGeneratorTable.
* Follows the pattern established in AdminUsersPage.
*/
import React, { useState, useMemo } from 'react';
import { useUserWorkflows, useWorkflowOperations } from '../../hooks/useWorkflows';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaList, FaPlay } from 'react-icons/fa';
import { useNavigate } from 'react-router-dom';
import styles from '../admin/Admin.module.css';
interface Workflow {
id: string;
name?: string;
status: string;
workflowMode?: string;
[key: string]: any;
}
export const WorkflowsPage: React.FC = () => {
const navigate = useNavigate();
// Data hook
const {
data: workflows,
attributes,
permissions,
pagination,
loading,
error,
refetch,
fetchWorkflowById,
updateOptimistically,
} = useUserWorkflows();
// Operations hook
const {
handleWorkflowDelete,
handleWorkflowDeleteMultiple,
handleWorkflowUpdate,
handleInlineUpdate,
deletingWorkflows,
editingWorkflows,
} = useWorkflowOperations();
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
// 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,
fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField,
}));
}, [attributes]);
// Check permissions
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
// Handle edit click - fetch full workflow data
const handleEditClick = async (workflow: Workflow) => {
const fullWorkflow = await fetchWorkflowById(workflow.id);
if (fullWorkflow) {
setEditingWorkflow(fullWorkflow as Workflow);
}
};
// Handle continue workflow - navigate to playground
const handleContinueWorkflow = (workflow: Workflow) => {
navigate(`/playground?workflowId=${workflow.id}`);
};
// Handle edit submit
const handleEditSubmit = async (data: Partial<Workflow>) => {
if (!editingWorkflow) return;
const result = await handleWorkflowUpdate(editingWorkflow.id, data);
if (result.success) {
setEditingWorkflow(null);
refetch();
}
};
// Handle delete single workflow
const handleDelete = async (workflow: Workflow) => {
if (window.confirm(`Möchten Sie den Workflow "${workflow.name || workflow.id}" wirklich löschen?`)) {
const success = await handleWorkflowDelete(workflow.id);
if (success) {
refetch();
}
}
};
// Handle delete multiple workflows
const handleDeleteMultiple = async (workflowsToDelete: Workflow[]) => {
const count = workflowsToDelete.length;
if (window.confirm(`Möchten Sie ${count} Workflow(s) wirklich löschen?`)) {
const ids = workflowsToDelete.map(w => w.id);
const success = await handleWorkflowDeleteMultiple(ids);
if (success) {
refetch();
}
}
};
// Form attributes for edit modal - filter out non-editable fields
const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt'];
return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Workflows: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Workflows</h1>
<p className={styles.pageSubtitle}>Übersicht aller Workflows</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!workflows || workflows.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Workflows...</span>
</div>
) : !workflows || workflows.length === 0 ? (
<div className={styles.emptyState}>
<FaList className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Workflows vorhanden</h3>
<p className={styles.emptyDescription}>
Starten Sie einen neuen Workflow im Chat Playground.
</p>
</div>
) : (
<FormGeneratorTable
data={workflows}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: Workflow) => deletingWorkflows.has(row.id),
}] : []),
]}
customActions={[
{
id: 'continue',
icon: <FaPlay />,
onClick: handleContinueWorkflow,
title: 'Workflow fortsetzen',
}
]}
onDelete={handleDelete}
onDeleteMultiple={handleDeleteMultiple}
hookData={{
refetch,
permissions,
pagination,
handleDelete: handleWorkflowDelete,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Workflows gefunden"
/>
)}
</div>
{/* Edit Modal */}
{editingWorkflow && (
<div className={styles.modalOverlay} onClick={() => setEditingWorkflow(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Workflow bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => setEditingWorkflow(null)}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingWorkflow}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingWorkflow(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default WorkflowsPage;

View file

@ -0,0 +1,3 @@
export { PlaygroundPage } from './PlaygroundPage';
export { WorkflowsPage } from './WorkflowsPage';
export { AutomationsPage } from './AutomationsPage';