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 { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
||||||
import { LanguageProvider } from './providers/language/LanguageContext';
|
import { LanguageProvider } from './providers/language/LanguageContext';
|
||||||
import { ToastProvider } from './contexts/ToastContext';
|
import { ToastProvider } from './contexts/ToastContext';
|
||||||
|
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
||||||
|
import { FileProvider } from './contexts/FileContext';
|
||||||
|
|
||||||
// Layouts
|
// Layouts
|
||||||
import { MainLayout } from './layouts/MainLayout';
|
import { MainLayout } from './layouts/MainLayout';
|
||||||
|
|
@ -39,6 +41,15 @@ import { SettingsPage } from './pages/Settings';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
import { FeatureViewPage } from './pages/FeatureView';
|
||||||
import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage } from './pages/admin';
|
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() {
|
function App() {
|
||||||
// Load saved theme preference and set app name on app mount
|
// Load saved theme preference and set app name on app mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -66,6 +77,8 @@ function App() {
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<WorkflowSelectionProvider>
|
||||||
|
<FileProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* ================================================== */}
|
{/* ================================================== */}
|
||||||
|
|
@ -91,6 +104,31 @@ function App() {
|
||||||
{/* System-Seiten (ohne Instanz-Kontext) */}
|
{/* System-Seiten (ohne Instanz-Kontext) */}
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<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 */}
|
{/* FEATURE-INSTANZ ROUTES */}
|
||||||
{/* /mandates/:mandateId/:featureCode/:instanceId */}
|
{/* /mandates/:mandateId/:featureCode/:instanceId */}
|
||||||
|
|
@ -141,6 +179,8 @@ function App() {
|
||||||
} />
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
</FileProvider>
|
||||||
|
</WorkflowSelectionProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</LanguageProvider>
|
</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 { useCurrentUser } from '../../hooks/useUsers';
|
||||||
import { FEATURE_REGISTRY, getLabel } from '../../types/mandate';
|
import { FEATURE_REGISTRY, getLabel } from '../../types/mandate';
|
||||||
import type { Mandate, MandateFeature, FeatureInstance } 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 { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
|
||||||
import styles from './MandateNavigation.module.css';
|
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
|
// Separator
|
||||||
items.push({ type: 'separator' });
|
items.push({ type: 'separator' });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Supports mandate-level and global exports with different import modes.
|
* 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 {
|
import {
|
||||||
FaDownload,
|
FaDownload,
|
||||||
FaUpload,
|
FaUpload,
|
||||||
|
|
@ -182,7 +182,6 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
|
||||||
exporting,
|
exporting,
|
||||||
importing,
|
importing,
|
||||||
error,
|
error,
|
||||||
lastExport,
|
|
||||||
lastImportResult,
|
lastImportResult,
|
||||||
exportMandateRbac,
|
exportMandateRbac,
|
||||||
exportGlobalRbac,
|
exportGlobalRbac,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { allPageData, SidebarItem } from './data';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import { resolveLanguageText } from './pageInterface';
|
import { resolveLanguageText } from './pageInterface';
|
||||||
import { usePermissions } from '../../hooks/usePermissions';
|
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';
|
import { RiFolderSettingsFill } from 'react-icons/ri';
|
||||||
|
|
||||||
// Configuration for parent groups that don't have a page definition
|
// Configuration for parent groups that don't have a page definition
|
||||||
|
|
@ -16,17 +16,21 @@ const parentGroupConfig: Record<string, {
|
||||||
icon: FaHome,
|
icon: FaHome,
|
||||||
defaultOrder: 1
|
defaultOrder: 1
|
||||||
},
|
},
|
||||||
'trustee': {
|
'workflows': {
|
||||||
icon: FaBriefcase,
|
icon: FaProjectDiagram,
|
||||||
defaultOrder: 2
|
defaultOrder: 2
|
||||||
},
|
},
|
||||||
'administration': {
|
'trustee': {
|
||||||
icon: RiFolderSettingsFill,
|
icon: FaBriefcase,
|
||||||
defaultOrder: 3
|
defaultOrder: 3
|
||||||
},
|
},
|
||||||
|
'basedata': {
|
||||||
|
icon: RiFolderSettingsFill,
|
||||||
|
defaultOrder: 4
|
||||||
|
},
|
||||||
'admin': {
|
'admin': {
|
||||||
icon: FaHatWizard,
|
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 = {
|
export const connectionsPageData: GenericPageData = {
|
||||||
id: 'administration-connections',
|
id: 'basedata-connections',
|
||||||
path: 'administration/connections',
|
path: 'basedata/connections',
|
||||||
name: 'connections.title',
|
name: 'connections.title',
|
||||||
description: 'connections.title',
|
description: 'connections.title',
|
||||||
|
|
||||||
// Parent page
|
// Parent page
|
||||||
parentPath: 'administration',
|
parentPath: 'basedata',
|
||||||
|
|
||||||
// Visual
|
// Visual
|
||||||
icon: FaLink,
|
icon: FaLink,
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,18 @@ import { HiOutlineCollection } from 'react-icons/hi';
|
||||||
import { createDashboardHook } from '../../../../hooks/usePlayground';
|
import { createDashboardHook } from '../../../../hooks/usePlayground';
|
||||||
|
|
||||||
export const dashboardPageData: GenericPageData = {
|
export const dashboardPageData: GenericPageData = {
|
||||||
id: 'start-dashboard',
|
id: 'workflows-playground',
|
||||||
path: 'start/dashboard',
|
path: 'workflows/playground',
|
||||||
name: 'Dashboard',
|
name: 'chatPlayground.title',
|
||||||
description: 'Main dashboard with overview and quick actions',
|
description: 'chatPlayground.description',
|
||||||
|
|
||||||
// Parent page
|
// Parent page - now under 'workflows' group
|
||||||
parentPath: 'start',
|
parentPath: 'workflows',
|
||||||
|
|
||||||
// Visual
|
// Visual
|
||||||
icon: LuTicket,
|
icon: LuTicket,
|
||||||
title: 'Dashboard',
|
title: 'chatPlayground.title',
|
||||||
subtitle: 'Welcome to your workspace',
|
subtitle: 'chatPlayground.subtitle',
|
||||||
|
|
||||||
// Header buttons
|
// Header buttons
|
||||||
headerButtons: [
|
headerButtons: [
|
||||||
|
|
|
||||||
|
|
@ -161,13 +161,13 @@ const createFilesHook = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filesPageData: GenericPageData = {
|
export const filesPageData: GenericPageData = {
|
||||||
id: 'administration-files',
|
id: 'basedata-files',
|
||||||
path: 'administration/files',
|
path: 'basedata/files',
|
||||||
name: 'files.title',
|
name: 'files.title',
|
||||||
description: 'files.title',
|
description: 'files.description',
|
||||||
|
|
||||||
// Parent page
|
// Parent page - now under 'basedata' group (formerly 'administration')
|
||||||
parentPath: 'administration',
|
parentPath: 'basedata',
|
||||||
|
|
||||||
// Visual
|
// Visual
|
||||||
icon: FaRegFileAlt,
|
icon: FaRegFileAlt,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
export { dashboardPageData } from './dashboard';
|
export { dashboardPageData } from './dashboard';
|
||||||
export { filesPageData } from './files';
|
export { filesPageData } from './files';
|
||||||
export { workflowsPageData } from './workflows';
|
export { workflowsPageData } from './workflows';
|
||||||
|
export { automationsPageData } from './automations';
|
||||||
export { connectionsPageData } from './connections';
|
export { connectionsPageData } from './connections';
|
||||||
export { teamMembersPageData } from './admin/team-members';
|
export { teamMembersPageData } from './admin/team-members';
|
||||||
export { promptsPageData } from './prompts';
|
export { promptsPageData } from './prompts';
|
||||||
|
|
@ -28,6 +29,7 @@ export {
|
||||||
import { dashboardPageData } from './dashboard';
|
import { dashboardPageData } from './dashboard';
|
||||||
import { filesPageData } from './files';
|
import { filesPageData } from './files';
|
||||||
import { workflowsPageData } from './workflows';
|
import { workflowsPageData } from './workflows';
|
||||||
|
import { automationsPageData } from './automations';
|
||||||
import { connectionsPageData } from './connections';
|
import { connectionsPageData } from './connections';
|
||||||
import { teamMembersPageData } from './admin/team-members';
|
import { teamMembersPageData } from './admin/team-members';
|
||||||
import { promptsPageData } from './prompts';
|
import { promptsPageData } from './prompts';
|
||||||
|
|
@ -43,19 +45,23 @@ import { trusteePages } from './trustee';
|
||||||
|
|
||||||
// Array of all page data
|
// Array of all page data
|
||||||
export const allPageData = [
|
export const allPageData = [
|
||||||
dashboardPageData,
|
// Workflows group
|
||||||
|
dashboardPageData, // Chat Playground
|
||||||
|
workflowsPageData, // Workflows list
|
||||||
|
automationsPageData, // Automations
|
||||||
|
// Basedata group
|
||||||
filesPageData,
|
filesPageData,
|
||||||
workflowsPageData,
|
|
||||||
connectionsPageData,
|
|
||||||
promptsPageData,
|
promptsPageData,
|
||||||
|
// Other pages
|
||||||
|
connectionsPageData,
|
||||||
speechPageData,
|
speechPageData,
|
||||||
settingsPageData,
|
settingsPageData,
|
||||||
pekPageData,
|
pekPageData,
|
||||||
pekTablesPageData,
|
pekTablesPageData,
|
||||||
chatbotPageData,
|
chatbotPageData,
|
||||||
// Trustee pages (before Administration)
|
// Trustee pages
|
||||||
...trusteePages,
|
...trusteePages,
|
||||||
// Administration pages
|
// Admin pages
|
||||||
teamMembersPageData,
|
teamMembersPageData,
|
||||||
mandatesPageData,
|
mandatesPageData,
|
||||||
rbacRulesPageData,
|
rbacRulesPageData,
|
||||||
|
|
|
||||||
|
|
@ -128,13 +128,13 @@ const createPromptsHook = () => {
|
||||||
|
|
||||||
|
|
||||||
export const promptsPageData: GenericPageData = {
|
export const promptsPageData: GenericPageData = {
|
||||||
id: 'administration-prompts',
|
id: 'basedata-prompts',
|
||||||
path: 'administration/prompts',
|
path: 'basedata/prompts',
|
||||||
name: 'prompts.title',
|
name: 'prompts.title',
|
||||||
description: 'prompts.description',
|
description: 'prompts.description',
|
||||||
|
|
||||||
// Parent page
|
// Parent page - now under 'basedata' group (formerly 'administration')
|
||||||
parentPath: 'administration',
|
parentPath: 'basedata',
|
||||||
|
|
||||||
// Visual
|
// Visual
|
||||||
icon: FaLightbulb,
|
icon: FaLightbulb,
|
||||||
|
|
|
||||||
|
|
@ -145,13 +145,13 @@ const createWorkflowsHook = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const workflowsPageData: GenericPageData = {
|
export const workflowsPageData: GenericPageData = {
|
||||||
id: 'administration-workflows',
|
id: 'workflows-list',
|
||||||
path: 'administration/workflows',
|
path: 'workflows/list',
|
||||||
name: 'workflows.title',
|
name: 'workflows.title',
|
||||||
description: 'workflows.title',
|
description: 'workflows.description',
|
||||||
|
|
||||||
// Parent page
|
// Parent page - now under 'workflows' group
|
||||||
parentPath: 'administration',
|
parentPath: 'workflows',
|
||||||
|
|
||||||
// Visual
|
// Visual
|
||||||
icon: FaProjectDiagram,
|
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 PageManager } from './PageManager';
|
||||||
export { default as PageRenderer } from './PageRenderer';
|
export { default as PageRenderer } from './PageRenderer';
|
||||||
export { default as SidebarProvider } from './SidebarProvider';
|
export { default as SidebarProvider } from './SidebarProvider';
|
||||||
|
|
||||||
// Export data and interfaces
|
// Export data and interfaces (DEPRECATED)
|
||||||
export * from './data';
|
export * from './data';
|
||||||
export * from './pageInterface';
|
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();
|
||||||
}, [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 {
|
return {
|
||||||
connections,
|
connections,
|
||||||
|
data: connections, // Alias for FormGenerator compatibility
|
||||||
fetchConnections,
|
fetchConnections,
|
||||||
|
refetch: fetchConnections, // Alias for FormGenerator compatibility
|
||||||
createConnection,
|
createConnection,
|
||||||
updateConnection,
|
updateConnection,
|
||||||
connectService,
|
connectService,
|
||||||
|
|
@ -562,6 +601,7 @@ export function useConnections() {
|
||||||
createGoogleConnectionAndAuth,
|
createGoogleConnectionAndAuth,
|
||||||
createMicrosoftConnectionAndAuth,
|
createMicrosoftConnectionAndAuth,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
loading: isLoading, // Alias for FormGenerator compatibility
|
||||||
isConnecting,
|
isConnecting,
|
||||||
error: error || connectError,
|
error: error || connectError,
|
||||||
// Attributes and permissions for dynamic column/button generation
|
// Attributes and permissions for dynamic column/button generation
|
||||||
|
|
@ -571,7 +611,11 @@ export function useConnections() {
|
||||||
generateEditFieldsFromAttributes,
|
generateEditFieldsFromAttributes,
|
||||||
ensureAttributesLoaded,
|
ensureAttributesLoaded,
|
||||||
fetchAttributes,
|
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 {
|
return {
|
||||||
downloadingFiles,
|
downloadingFiles,
|
||||||
deletingFiles,
|
deletingFiles,
|
||||||
|
|
@ -930,6 +943,7 @@ export function useFileOperations() {
|
||||||
handleFileUpload,
|
handleFileUpload,
|
||||||
handleFileUpdate,
|
handleFileUpdate,
|
||||||
handleFilePreview,
|
handleFilePreview,
|
||||||
|
handleInlineUpdate,
|
||||||
isLoading
|
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 {
|
return {
|
||||||
// Loading states
|
// Loading states
|
||||||
startingWorkflow,
|
startingWorkflow,
|
||||||
|
|
@ -628,6 +648,7 @@ export function useWorkflowOperations() {
|
||||||
handleWorkflowDelete,
|
handleWorkflowDelete,
|
||||||
handleWorkflowDeleteMultiple,
|
handleWorkflowDeleteMultiple,
|
||||||
handleWorkflowUpdate,
|
handleWorkflowUpdate,
|
||||||
|
handleInlineUpdate,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
deleteFileFromMessage
|
deleteFileFromMessage
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -718,7 +718,28 @@ export default {
|
||||||
'warning.duplicate_file.title': 'Datei bereits vorhanden',
|
'warning.duplicate_file.title': 'Datei bereits vorhanden',
|
||||||
'warning.duplicate_file.message': 'Die Datei "{fileName}" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.',
|
'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.title': 'Werkzeuge',
|
||||||
'administration.description': 'Werkzeuge und Hilfsmittel',
|
'administration.description': 'Werkzeuge und Hilfsmittel',
|
||||||
'administration.subtitle': 'Verwaltungs- und Management-Tools',
|
'administration.subtitle': 'Verwaltungs- und Management-Tools',
|
||||||
|
|
|
||||||
|
|
@ -718,7 +718,28 @@ export default {
|
||||||
'warning.duplicate_file.title': 'File Already Exists',
|
'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.',
|
'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.title': 'Utils',
|
||||||
'administration.description': 'Utilities and tools',
|
'administration.description': 'Utilities and tools',
|
||||||
'administration.subtitle': 'Administration and management tools',
|
'administration.subtitle': 'Administration and management tools',
|
||||||
|
|
|
||||||
|
|
@ -718,7 +718,28 @@ export default {
|
||||||
'warning.duplicate_file.title': 'Fichier Déjà Existant',
|
'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é.',
|
'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.title': 'Outils',
|
||||||
'administration.description': 'Outils et utilitaires',
|
'administration.description': 'Outils et utilitaires',
|
||||||
'administration.subtitle': 'Outils d\'administration et de gestion',
|
'administration.subtitle': 'Outils d\'administration et de gestion',
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,17 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { useInvitations, type InvitationValidation, type RegisterAndAcceptData } from '../hooks/useInvitations';
|
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 { FaCheckCircle, FaTimesCircle, FaSpinner, FaEnvelope, FaUser, FaLock } from 'react-icons/fa';
|
||||||
import styles from './InvitePage.module.css';
|
import styles from './InvitePage.module.css';
|
||||||
|
|
||||||
export const InvitePage: React.FC = () => {
|
export const InvitePage: React.FC = () => {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAuthenticated, user } = useAuth();
|
const { validateInvitation, acceptInvitation, registerAndAccept } = useInvitations();
|
||||||
const { validateInvitation, acceptInvitation, registerAndAccept, loading } = useInvitations();
|
|
||||||
|
// Check if user has auth token (simplified check)
|
||||||
|
const isAuthenticated = !!sessionStorage.getItem('auth_authority');
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [validation, setValidation] = useState<InvitationValidation | null>(null);
|
const [validation, setValidation] = useState<InvitationValidation | null>(null);
|
||||||
|
|
@ -185,8 +187,8 @@ export const InvitePage: React.FC = () => {
|
||||||
|
|
||||||
<div className={styles.inviteInfo}>
|
<div className={styles.inviteInfo}>
|
||||||
<div className={styles.infoRow}>
|
<div className={styles.infoRow}>
|
||||||
<span className={styles.infoLabel}>Angemeldet als:</span>
|
<span className={styles.infoLabel}>Status:</span>
|
||||||
<span className={styles.infoValue}>{user?.email || user?.username}</span>
|
<span className={styles.infoValue}>Angemeldet</span>
|
||||||
</div>
|
</div>
|
||||||
{validation.roleLabels && validation.roleLabels.length > 0 && (
|
{validation.roleLabels && validation.roleLabels.length > 0 && (
|
||||||
<div className={styles.infoRow}>
|
<div className={styles.infoRow}>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
* Allows creating, viewing, and revoking invitations.
|
* Allows creating, viewing, and revoking invitations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { useInvitations, type Invitation, type InvitationCreate, type PaginationParams } from '../../hooks/useInvitations';
|
import { useInvitations, type Invitation, type InvitationCreate } from '../../hooks/useInvitations';
|
||||||
import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates';
|
import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
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 [showUrlModal, setShowUrlModal] = useState<Invitation | null>(null);
|
||||||
const [showExpired, setShowExpired] = useState(false);
|
const [showExpired, setShowExpired] = useState(false);
|
||||||
const [showUsed, setShowUsed] = 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 [copySuccess, setCopySuccess] = useState(false);
|
||||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
|
|
@ -92,7 +92,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
{
|
{
|
||||||
key: 'roleIds',
|
key: 'roleIds',
|
||||||
label: 'Rollen',
|
label: 'Rollen',
|
||||||
type: 'array' as const,
|
type: 'string', // Array rendered as string
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
width: 150,
|
width: 150,
|
||||||
|
|
@ -103,14 +103,14 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
return role?.roleLabel || roleId;
|
return role?.roleLabel || roleId;
|
||||||
}).join(', ');
|
}).join(', ');
|
||||||
}
|
}
|
||||||
},
|
} as any,
|
||||||
{
|
{
|
||||||
key: 'expiresAt',
|
key: 'expiresAt',
|
||||||
label: 'Gültig bis',
|
label: 'Gültig bis',
|
||||||
type: 'number' as const,
|
type: 'number' as const,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (value: number, row: Invitation) => {
|
render: (value: number) => {
|
||||||
const text = formatDate(value);
|
const text = formatDate(value);
|
||||||
const isExpired = value < Date.now() / 1000;
|
const isExpired = value < Date.now() / 1000;
|
||||||
return (
|
return (
|
||||||
|
|
@ -155,8 +155,8 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
|
|
||||||
// Add helper field expiresInHours if not in model but fields exist
|
// Add helper field expiresInHours if not in model but fields exist
|
||||||
if (fields.length > 0 && !fields.find(f => f.name === 'expiresInHours')) {
|
if (fields.length > 0 && !fields.find(f => f.name === 'expiresInHours')) {
|
||||||
fields.push({ name: 'expiresInHours', label: 'Gültigkeitsdauer (Stunden)', type: 'number' as any,
|
fields.push({ name: 'expiresInHours', label: 'Gültigkeitsdauer (Stunden)', type: 'number',
|
||||||
required: true, default: 72, min: 1, max: 720 });
|
required: true, default: 72 } as any);
|
||||||
}
|
}
|
||||||
return fields;
|
return fields;
|
||||||
}, [roles, backendAttributes]);
|
}, [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
|
* 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 React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { useTrusteeAccess, useTrusteeAccessOperations } from '../../../hooks/useTrustee';
|
import { useTrusteeAccess, useTrusteeAccessOperations, TrusteeAccess } from '../../../hooks/useTrustee';
|
||||||
|
import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions';
|
||||||
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
||||||
|
import { Popup } from '../../../components/UiComponents/Popup/Popup';
|
||||||
|
import { TrusteeEditForm, FieldConfig } from './components';
|
||||||
import styles from './TrusteeViews.module.css';
|
import styles from './TrusteeViews.module.css';
|
||||||
|
|
||||||
export const TrusteeAccessView: React.FC = () => {
|
export const TrusteeAccessView: React.FC = () => {
|
||||||
const { items: accessList, loading, error, refetch } = useTrusteeAccess();
|
const { items: accessList, loading, error, refetch } = useTrusteeAccess();
|
||||||
const { handleDelete, deletingItems } = useTrusteeAccessOperations();
|
const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteeAccessOperations();
|
||||||
const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeAccess');
|
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>;
|
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 (
|
return (
|
||||||
<div className={styles.listView}>
|
<div className={styles.listView}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className={styles.primaryButton}>
|
<button className={styles.primaryButton} onClick={onCreate}>
|
||||||
+ Neuer Zugriff
|
+ Neuer Zugriff
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -54,7 +167,7 @@ export const TrusteeAccessView: React.FC = () => {
|
||||||
<table className={styles.dataTable}>
|
<table className={styles.dataTable}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>User</th>
|
<th>Benutzer</th>
|
||||||
<th>Organisation</th>
|
<th>Organisation</th>
|
||||||
<th>Rolle</th>
|
<th>Rolle</th>
|
||||||
<th>Vertrag</th>
|
<th>Vertrag</th>
|
||||||
|
|
@ -64,13 +177,27 @@ export const TrusteeAccessView: React.FC = () => {
|
||||||
<tbody>
|
<tbody>
|
||||||
{accessList.map((access) => (
|
{accessList.map((access) => (
|
||||||
<tr key={access.id}>
|
<tr key={access.id}>
|
||||||
<td>{access.userId}</td>
|
<td>{getLabelFast('users', access.userId)}</td>
|
||||||
<td>{access.organisationId}</td>
|
<td>{getLabelFast('organisations', access.organisationId)}</td>
|
||||||
<td>{access.roleId}</td>
|
<td>
|
||||||
<td>{access.contractId || '-'}</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}>
|
<td className={styles.actions}>
|
||||||
{canUpdate && (
|
{canUpdate && (
|
||||||
<button className={styles.iconButton} title="Bearbeiten">
|
<button
|
||||||
|
className={styles.iconButton}
|
||||||
|
title="Bearbeiten"
|
||||||
|
onClick={() => onEdit(access)}
|
||||||
|
>
|
||||||
✏️
|
✏️
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -90,6 +217,29 @@ export const TrusteeAccessView: React.FC = () => {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,58 @@
|
||||||
/**
|
/**
|
||||||
* TrusteeContractsView
|
* 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 React, { useState, useMemo } from 'react';
|
||||||
import { useTrusteeContracts, useTrusteeContractOperations } from '../../../hooks/useTrustee';
|
import { useTrusteeContracts, useTrusteeContractOperations, TrusteeContract } from '../../../hooks/useTrustee';
|
||||||
|
import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions';
|
||||||
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
||||||
|
import { Popup } from '../../../components/UiComponents/Popup/Popup';
|
||||||
|
import { TrusteeEditForm, FieldConfig } from './components';
|
||||||
import styles from './TrusteeViews.module.css';
|
import styles from './TrusteeViews.module.css';
|
||||||
|
|
||||||
export const TrusteeContractsView: React.FC = () => {
|
export const TrusteeContractsView: React.FC = () => {
|
||||||
const { items: contracts, loading, error, refetch } = useTrusteeContracts();
|
const { items: contracts, loading, error, refetch } = useTrusteeContracts();
|
||||||
const { handleDelete, deletingItems } = useTrusteeContractOperations();
|
const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteeContractOperations();
|
||||||
const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeContract');
|
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>;
|
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 (
|
return (
|
||||||
<div className={styles.listView}>
|
<div className={styles.listView}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className={styles.primaryButton}>
|
<button className={styles.primaryButton} onClick={onCreate}>
|
||||||
+ Neuer Vertrag
|
+ Neuer Vertrag
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -54,7 +137,7 @@ export const TrusteeContractsView: React.FC = () => {
|
||||||
<table className={styles.dataTable}>
|
<table className={styles.dataTable}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Label</th>
|
<th>Bezeichnung</th>
|
||||||
<th>Organisation</th>
|
<th>Organisation</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Aktionen</th>
|
<th>Aktionen</th>
|
||||||
|
|
@ -64,7 +147,7 @@ export const TrusteeContractsView: React.FC = () => {
|
||||||
{contracts.map((contract) => (
|
{contracts.map((contract) => (
|
||||||
<tr key={contract.id}>
|
<tr key={contract.id}>
|
||||||
<td>{contract.label}</td>
|
<td>{contract.label}</td>
|
||||||
<td>{contract.organisationId}</td>
|
<td>{getLabelFast('organisations', contract.organisationId)}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`${styles.badge} ${contract.enabled ? styles.badgeSuccess : styles.badgeWarning}`}>
|
<span className={`${styles.badge} ${contract.enabled ? styles.badgeSuccess : styles.badgeWarning}`}>
|
||||||
{contract.enabled ? 'Aktiv' : 'Inaktiv'}
|
{contract.enabled ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
|
@ -72,7 +155,11 @@ export const TrusteeContractsView: React.FC = () => {
|
||||||
</td>
|
</td>
|
||||||
<td className={styles.actions}>
|
<td className={styles.actions}>
|
||||||
{canUpdate && (
|
{canUpdate && (
|
||||||
<button className={styles.iconButton} title="Bearbeiten">
|
<button
|
||||||
|
className={styles.iconButton}
|
||||||
|
title="Bearbeiten"
|
||||||
|
onClick={() => onEdit(contract)}
|
||||||
|
>
|
||||||
✏️
|
✏️
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -92,6 +179,29 @@ export const TrusteeContractsView: React.FC = () => {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,78 @@
|
||||||
/**
|
/**
|
||||||
* TrusteeDocumentsView
|
* 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 React, { useState, useMemo } from 'react';
|
||||||
import { useTrusteeDocuments, useTrusteeDocumentOperations } from '../../../hooks/useTrustee';
|
import { useTrusteeDocuments, useTrusteeDocumentOperations, TrusteeDocument } from '../../../hooks/useTrustee';
|
||||||
|
import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions';
|
||||||
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
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';
|
import styles from './TrusteeViews.module.css';
|
||||||
|
|
||||||
export const TrusteeDocumentsView: React.FC = () => {
|
export const TrusteeDocumentsView: React.FC = () => {
|
||||||
|
const instanceId = useInstanceId();
|
||||||
const { items: documents, loading, error, refetch } = useTrusteeDocuments();
|
const { items: documents, loading, error, refetch } = useTrusteeDocuments();
|
||||||
const { handleDelete, deletingItems } = useTrusteeDocumentOperations();
|
const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteeDocumentOperations();
|
||||||
const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeDocument');
|
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>;
|
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 (
|
return (
|
||||||
<div className={styles.listView}>
|
<div className={styles.listView}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className={styles.primaryButton}>
|
<button className={styles.primaryButton} onClick={onCreate}>
|
||||||
+ Neues Dokument
|
+ Neues Dokument
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -64,14 +206,27 @@ export const TrusteeDocumentsView: React.FC = () => {
|
||||||
{documents.map((doc) => (
|
{documents.map((doc) => (
|
||||||
<tr key={doc.id}>
|
<tr key={doc.id}>
|
||||||
<td>{doc.documentName}</td>
|
<td>{doc.documentName}</td>
|
||||||
<td>{doc.documentMimeType}</td>
|
<td>
|
||||||
<td>{doc.contractId}</td>
|
<span className={styles.badge}>
|
||||||
|
{getMimeTypeLabel(doc.documentMimeType)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{getLabelFast('contracts', doc.contractId)}</td>
|
||||||
<td className={styles.actions}>
|
<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>
|
</button>
|
||||||
{canUpdate && (
|
{canUpdate && (
|
||||||
<button className={styles.iconButton} title="Bearbeiten">
|
<button
|
||||||
|
className={styles.iconButton}
|
||||||
|
title="Bearbeiten"
|
||||||
|
onClick={() => onEdit(doc)}
|
||||||
|
>
|
||||||
✏️
|
✏️
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -91,6 +246,29 @@ export const TrusteeDocumentsView: React.FC = () => {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,53 @@
|
||||||
/**
|
/**
|
||||||
* TrusteeOrganisationsView
|
* 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 React, { useState, useMemo } from 'react';
|
||||||
import { useTrusteeOrganisations, useTrusteeOrganisationOperations } from '../../../hooks/useTrustee';
|
import { useTrusteeOrganisations, useTrusteeOrganisationOperations, TrusteeOrganisation } from '../../../hooks/useTrustee';
|
||||||
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
||||||
|
import { Popup } from '../../../components/UiComponents/Popup/Popup';
|
||||||
|
import { TrusteeEditForm, FieldConfig } from './components';
|
||||||
import styles from './TrusteeViews.module.css';
|
import styles from './TrusteeViews.module.css';
|
||||||
|
|
||||||
export const TrusteeOrganisationsView: React.FC = () => {
|
export const TrusteeOrganisationsView: React.FC = () => {
|
||||||
const { items: organisations, loading, error, refetch } = useTrusteeOrganisations();
|
const { items: organisations, loading, error, refetch, generateCreateFieldsFromAttributes, generateEditFieldsFromAttributes, ensureAttributesLoaded } = useTrusteeOrganisations();
|
||||||
const { handleDelete, deletingItems } = useTrusteeOrganisationOperations();
|
const { handleDelete, handleCreate, handleUpdate, deletingItems, createError, updateError, creatingItem } = useTrusteeOrganisationOperations();
|
||||||
const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeOrganisation');
|
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) {
|
if (loading) {
|
||||||
return <div className={styles.loading}>Lade Organisationen...</div>;
|
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 (
|
return (
|
||||||
<div className={styles.listView}>
|
<div className={styles.listView}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className={styles.primaryButton}>
|
<button className={styles.primaryButton} onClick={onCreate}>
|
||||||
+ Neue Organisation
|
+ Neue Organisation
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -54,7 +149,8 @@ export const TrusteeOrganisationsView: React.FC = () => {
|
||||||
<table className={styles.dataTable}>
|
<table className={styles.dataTable}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Label</th>
|
<th>ID</th>
|
||||||
|
<th>Bezeichnung</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Aktionen</th>
|
<th>Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -62,6 +158,7 @@ export const TrusteeOrganisationsView: React.FC = () => {
|
||||||
<tbody>
|
<tbody>
|
||||||
{organisations.map((org) => (
|
{organisations.map((org) => (
|
||||||
<tr key={org.id}>
|
<tr key={org.id}>
|
||||||
|
<td className={styles.monospace}>{org.id}</td>
|
||||||
<td>{org.label}</td>
|
<td>{org.label}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`${styles.badge} ${org.enabled ? styles.badgeSuccess : styles.badgeWarning}`}>
|
<span className={`${styles.badge} ${org.enabled ? styles.badgeSuccess : styles.badgeWarning}`}>
|
||||||
|
|
@ -70,7 +167,11 @@ export const TrusteeOrganisationsView: React.FC = () => {
|
||||||
</td>
|
</td>
|
||||||
<td className={styles.actions}>
|
<td className={styles.actions}>
|
||||||
{canUpdate && (
|
{canUpdate && (
|
||||||
<button className={styles.iconButton} title="Bearbeiten">
|
<button
|
||||||
|
className={styles.iconButton}
|
||||||
|
title="Bearbeiten"
|
||||||
|
onClick={() => onEdit(org)}
|
||||||
|
>
|
||||||
✏️
|
✏️
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -90,6 +191,30 @@ export const TrusteeOrganisationsView: React.FC = () => {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</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
|
* 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 React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { useTrusteePositions, useTrusteePositionOperations } from '../../../hooks/useTrustee';
|
import { useTrusteePositions, useTrusteePositionOperations, TrusteePosition } from '../../../hooks/useTrustee';
|
||||||
|
import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions';
|
||||||
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
||||||
|
import { Popup } from '../../../components/UiComponents/Popup/Popup';
|
||||||
|
import { TrusteeEditForm, FieldConfig } from './components';
|
||||||
import styles from './TrusteeViews.module.css';
|
import styles from './TrusteeViews.module.css';
|
||||||
|
|
||||||
export const TrusteePositionsView: React.FC = () => {
|
export const TrusteePositionsView: React.FC = () => {
|
||||||
const { items: positions, loading, error, refetch } = useTrusteePositions();
|
const { items: positions, loading, error, refetch } = useTrusteePositions();
|
||||||
const { handleDelete, deletingItems } = useTrusteePositionOperations();
|
const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteePositionOperations();
|
||||||
const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteePosition');
|
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>;
|
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
|
// Formatiere Betrag
|
||||||
const formatAmount = (amount: number, currency: string) => {
|
const formatAmount = (amount: number, currency: string) => {
|
||||||
return new Intl.NumberFormat('de-CH', {
|
return new Intl.NumberFormat('de-CH', {
|
||||||
|
|
@ -39,12 +199,22 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
}).format(amount);
|
}).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 (
|
return (
|
||||||
<div className={styles.listView}>
|
<div className={styles.listView}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className={styles.primaryButton}>
|
<button className={styles.primaryButton} onClick={onCreate}>
|
||||||
+ Neue Position
|
+ Neue Position
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -62,21 +232,43 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
<table className={styles.dataTable}>
|
<table className={styles.dataTable}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Beschreibung</th>
|
<th>Valuta</th>
|
||||||
<th>Firma</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>
|
<th>Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{positions.map((pos) => (
|
{positions.map((pos) => (
|
||||||
<tr key={pos.id}>
|
<tr key={pos.id}>
|
||||||
<td>{pos.desc}</td>
|
<td>{formatDate(pos.valuta)}</td>
|
||||||
<td>{pos.company}</td>
|
<td>{pos.company || '-'}</td>
|
||||||
<td>{formatAmount(pos.bookingAmount, pos.bookingCurrency)}</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}>
|
<td className={styles.actions}>
|
||||||
{canUpdate && (
|
{canUpdate && (
|
||||||
<button className={styles.iconButton} title="Bearbeiten">
|
<button
|
||||||
|
className={styles.iconButton}
|
||||||
|
title="Bearbeiten"
|
||||||
|
onClick={() => onEdit(pos)}
|
||||||
|
>
|
||||||
✏️
|
✏️
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -96,6 +288,36 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,48 @@
|
||||||
/**
|
/**
|
||||||
* TrusteeRolesView
|
* 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 React, { useState, useMemo } from 'react';
|
||||||
import { useTrusteeRoles, useTrusteeRoleOperations } from '../../../hooks/useTrustee';
|
import { useTrusteeRoles, useTrusteeRoleOperations, TrusteeRole } from '../../../hooks/useTrustee';
|
||||||
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
||||||
|
import { Popup } from '../../../components/UiComponents/Popup/Popup';
|
||||||
|
import { TrusteeEditForm, FieldConfig } from './components';
|
||||||
import styles from './TrusteeViews.module.css';
|
import styles from './TrusteeViews.module.css';
|
||||||
|
|
||||||
export const TrusteeRolesView: React.FC = () => {
|
export const TrusteeRolesView: React.FC = () => {
|
||||||
const { items: roles, loading, error, refetch } = useTrusteeRoles();
|
const { items: roles, loading, error, refetch } = useTrusteeRoles();
|
||||||
const { handleDelete, deletingItems } = useTrusteeRoleOperations();
|
const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteeRoleOperations();
|
||||||
const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeRole');
|
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) {
|
if (loading) {
|
||||||
return <div className={styles.loading}>Lade Rollen...</div>;
|
return <div className={styles.loading}>Lade Rollen...</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -23,7 +52,7 @@ export const TrusteeRolesView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDelete = async (roleId: string) => {
|
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);
|
const success = await handleDelete(roleId);
|
||||||
if (success) {
|
if (success) {
|
||||||
refetch();
|
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 (
|
return (
|
||||||
<div className={styles.listView}>
|
<div className={styles.listView}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className={styles.primaryButton}>
|
<button className={styles.primaryButton} onClick={onCreate}>
|
||||||
+ Neue Rolle
|
+ Neue Rolle
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -45,6 +117,11 @@ export const TrusteeRolesView: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Tabelle */}
|
||||||
{roles.length === 0 ? (
|
{roles.length === 0 ? (
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
|
|
@ -62,11 +139,15 @@ export const TrusteeRolesView: React.FC = () => {
|
||||||
<tbody>
|
<tbody>
|
||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
<tr key={role.id}>
|
<tr key={role.id}>
|
||||||
<td><code>{role.id}</code></td>
|
<td className={styles.monospace}>{role.id}</td>
|
||||||
<td>{role.desc}</td>
|
<td>{role.desc}</td>
|
||||||
<td className={styles.actions}>
|
<td className={styles.actions}>
|
||||||
{canUpdate && (
|
{canUpdate && (
|
||||||
<button className={styles.iconButton} title="Bearbeiten">
|
<button
|
||||||
|
className={styles.iconButton}
|
||||||
|
title="Bearbeiten"
|
||||||
|
onClick={() => onEdit(role)}
|
||||||
|
>
|
||||||
✏️
|
✏️
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -86,6 +167,29 @@ export const TrusteeRolesView: React.FC = () => {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -310,3 +310,177 @@
|
||||||
:global(.dark-theme) .infoLabel {
|
:global(.dark-theme) .infoLabel {
|
||||||
color: var(--text-tertiary-dark, #888);
|
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 { TrusteeOrganisationsView } from './TrusteeOrganisationsView';
|
||||||
export { TrusteeDocumentsView } from './TrusteeDocumentsView';
|
export { TrusteeDocumentsView } from './TrusteeDocumentsView';
|
||||||
export { TrusteePositionsView } from './TrusteePositionsView';
|
export { TrusteePositionsView } from './TrusteePositionsView';
|
||||||
|
export { TrusteePositionDocumentsView } from './TrusteePositionDocumentsView';
|
||||||
export { TrusteeRolesView } from './TrusteeRolesView';
|
export { TrusteeRolesView } from './TrusteeRolesView';
|
||||||
export { TrusteeAccessView } from './TrusteeAccessView';
|
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