refactored pages ui access with saas mandates
This commit is contained in:
parent
b207c0cc5b
commit
dc4b475728
51 changed files with 7000 additions and 118 deletions
40
src/App.tsx
40
src/App.tsx
|
|
@ -28,6 +28,8 @@ import { AuthProvider } from './providers/auth/AuthProvider';
|
|||
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
||||
import { LanguageProvider } from './providers/language/LanguageContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
||||
import { FileProvider } from './contexts/FileContext';
|
||||
|
||||
// Layouts
|
||||
import { MainLayout } from './layouts/MainLayout';
|
||||
|
|
@ -39,6 +41,15 @@ import { SettingsPage } from './pages/Settings';
|
|||
import { FeatureViewPage } from './pages/FeatureView';
|
||||
import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage } from './pages/admin';
|
||||
|
||||
// Workflow Pages (global)
|
||||
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
|
||||
|
||||
// Basedata Pages (global)
|
||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||
|
||||
// Migrate Pages (temporary - to be migrated to feature instances)
|
||||
import { ChatbotPage, PekPage, SpeechPage } from './pages/migrate';
|
||||
|
||||
function App() {
|
||||
// Load saved theme preference and set app name on app mount
|
||||
useEffect(() => {
|
||||
|
|
@ -66,6 +77,8 @@ function App() {
|
|||
<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
238
src/api/automationApi.ts
Normal 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 [];
|
||||
}
|
||||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
282
src/core/PageManager/data/pages/automations.ts
Normal file
282
src/core/PageManager/data/pages/automations.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
399
src/hooks/useAutomations.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
185
src/hooks/useResizablePanels.ts
Normal file
185
src/hooks/useResizablePanels.ts
Normal 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;
|
||||
287
src/hooks/useTrusteeOptions.ts
Normal file
287
src/hooks/useTrusteeOptions.ts
Normal 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;
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
352
src/pages/basedata/BasedataPages.module.css
Normal file
352
src/pages/basedata/BasedataPages.module.css
Normal 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);
|
||||
}
|
||||
348
src/pages/basedata/ConnectionsPage.tsx
Normal file
348
src/pages/basedata/ConnectionsPage.tsx
Normal 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;
|
||||
328
src/pages/basedata/FilesPage.tsx
Normal file
328
src/pages/basedata/FilesPage.tsx
Normal 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;
|
||||
295
src/pages/basedata/PromptsPage.tsx
Normal file
295
src/pages/basedata/PromptsPage.tsx
Normal 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;
|
||||
3
src/pages/basedata/index.ts
Normal file
3
src/pages/basedata/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { PromptsPage } from './PromptsPage';
|
||||
export { FilesPage } from './FilesPage';
|
||||
export { ConnectionsPage } from './ConnectionsPage';
|
||||
130
src/pages/migrate/ChatbotPage.tsx
Normal file
130
src/pages/migrate/ChatbotPage.tsx
Normal 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;
|
||||
223
src/pages/migrate/MigratePages.module.css
Normal file
223
src/pages/migrate/MigratePages.module.css
Normal 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;
|
||||
}
|
||||
39
src/pages/migrate/PekPage.tsx
Normal file
39
src/pages/migrate/PekPage.tsx
Normal 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;
|
||||
39
src/pages/migrate/SpeechPage.tsx
Normal file
39
src/pages/migrate/SpeechPage.tsx
Normal 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;
|
||||
3
src/pages/migrate/index.ts
Normal file
3
src/pages/migrate/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { ChatbotPage } from './ChatbotPage';
|
||||
export { PekPage } from './PekPage';
|
||||
export { SpeechPage } from './SpeechPage';
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
206
src/pages/views/trustee/TrusteePositionDocumentsView.tsx
Normal file
206
src/pages/views/trustee/TrusteePositionDocumentsView.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
329
src/pages/views/trustee/components/TrusteeEditForm.tsx
Normal file
329
src/pages/views/trustee/components/TrusteeEditForm.tsx
Normal 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;
|
||||
6
src/pages/views/trustee/components/index.ts
Normal file
6
src/pages/views/trustee/components/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Trustee Components
|
||||
*/
|
||||
|
||||
export { TrusteeEditForm } from './TrusteeEditForm';
|
||||
export type { FieldConfig, TrusteeEditFormProps } from './TrusteeEditForm';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
334
src/pages/workflows/AutomationsPage.tsx
Normal file
334
src/pages/workflows/AutomationsPage.tsx
Normal 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;
|
||||
493
src/pages/workflows/PlaygroundPage.module.css
Normal file
493
src/pages/workflows/PlaygroundPage.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
445
src/pages/workflows/PlaygroundPage.tsx
Normal file
445
src/pages/workflows/PlaygroundPage.tsx
Normal 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;
|
||||
298
src/pages/workflows/WorkflowPages.module.css
Normal file
298
src/pages/workflows/WorkflowPages.module.css
Normal 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);
|
||||
}
|
||||
255
src/pages/workflows/WorkflowsPage.tsx
Normal file
255
src/pages/workflows/WorkflowsPage.tsx
Normal 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;
|
||||
3
src/pages/workflows/index.ts
Normal file
3
src/pages/workflows/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { PlaygroundPage } from './PlaygroundPage';
|
||||
export { WorkflowsPage } from './WorkflowsPage';
|
||||
export { AutomationsPage } from './AutomationsPage';
|
||||
Loading…
Reference in a new issue