diff --git a/src/App.tsx b/src/App.tsx index 0f9f8af..da7e872 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,7 +50,7 @@ import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflow import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; // Migrate Pages (temporary - to be migrated to feature instances) -import { ChatbotPage, PekPage, SpeechPage } from './pages/migrate'; +import { PekPage, SpeechPage } from './pages/migrate'; function App() { // Load saved theme preference and set app name on app mount @@ -128,7 +128,6 @@ function App() { {/* ============================================== */} {/* MIGRATE TO FEATURES (temporary) */} {/* ============================================== */} - } /> } /> } /> diff --git a/src/api/chatbotApi.ts b/src/api/chatbotApi.ts index 80f3eef..23e887e 100644 --- a/src/api/chatbotApi.ts +++ b/src/api/chatbotApi.ts @@ -40,7 +40,7 @@ export interface StartChatbotResponse extends ChatbotWorkflow { } export interface ChatDataItem { - type: 'message' | 'log' | 'stat' | 'document'; + type: 'message' | 'log' | 'stat' | 'document' | 'stopped'; createdAt: number; item: Message | any; } @@ -57,8 +57,9 @@ export type SSEEventHandler = (item: ChatDataItem) => void; /** * Start a new chatbot workflow or continue an existing one with SSE streaming - * Endpoint: POST /api/chatbot/start/stream + * Endpoint: POST /api/chatbot/{instanceId}/start/stream * + * @param instanceId - Feature Instance ID * @param requestBody - Request body with prompt and optional workflowId * @param onEvent - Callback function called for each SSE event * @param onError - Optional error callback @@ -66,6 +67,7 @@ export type SSEEventHandler = (item: ChatDataItem) => void; * @returns Promise that resolves when stream completes */ export async function startChatbotStreamApi( + instanceId: string, requestBody: StartChatbotRequest, onEvent: SSEEventHandler, onError?: (error: Error) => void, @@ -73,6 +75,7 @@ export async function startChatbotStreamApi( ): Promise { try { // Prepare request body + console.log('[startChatbotStreamApi] instanceId:', instanceId); console.log('[startChatbotStreamApi] requestBody received:', JSON.stringify(requestBody, null, 2)); const body: any = { @@ -86,8 +89,8 @@ export async function startChatbotStreamApi( // Add workflowId to query params if provided const url = requestBody.workflowId - ? `/api/chatbot/start/stream?workflowId=${encodeURIComponent(requestBody.workflowId)}` - : '/api/chatbot/start/stream'; + ? `/api/chatbot/${instanceId}/start/stream?workflowId=${encodeURIComponent(requestBody.workflowId)}` + : `/api/chatbot/${instanceId}/start/stream`; // Get base URL from api instance const baseURL = api.defaults.baseURL || ''; @@ -200,26 +203,30 @@ export async function startChatbotStreamApi( /** * Stop a running chatbot workflow - * Endpoint: POST /api/chatbot/{workflowId}/stop + * Endpoint: POST /api/chatbot/{instanceId}/stop/{workflowId} */ export async function stopChatbotApi( request: ApiRequestFunction, + instanceId: string, workflowId: string ): Promise { + console.log('[stopChatbotApi] Calling stop endpoint:', `/api/chatbot/${instanceId}/stop/${workflowId}`, { instanceId, workflowId }); const data = await request({ - url: `/api/chatbot/${workflowId}/stop`, + url: `/api/chatbot/${instanceId}/stop/${workflowId}`, method: 'post' }); + console.log('[stopChatbotApi] Stop response:', data); return data as ChatbotWorkflow; } /** * Get chatbot threads/workflows - * Endpoint: GET /api/chatbot/threads + * Endpoint: GET /api/chatbot/{instanceId}/threads */ export async function getChatbotThreadsApi( request: ApiRequestFunction, + instanceId: string, pagination?: { page?: number; pageSize?: number } ): Promise<{ items: ChatbotWorkflow[]; metadata: any }> { const paginationParam = pagination ? JSON.stringify(pagination) : undefined; @@ -227,10 +234,10 @@ export async function getChatbotThreadsApi( ? { pagination: paginationParam } : undefined; - console.log(`[getChatbotThreadsApi] Fetching threads with params:`, requestParams); + console.log(`[getChatbotThreadsApi] instanceId: ${instanceId}, params:`, requestParams); const data = await request({ - url: '/api/chatbot/threads', + url: `/api/chatbot/${instanceId}/threads`, method: 'get', params: requestParams }) as any; @@ -251,22 +258,24 @@ export async function getChatbotThreadsApi( /** * Get a specific chatbot thread/workflow with its chat data - * Endpoint: GET /api/chatbot/threads?workflowId={id} + * Endpoint: GET /api/chatbot/{instanceId}/threads?workflowId={id} * * Backend returns: { workflow: ChatbotWorkflow, chatData: { items: ChatDataItem[] } } * * @param request - API request function + * @param instanceId - Feature Instance ID * @param workflowId - ID of the workflow to fetch * @returns Object containing workflow details and chatData with items array */ export async function getChatbotThreadApi( request: ApiRequestFunction, + instanceId: string, workflowId: string ): Promise<{ workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } }> { - console.log(`[getChatbotThreadApi] Fetching thread with workflowId: ${workflowId}`); + console.log(`[getChatbotThreadApi] instanceId: ${instanceId}, workflowId: ${workflowId}`); const data = await request({ - url: '/api/chatbot/threads', + url: `/api/chatbot/${instanceId}/threads`, method: 'get', params: { workflowId } }) as { workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } }; @@ -290,19 +299,21 @@ export async function getChatbotThreadApi( /** * Delete a chatbot workflow - * Endpoint: DELETE /api/chatbot/{workflowId} + * Endpoint: DELETE /api/chatbot/{instanceId}/{workflowId} * * @param request - API request function + * @param instanceId - Feature Instance ID * @param workflowId - ID of the workflow to delete * @returns Success status */ export async function deleteChatbotWorkflowApi( request: ApiRequestFunction, + instanceId: string, workflowId: string ): Promise { try { await request({ - url: `/api/chatbot/${workflowId}`, + url: `/api/chatbot/${instanceId}/${workflowId}`, method: 'delete' }); return true; diff --git a/src/api/featuresApi.ts b/src/api/featuresApi.ts index eac7003..f6eb298 100644 --- a/src/api/featuresApi.ts +++ b/src/api/featuresApi.ts @@ -171,14 +171,55 @@ export async function fetchMyFeatures(): Promise { try { console.log('📡 featuresApi: Fetching /api/features/my'); const response = await api.get('/api/features/my'); - console.log('✅ featuresApi: Loaded features:', { - mandateCount: response.data.mandates.length, - totalInstances: response.data.mandates - .flatMap(m => m.features) - .flatMap(f => f.instances) - .length, + + // Get the actual data (response.data contains the FeaturesMyResponse) + const data = response.data; + + // DEBUG: Log all chatbot instances and their permissions + console.log('🔍 [DEBUG] featuresApi: Full response received', { + response, + data, + hasMandates: !!data?.mandates, + mandateCount: data?.mandates?.length || 0, }); - return response.data; + + if (data?.mandates) { + data.mandates.forEach(mandate => { + mandate.features.forEach(feature => { + if (feature.code === 'chatbot') { + console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', { + mandateId: mandate.id, + mandateName: mandate.name, + featureCode: feature.code, + instanceCount: feature.instances.length, + }); + feature.instances.forEach(instance => { + console.log('🔍 [DEBUG] featuresApi: Chatbot Instance Details:', { + instanceId: instance.id, + instanceLabel: instance.instanceLabel, + featureCode: instance.featureCode, + userRoles: instance.userRoles, + permissions: instance.permissions, + views: instance.permissions?.views, + viewKeys: instance.permissions?.views ? Object.keys(instance.permissions.views) : [], + hasConversationsView: instance.permissions?.views?.['chatbot-conversations'] || + instance.permissions?.views?.['ui.feature.chatbot.conversations'] || + instance.permissions?.views?.['_all'], + }); + }); + } + }); + }); + } + + console.log('✅ featuresApi: Loaded features:', { + mandateCount: data?.mandates?.length || 0, + totalInstances: data?.mandates + ?.flatMap(m => m.features) + ?.flatMap(f => f.instances) + ?.length || 0, + }); + return data; } catch (error) { console.error('❌ featuresApi: Error fetching features:', error); throw error; diff --git a/src/hooks/useChatbot.ts b/src/hooks/useChatbot.ts new file mode 100644 index 0000000..f2d06ed --- /dev/null +++ b/src/hooks/useChatbot.ts @@ -0,0 +1,444 @@ +/** + * useChatbot Hook + * + * Hook for managing chatbot conversations, messages, and chat functionality. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useApiRequest } from './useApi'; +import { + getChatbotThreadsApi, + getChatbotThreadApi, + startChatbotStreamApi, + stopChatbotApi, + deleteChatbotWorkflowApi, + type ChatbotWorkflow, + type ChatDataItem, + type StartChatbotRequest +} from '../api/chatbotApi'; +import { Message } from '../components/UiComponents/Messages/MessagesTypes'; +import { useInstanceId } from './useCurrentInstance'; + +export interface ChatbotHookReturn { + // Threads/Conversations + threads: ChatbotWorkflow[]; + selectedThreadId: string | null; + loadingThreads: boolean; + error: string | null; + + // Messages + messages: Message[]; + loadingMessages: boolean; + + // Current workflow state + currentWorkflowId: string | null; + isStreaming: boolean; + + // Actions + selectThread: (workflowId: string) => Promise; + createNewThread: () => void; + sendMessage: (input: string, files?: Array<{ id: string; name: string }>) => Promise; + stopStreaming: () => Promise; + deleteThread: (workflowId: string) => Promise; + refreshThreads: () => Promise; + + // Input form state + inputValue: string; + setInputValue: (value: string) => void; +} + +/** + * Main chatbot hook + */ +export function useChatbot(): ChatbotHookReturn { + const { request } = useApiRequest(); + const instanceId = useInstanceId(); + + // Threads state + const [threads, setThreads] = useState([]); + const [selectedThreadId, setSelectedThreadId] = useState(null); + const [loadingThreads, setLoadingThreads] = useState(false); + + // Messages state + const [messages, setMessages] = useState([]); + const [loadingMessages, setLoadingMessages] = useState(false); + + // Current workflow state + const [currentWorkflowId, setCurrentWorkflowId] = useState(null); + const [isStreaming, setIsStreaming] = useState(false); + + // Error state + const [error, setError] = useState(null); + + // Input state + const [inputValue, setInputValue] = useState(''); + + // Ref to track if component is mounted + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + // Load threads + const refreshThreads = useCallback(async () => { + if (!instanceId) return; + + setLoadingThreads(true); + setError(null); + try { + const result = await getChatbotThreadsApi(request, instanceId); + if (isMountedRef.current) { + setThreads(result.items || []); + } + } catch (err: any) { + console.error('Error loading threads:', err); + if (isMountedRef.current) { + setError(err.message || 'Fehler beim Laden der Konversationen'); + } + } finally { + if (isMountedRef.current) { + setLoadingThreads(false); + } + } + }, [request, instanceId]); + + // Load messages for a thread + const loadThreadMessages = useCallback(async (workflowId: string) => { + if (!instanceId) return; + + setLoadingMessages(true); + setError(null); + try { + const result = await getChatbotThreadApi(request, instanceId, workflowId); + if (isMountedRef.current) { + // Extract messages from chatData items + const messageItems = (result.chatData?.items || []) + .filter((item: ChatDataItem) => item.type === 'message') + .map((item: ChatDataItem) => item.item as Message); + + setMessages(messageItems); + setCurrentWorkflowId(workflowId); + } + } catch (err: any) { + console.error('Error loading thread messages:', err); + if (isMountedRef.current) { + setError(err.message || 'Fehler beim Laden der Nachrichten'); + setMessages([]); + } + } finally { + if (isMountedRef.current) { + setLoadingMessages(false); + } + } + }, [request, instanceId]); + + // Select a thread + const selectThread = useCallback(async (workflowId: string) => { + setSelectedThreadId(workflowId); + await loadThreadMessages(workflowId); + }, [loadThreadMessages]); + + // Create new thread + const createNewThread = useCallback(() => { + setSelectedThreadId(null); + setMessages([]); + setCurrentWorkflowId(null); + setInputValue(''); + }, []); + + // Send message + const sendMessage = useCallback(async ( + input: string, + files?: Array<{ id: string; name: string }> + ) => { + if (!input.trim() || isStreaming || !instanceId) return; + + setError(null); + setIsStreaming(true); + + // Store the input message content to track duplicates + const inputMessageContent = input.trim(); + + // Add user message immediately for better UX + const tempUserMessageId = `temp-user-${Date.now()}`; + const userMessage: Message = { + id: tempUserMessageId, + workflowId: currentWorkflowId || '', + role: 'user', + message: inputMessageContent, + publishedAt: Date.now() + }; + + setMessages(prev => [...prev, userMessage]); + setInputValue(''); + + try { + const requestBody: StartChatbotRequest = { + prompt: input, + workflowId: currentWorkflowId || undefined, + listFileId: files?.map(f => f.id), + userLanguage: navigator.language || 'de' + }; + + let newWorkflowId: string | null = null; + + await startChatbotStreamApi( + instanceId, + requestBody, + (item: ChatDataItem) => { + if (!isMountedRef.current) return; + + // Handle stopped event + if (item.type === 'stopped') { + console.log('Received stopped event from backend'); + setIsStreaming(false); + return; + } + + // Handle workflow update + if (item.type === 'stat' && item.item?.id) { + newWorkflowId = item.item.id; + setCurrentWorkflowId(item.item.id); + if (!selectedThreadId) { + setSelectedThreadId(item.item.id); + } + // Check if workflow status is stopped + if (item.item.status === 'stopped') { + console.log('Workflow status is stopped'); + setIsStreaming(false); + } + } + + // Handle messages + if (item.type === 'message' && item.item) { + const message = item.item as Message; + + // Extract workflowId from message if available and not yet set + if (message.workflowId) { + const extractedWorkflowId = message.workflowId; + // Update local variable and state if not already set + if (!newWorkflowId) { + newWorkflowId = extractedWorkflowId; + console.log('Extracting workflowId from message:', extractedWorkflowId); + } + // Always update state to ensure we have the latest workflowId + setCurrentWorkflowId(prev => { + if (!prev) { + console.log('Setting currentWorkflowId from message:', extractedWorkflowId); + return extractedWorkflowId; + } + return prev; + }); + if (!selectedThreadId) { + setSelectedThreadId(extractedWorkflowId); + } + } + + setMessages(prev => { + // Check if message already exists by ID + if (prev.some(m => m.id === message.id)) { + return prev; + } + + // For user messages, check if we already have a temporary one with same content + // Only replace if it's the temporary message we just created (by ID match) + if (message.role === 'user' && message.message === inputMessageContent) { + // Check if we have the exact temporary message we created + const hasTempMessage = prev.some(m => m.id === tempUserMessageId); + if (hasTempMessage) { + // Replace the temporary message with the real one from backend + return prev.map(m => + m.id === tempUserMessageId ? message : m + ); + } + // If no temp message found, check if this is a duplicate of an existing real message + const isDuplicate = prev.some(m => + m.role === 'user' && + m.message === inputMessageContent && + !m.id.startsWith('temp-') + ); + if (isDuplicate) { + return prev; // Don't add duplicate + } + } + + // For other messages, check for duplicates by role and content (more lenient check) + const isDuplicate = prev.some(m => { + // Exact ID match + if (m.id === message.id) return true; + // For same role and content, check if it's a duplicate + if (m.role === message.role && m.message === message.message) { + // If it's a user message, it's definitely a duplicate + if (message.role === 'user') return true; + // For assistant messages, check if timestamps are very close (within 1 second) + if (m.publishedAt && message.publishedAt) { + return Math.abs(m.publishedAt - message.publishedAt) < 1000; + } + } + return false; + }); + + if (isDuplicate) return prev; + return [...prev, message]; + }); + } + }, + (err: Error) => { + console.error('Stream error:', err); + if (isMountedRef.current) { + setError(err.message || 'Fehler beim Senden der Nachricht'); + setIsStreaming(false); + } + }, + () => { + if (isMountedRef.current) { + setIsStreaming(false); + // Refresh threads to get updated list + refreshThreads(); + } + } + ); + + // Refresh threads after completion + if (newWorkflowId) { + await refreshThreads(); + } + } catch (err: any) { + console.error('Error sending message:', err); + if (isMountedRef.current) { + setError(err.message || 'Fehler beim Senden der Nachricht'); + setIsStreaming(false); + } + } + }, [currentWorkflowId, selectedThreadId, isStreaming, instanceId, refreshThreads]); + + // Stop streaming + const stopStreaming = useCallback(async () => { + if (!instanceId) { + console.warn('Cannot stop: missing instanceId', { instanceId }); + return; + } + + if (!isStreaming) { + console.warn('Cannot stop: not currently streaming'); + return; + } + + // Immediately reset UI state for instant feedback + setIsStreaming(false); + console.log('UI reset immediately after stop button click'); + + // Try to get workflowId from currentWorkflowId, or from the latest message + let workflowIdToStop = currentWorkflowId; + if (!workflowIdToStop && messages.length > 0) { + // Try to extract workflowId from the latest message + const latestMessage = messages[messages.length - 1]; + if (latestMessage.workflowId) { + workflowIdToStop = latestMessage.workflowId; + console.log('Extracted workflowId from latest message:', workflowIdToStop); + } + } + + if (!workflowIdToStop) { + console.warn('Cannot stop: missing workflowId, but UI already reset', { + currentWorkflowId, + messagesCount: messages.length, + latestMessage: messages.length > 0 ? messages[messages.length - 1] : null + }); + // UI already reset above, just return + return; + } + + // Send stop request to backend (fire and forget - UI already reset) + try { + console.log('Sending stop request to backend for workflow:', workflowIdToStop); + // Don't await - let it run in background, UI is already reset + stopChatbotApi(request, instanceId, workflowIdToStop).catch((err: any) => { + console.error('Error stopping stream on backend (non-blocking):', err); + // Optionally show a non-intrusive error notification + if (isMountedRef.current) { + // Don't reset isStreaming again as it's already false + // Just log the error + console.warn('Backend stop request failed, but UI was already reset'); + } + }); + } catch (err: any) { + console.error('Error initiating stop request:', err); + // UI already reset, so just log the error + } + }, [currentWorkflowId, isStreaming, instanceId, request, messages]); + + // Delete thread + const deleteThread = useCallback(async (workflowId: string) => { + if (!instanceId) return; + + try { + await deleteChatbotWorkflowApi(request, instanceId, workflowId); + + // If deleted thread was selected, clear selection + if (selectedThreadId === workflowId) { + createNewThread(); + } + + // Refresh threads list + await refreshThreads(); + } catch (err: any) { + console.error('Error deleting thread:', err); + setError(err.message || 'Fehler beim Löschen der Konversation'); + } + }, [request, instanceId, selectedThreadId, createNewThread, refreshThreads]); + + // Initial load + useEffect(() => { + if (instanceId) { + refreshThreads(); + } + }, [instanceId, refreshThreads]); + + return { + threads, + selectedThreadId, + loadingThreads, + error, + messages, + loadingMessages, + currentWorkflowId, + isStreaming, + selectThread, + createNewThread, + sendMessage, + stopStreaming, + deleteThread, + refreshThreads, + inputValue, + setInputValue + }; +} + +/** + * Hook factory for use in GenericPageData inputFormConfig + */ +export function createChatbotHook() { + return () => { + const chatbot = useChatbot(); + + return { + messages: chatbot.messages, + loading: chatbot.loadingMessages || chatbot.isStreaming, + error: chatbot.error, + data: [], + inputValue: chatbot.inputValue, + onInputChange: chatbot.setInputValue, + handleSubmit: async () => { + await chatbot.sendMessage(chatbot.inputValue); + }, + isSubmitting: chatbot.isStreaming, + stopAction: chatbot.stopStreaming, + canStop: chatbot.isStreaming + }; + }; +} diff --git a/src/hooks/useInstancePermissions.tsx b/src/hooks/useInstancePermissions.tsx index e39556d..3d13ca7 100644 --- a/src/hooks/useInstancePermissions.tsx +++ b/src/hooks/useInstancePermissions.tsx @@ -127,11 +127,43 @@ export function useCanViewFeatureView(viewCode: string): boolean { const { instance, featureCode } = useCurrentInstance(); if (!instance?.permissions?.views) { + // DEBUG: Log for chatbot + if (featureCode === 'chatbot') { + console.log('🔍 [DEBUG] useCanViewFeatureView: No views permissions', { + viewCode, + featureCode, + instanceId: instance?.id, + hasPermissions: !!instance?.permissions, + hasViews: !!instance?.permissions?.views, + }); + } return false; } const views = instance.permissions.views; + // DEBUG: Log for chatbot + if (featureCode === 'chatbot') { + const parts = viewCode.split('-'); + const viewName = parts.length >= 2 ? parts.slice(1).join('-') : ''; + const fullObjectKey = `ui.feature.${featureCode}.${viewName}`; + + console.log('🔍 [DEBUG] useCanViewFeatureView: Checking permissions', { + viewCode, + featureCode, + viewName, + fullObjectKey, + instanceId: instance.id, + viewKeys: Object.keys(views), + hasWildcard: !!views["_all"], + hasLegacyView: !!views[viewCode], + hasFullObjectKey: !!views[fullObjectKey], + wildcardValue: views["_all"], + legacyValue: views[viewCode], + fullObjectKeyValue: views[fullObjectKey], + }); + } + // Check for wildcard "_all" permission first (item=None in backend = all views) if (views["_all"]) { return true; diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 25e133f..9e237d6 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -19,6 +19,9 @@ import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView'; import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView'; import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView'; +// Chatbot Views +import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView'; + import styles from './FeatureView.module.css'; // ============================================================================= @@ -46,9 +49,7 @@ const ChatworkflowFiles: React.FC = () => ( ); // Chatbot Views -const ChatbotConversations: React.FC = () => ( - -); +// ChatbotConversationsView is imported above const ChatbotSettings: React.FC = () => ( @@ -90,7 +91,7 @@ const VIEW_COMPONENTS: Record> = { files: ChatworkflowFiles, }, chatbot: { - conversations: ChatbotConversations, + conversations: ChatbotConversationsView, settings: ChatbotSettings, }, }; @@ -110,6 +111,25 @@ export const FeatureViewPage: React.FC = ({ view }) => { const viewCode = `${featureCode}-${view}`; const canView = useCanViewFeatureView(viewCode); + // DEBUG: Log permission check for chatbot + if (featureCode === 'chatbot') { + console.log('🔍 [DEBUG] FeatureView Permission Check:', { + featureCode, + view, + viewCode, + instanceId: instance?.id, + instanceLabel: instance?.instanceLabel, + isValid, + canView, + permissions: instance?.permissions, + views: instance?.permissions?.views, + viewKeys: instance?.permissions?.views ? Object.keys(instance.permissions.views) : [], + hasLegacyView: instance?.permissions?.views?.[viewCode], + hasFullObjectKey: instance?.permissions?.views?.[`ui.feature.${featureCode}.${view}`], + hasWildcard: instance?.permissions?.views?.['_all'], + }); + } + // Nicht valider Kontext if (!isValid || !featureCode || !instance) { return ; diff --git a/src/pages/migrate/ChatbotPage.tsx b/src/pages/migrate/ChatbotPage.tsx deleted file mode 100644 index 131e445..0000000 --- a/src/pages/migrate/ChatbotPage.tsx +++ /dev/null @@ -1,130 +0,0 @@ -/** - * ChatbotPage - * - * Simple chatbot interface - temporary global page. - * TODO: Migrate to feature instance. - */ - -import React, { useState, useEffect, useRef } from 'react'; -import styles from './MigratePages.module.css'; - -interface Message { - id: string; - role: 'user' | 'assistant' | 'system'; - content: string; - timestamp: Date; -} - -export const ChatbotPage: React.FC = () => { - const [messages, setMessages] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const messagesEndRef = useRef(null); - - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; - - useEffect(() => { - scrollToBottom(); - }, [messages]); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!inputValue.trim() || isLoading) return; - - const userMessage: Message = { - id: Date.now().toString(), - role: 'user', - content: inputValue, - timestamp: new Date() - }; - - setMessages(prev => [...prev, userMessage]); - setInputValue(''); - setIsLoading(true); - - // Simulate API call - replace with actual chatbot API - try { - // TODO: Replace with actual chatbot API call - await new Promise(resolve => setTimeout(resolve, 1000)); - - const assistantMessage: Message = { - id: (Date.now() + 1).toString(), - role: 'assistant', - content: 'Dies ist eine Platzhalter-Antwort. Der Chatbot wird zu einer Feature-Instanz migriert.', - timestamp: new Date() - }; - - setMessages(prev => [...prev, assistantMessage]); - } catch (error) { - console.error('Error:', error); - } finally { - setIsLoading(false); - } - }; - - return ( - - - Chatbot - - MIGRATE TO FEATURE - Einfache Chat-Oberfläche - - - - - - {messages.length === 0 ? ( - - Noch keine Nachrichten. Starten Sie eine Konversation! - - ) : ( - messages.map(message => ( - - - {message.content} - - - {message.timestamp.toLocaleTimeString('de-DE')} - - - )) - )} - {isLoading && ( - - - - - - )} - - - - - setInputValue(e.target.value)} - placeholder="Nachricht eingeben..." - disabled={isLoading} - className={styles.chatInput} - /> - - Senden - - - - - ); -}; - -export default ChatbotPage; diff --git a/src/pages/migrate/index.ts b/src/pages/migrate/index.ts index 55e9747..c7ed0d3 100644 --- a/src/pages/migrate/index.ts +++ b/src/pages/migrate/index.ts @@ -1,3 +1,2 @@ -export { ChatbotPage } from './ChatbotPage'; export { PekPage } from './PekPage'; export { SpeechPage } from './SpeechPage'; diff --git a/src/pages/views/chatbot/ChatbotConversationsView.tsx b/src/pages/views/chatbot/ChatbotConversationsView.tsx new file mode 100644 index 0000000..56b93bf --- /dev/null +++ b/src/pages/views/chatbot/ChatbotConversationsView.tsx @@ -0,0 +1,253 @@ +/** + * ChatbotConversationsView + * + * Chatbot interface with chat history sidebar and messages view. + * Similar to trustee views but hardcoded for chatbot feature. + */ + +import React, { useState } from 'react'; +import { useChatbot } from '../../../hooks/useChatbot'; +import { Messages } from '../../../components/UiComponents/Messages'; +import { TextField } from '../../../components/UiComponents/TextField'; +import { Button } from '../../../components/UiComponents/Button'; +import { AutoScroll } from '../../../components/UiComponents/AutoScroll'; +import { ChatMessage } from '../../../components/UiComponents/Messages/ChatMessages/ChatMessage'; +import { Message } from '../../../components/UiComponents/Messages/MessagesTypes'; +import { IoMdSend } from 'react-icons/io'; +import { MdStop } from 'react-icons/md'; +import { LuMessageSquare, LuTrash2 } from 'react-icons/lu'; +import messagesStyles from '../../../components/UiComponents/Messages/Messages.module.css'; +import styles from './ChatbotViews.module.css'; + +export const ChatbotConversationsView: React.FC = () => { + const { + threads, + selectedThreadId, + loadingThreads, + error, + messages, + loadingMessages, + isStreaming, + currentWorkflowId, + selectThread, + createNewThread, + sendMessage, + stopStreaming, + deleteThread, + refreshThreads, + inputValue, + setInputValue + } = useChatbot(); + + const [deletingId, setDeletingId] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!inputValue.trim() || isStreaming) return; + await sendMessage(inputValue); + }; + + const handleStop = async () => { + console.log('Stop button clicked', { + isStreaming, + currentWorkflowId, + selectedThreadId, + hasMessages: messages.length > 0 + }); + if (isStreaming) { + console.log('Calling stopStreaming...'); + try { + await stopStreaming(); + console.log('stopStreaming completed'); + } catch (error) { + console.error('Error in stopStreaming:', error); + } + } else { + console.warn('Stop button clicked but not streaming'); + } + }; + + const handleDeleteThread = async (e: React.MouseEvent, workflowId: string) => { + e.stopPropagation(); + if (window.confirm('Möchten Sie diese Konversation wirklich löschen?')) { + setDeletingId(workflowId); + try { + await deleteThread(workflowId); + } finally { + setDeletingId(null); + } + } + }; + + const formatDate = (timestamp?: number) => { + if (!timestamp) return ''; + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Gerade eben'; + if (diffMins < 60) return `Vor ${diffMins} Min`; + if (diffHours < 24) return `Vor ${diffHours} Std`; + if (diffDays < 7) return `Vor ${diffDays} Tagen`; + + return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + }; + + const getThreadTitle = (thread: any) => { + if (thread.name) return thread.name; + // Try to get first message content as title + return 'Neue Konversation'; + }; + + return ( + + {/* Chat History Sidebar */} + + + {/* Main Chat Area */} + + {/* Messages Area */} + + {loadingMessages && messages.length === 0 ? ( + + + Lade Nachrichten... + + ) : messages.length === 0 ? ( + + + Noch keine Nachrichten. Starte eine Konversation! + + + ) : ( + + + {messages.map((message) => ( + + ))} + {isStreaming && ( + + + + + + + + + + )} + + + )} + + + {/* Input Form */} + + + {isStreaming ? ( + + Stoppen + + ) : ( + + Senden + + )} + + + + ); +}; + +export default ChatbotConversationsView; diff --git a/src/pages/views/chatbot/ChatbotViews.module.css b/src/pages/views/chatbot/ChatbotViews.module.css new file mode 100644 index 0000000..6c66d2a --- /dev/null +++ b/src/pages/views/chatbot/ChatbotViews.module.css @@ -0,0 +1,431 @@ +/** + * Chatbot Views Shared Styles + */ + +.chatbotView { + display: flex; + height: calc(100vh - 200px); + min-height: 600px; + gap: 1rem; + background: var(--bg-primary, #ffffff); +} + +/* ============================================================================= + * Chat History Sidebar + * ============================================================================= */ + +.chatHistory { + width: 300px; + min-width: 250px; + display: flex; + flex-direction: column; + background: var(--surface-color, #f8f9fa); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + overflow: hidden; +} + +.chatHistoryHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + background: var(--bg-primary, #ffffff); +} + +.chatHistoryTitle { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +.newChatButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--primary-color, #2563eb); + border-radius: 6px; + background: var(--primary-color, #2563eb); + color: white; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.newChatButton:hover { + background: var(--primary-hover, #1d4ed8); +} + +.threadList { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.threadItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem; + margin-bottom: 0.5rem; + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; +} + +.threadItem:hover { + background: var(--hover-bg, rgba(0, 0, 0, 0.02)); + border-color: var(--primary-color, #2563eb); +} + +.threadItem.selected { + background: var(--primary-light, #eff6ff); + border-color: var(--primary-color, #2563eb); +} + +.threadContent { + flex: 1; + min-width: 0; +} + +.threadTitle { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary, #1a1a1a); + margin-bottom: 0.25rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.threadMeta { + font-size: 0.75rem; + color: var(--text-secondary, #666); +} + +.deleteButton { + display: flex; + align-items: center; + justify-content: center; + padding: 0.375rem; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-secondary, #666); + cursor: pointer; + transition: all 0.2s; + opacity: 0; +} + +.threadItem:hover .deleteButton { + opacity: 1; +} + +.deleteButton:hover { + background: var(--error-light, #fee2e2); + color: var(--error-color, #dc2626); +} + +.deleteButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ============================================================================= + * Chat Area + * ============================================================================= */ + +.chatArea { + flex: 1; + display: flex; + flex-direction: column; + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + overflow: hidden; +} + +.messagesArea { + flex: 1; + overflow-y: auto; + background: var(--bg-primary, #ffffff); + display: flex; + flex-direction: column; +} + +.messagesWrapper { + display: flex; + flex-direction: column; + flex: 1; + position: relative; +} + +/* ============================================================================= + * Typing Indicator (WhatsApp style) + * ============================================================================= */ + +.typingIndicator { + display: flex; + width: 100%; + justify-content: flex-start; + padding: 0; + margin: 0; +} + +.typingBubble { + display: inline-flex; + align-items: center; + padding: 12px 16px; + background-color: var(--color-surface, #f0f0f0); + color: var(--color-text, #1a1a1a); + border-radius: 18px; + border-bottom-left-radius: 4px; + max-width: 65%; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.typingDots { + display: flex; + align-items: center; + gap: 4px; + height: 20px; +} + +.typingDots span { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--color-gray, #999); + animation: typingBounce 1.4s infinite ease-in-out; +} + +.typingDots span:nth-child(1) { + animation-delay: 0s; +} + +.typingDots span:nth-child(2) { + animation-delay: 0.2s; +} + +.typingDots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes typingBounce { + 0%, 60%, 100% { + transform: translateY(0); + opacity: 0.7; + } + 30% { + transform: translateY(-8px); + opacity: 1; + } +} + +/* Dark theme support for typing indicator */ +:global(.dark-theme) .typingBubble { + background-color: var(--surface-dark, #2a2a2a); +} + +:global(.dark-theme) .typingDots span { + background-color: var(--text-secondary-dark, #aaa); +} + +.inputForm { + display: flex; + gap: 0.75rem; + padding: 1rem; + border-top: 1px solid var(--border-color, #e0e0e0); + background: var(--surface-color, #f8f9fa); +} + +.inputField { + flex: 1; +} + +/* ============================================================================= + * Loading & Error States + * ============================================================================= */ + +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + color: var(--text-secondary, #666); + gap: 1rem; +} + +.spinner { + width: 24px; + height: 24px; + border: 3px solid var(--border-color, #e0e0e0); + border-top-color: var(--primary-color, #2563eb); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.error { + padding: 1rem; + color: var(--error-color, #dc2626); + font-size: 0.875rem; +} + +.retryButton { + margin-top: 0.5rem; + padding: 0.5rem 1rem; + border: 1px solid var(--error-color, #dc2626); + border-radius: 6px; + background: transparent; + color: var(--error-color, #dc2626); + font-size: 0.875rem; + cursor: pointer; + transition: background 0.2s; +} + +.retryButton:hover { + background: var(--error-light, #fee2e2); +} + +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + text-align: center; + color: var(--text-secondary, #666); +} + +.emptyIcon { + font-size: 3rem; + color: var(--text-tertiary, #999); + margin-bottom: 1rem; +} + +.emptyState p { + margin: 0.5rem 0; + font-size: 0.875rem; +} + +.emptyHint { + font-size: 0.75rem; + color: var(--text-tertiary, #888); +} + +/* ============================================================================= + * Dark Theme + * ============================================================================= */ + +:global(.dark-theme) .chatbotView { + background: var(--surface-dark, #1a1a1a); +} + +:global(.dark-theme) .chatHistory { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #333); +} + +:global(.dark-theme) .chatHistoryHeader { + background: var(--surface-dark, #1a1a1a); + border-bottom-color: var(--border-dark, #333); +} + +:global(.dark-theme) .chatHistoryTitle { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .threadItem { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #333); +} + +:global(.dark-theme) .threadItem:hover { + background: var(--surface-dark, #2a2a2a); + border-color: var(--primary-color, #2563eb); +} + +:global(.dark-theme) .threadItem.selected { + background: var(--primary-dark, #1e3a8a); + border-color: var(--primary-color, #2563eb); +} + +:global(.dark-theme) .threadTitle { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .threadMeta { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .deleteButton { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .deleteButton:hover { + background: var(--error-dark, #450a0a); + color: var(--error-light, #fef2f2); +} + +:global(.dark-theme) .chatArea { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #333); +} + +:global(.dark-theme) .messagesArea { + background: var(--surface-dark, #1a1a1a); +} + +:global(.dark-theme) .inputForm { + background: var(--surface-dark, #1a1a1a); + border-top-color: var(--border-dark, #333); +} + +:global(.dark-theme) .loading { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .spinner { + border-color: var(--border-dark, #333); + border-top-color: var(--primary-color, #2563eb); +} + +:global(.dark-theme) .error { + color: var(--error-light, #fef2f2); +} + +:global(.dark-theme) .retryButton { + border-color: var(--error-color, #dc2626); + color: var(--error-light, #fef2f2); +} + +:global(.dark-theme) .retryButton:hover { + background: var(--error-dark, #450a0a); +} + +:global(.dark-theme) .emptyState { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .emptyIcon { + color: var(--text-tertiary-dark, #666); +} + +:global(.dark-theme) .emptyHint { + color: var(--text-tertiary-dark, #666); +} + +:global(.dark-theme) .typingDots span { + background: var(--text-secondary-dark, #aaa); +} diff --git a/src/pages/views/chatbot/index.ts b/src/pages/views/chatbot/index.ts new file mode 100644 index 0000000..7a71c9e --- /dev/null +++ b/src/pages/views/chatbot/index.ts @@ -0,0 +1,5 @@ +/** + * Chatbot Views Export + */ + +export { ChatbotConversationsView } from './ChatbotConversationsView'; diff --git a/src/stores/featureStore.tsx b/src/stores/featureStore.tsx index a080585..40ea54a 100644 --- a/src/stores/featureStore.tsx +++ b/src/stores/featureStore.tsx @@ -95,6 +95,20 @@ export const FeatureProvider: React.FC = ({ children }) => mandate.features.forEach(feature => { feature.instances.forEach(instance => { cache.set(instance.id, instance); + + // DEBUG: Log permissions for chatbot instances + if (instance.featureCode === 'chatbot') { + console.log('🔍 [DEBUG] Chatbot Instance Permissions (loadFeatures):', { + instanceId: instance.id, + instanceLabel: instance.instanceLabel, + featureCode: instance.featureCode, + userRoles: instance.userRoles, + permissions: instance.permissions, + views: instance.permissions?.views, + viewKeys: instance.permissions?.views ? Object.keys(instance.permissions.views) : [], + hasConversationsView: instance.permissions?.views?.['chatbot-conversations'] || instance.permissions?.views?.['ui.feature.chatbot.conversations'] || instance.permissions?.views?.['_all'], + }); + } }); }); }); @@ -128,6 +142,20 @@ export const FeatureProvider: React.FC = ({ children }) => mandate.features.forEach(feature => { feature.instances.forEach(instance => { cache.set(instance.id, instance); + + // DEBUG: Log permissions for chatbot instances + if (instance.featureCode === 'chatbot') { + console.log('🔍 [DEBUG] Chatbot Instance Permissions:', { + instanceId: instance.id, + instanceLabel: instance.instanceLabel, + featureCode: instance.featureCode, + userRoles: instance.userRoles, + permissions: instance.permissions, + views: instance.permissions?.views, + viewKeys: instance.permissions?.views ? Object.keys(instance.permissions.views) : [], + hasConversationsView: instance.permissions?.views?.['chatbot-conversations'] || instance.permissions?.views?.['ui.feature.chatbot.conversations'] || instance.permissions?.views?.['_all'], + }); + } }); }); }); diff --git a/work-around/chatbot.ts b/work-around/chatbot.ts new file mode 100644 index 0000000..68c7f76 --- /dev/null +++ b/work-around/chatbot.ts @@ -0,0 +1,79 @@ +import { GenericPageData } from '../../pageInterface'; +import { LuMessageSquare } from 'react-icons/lu'; +import { IoMdSend } from 'react-icons/io'; +import { MdStop } from 'react-icons/md'; +import { createChatbotHook } from '../../../../hooks/useChatbot'; + +export const chatbotPageData: GenericPageData = { + id: 'start-chatbot', + path: 'start/chatbot', + name: 'Chatbot', + description: 'Simple chatbot interface for conversations', + + // Parent page + parentPath: 'start', + + // Visual + icon: LuMessageSquare, + title: 'Chatbot', + subtitle: 'Chat with AI assistant', + + // No header buttons (simpler than dashboard) + headerButtons: [], + + // Content sections + content: [ + { + id: 'chatbot-history', + type: 'chatHistory', + chatHistoryConfig: { + emptyMessage: 'No chat history yet. Start a conversation to see it here.' + } + }, + { + id: 'chatbot-messages', + type: 'messages', + messagesConfig: { + variant: 'chat', + showDocuments: true, + showMetadata: false, + showProgress: false, + emptyMessage: 'No messages yet. Start a conversation to see messages here.' + } + }, + { + id: 'chatbot-input', + type: 'inputForm', + inputFormConfig: { + hookFactory: createChatbotHook, + placeholder: 'Type your message here...', + buttonLabel: 'Send', + stopButtonLabel: 'Stop', + buttonIcon: IoMdSend, + stopButtonIcon: MdStop, + buttonVariant: 'primary', + stopButtonVariant: 'danger', + buttonSize: 'md', + textFieldSize: 'md', + showFileUpload: false + } + } + ], + + // Page behavior + persistent: true, + preserveState: true, + preload: true, + moduleEnabled: true, + + + + // Lifecycle hooks + onActivate: async () => { + if (import.meta.env.DEV) console.log('Chatbot activated - state preserved'); + }, + onDeactivate: async () => { + if (import.meta.env.DEV) console.log('Chatbot deactivated - keeping state'); + } +}; + diff --git a/work-around/pek.ts b/work-around/pek.ts new file mode 100644 index 0000000..b46c6f2 --- /dev/null +++ b/work-around/pek.ts @@ -0,0 +1,124 @@ +import { GenericPageData } from '../../pageInterface'; +import { FaBuilding } from 'react-icons/fa'; +import { IoMdSend } from 'react-icons/io'; +import PekLocationInput from './pek/PekLocationInput'; +import PekMapView from './pek/PekMapView'; +import { usePek } from '../../../../hooks/usePek'; +import PekPageWrapper from './pek/PekPageWrapper'; +import { getUserDataCache } from '../../../../utils/userCache'; + +// Hook factory for PEK page +const createPekHook = () => { + return () => { + const pekData = usePek(); + + const handleSubmit = async () => { + await pekData.processCommand(pekData.commandInput); + }; + + return { + // Messages for command results + messages: pekData.commandResults, + // Loading states + loading: pekData.isProcessingCommand || pekData.isSearchingParcel, + error: pekData.commandError || pekData.parcelSearchError || pekData.locationError, + // Empty data array for compatibility + data: [], + // Input form properties (for command input) + inputValue: pekData.commandInput, + onInputChange: pekData.setCommandInput, + handleSubmit, + isSubmitting: pekData.isProcessingCommand + }; + }; +}; + +export const pekPageData: GenericPageData = { + id: 'pek', + path: 'start/real-estate/pek', + name: 'projects.title', + description: 'projects.description', + + // Parent page + parentPath: 'start.real-estate', + + // Visual + icon: FaBuilding, + title: 'projects.title', + subtitle: 'projects.subtitle', + + // Header buttons + headerButtons: [], + + // Content sections + content: [ + { + id: 'pek-description', + type: 'paragraph', + content: 'projects.description_text' + }, + { + id: 'pek-location-input', + type: 'custom', + customComponent: PekLocationInput + }, + { + id: 'pek-map-view', + type: 'custom', + customComponent: PekMapView + }, + { + id: 'pek-command-input', + type: 'inputForm', + inputFormConfig: { + hookFactory: createPekHook, + placeholder: 'projects.command.placeholder', + buttonLabel: 'Senden', + buttonIcon: IoMdSend, + buttonVariant: 'primary', + buttonSize: 'md', + textFieldSize: 'md' + } + }, + { + id: 'pek-command-results', + type: 'messages', + messagesConfig: { + variant: 'chat', + showDocuments: false, + showMetadata: false, + showProgress: false, + emptyMessage: 'projects.command.empty' + } + } + ], + + // Page behavior + persistent: false, + preload: false, + preserveState: true, + moduleEnabled: true, + + // Sidebar + order: 10, + + // Privilege checker: deny access for "user" role + privilegeChecker: async () => { + const userData = getUserDataCache(); + const roleLabels = Array.isArray(userData?.roleLabels) ? userData.roleLabels : []; + // Deny access if user has "user" role + return !roleLabels.includes('user'); + }, + + // Custom component wrapper with PekProvider + customComponent: PekPageWrapper, + + // Lifecycle hooks + onActivate: async () => { + if (import.meta.env.DEV) console.log('PEK page activated'); + }, + onDeactivate: async () => { + if (import.meta.env.DEV) console.log('PEK page deactivated'); + } +}; + diff --git a/work-around/pek/PekLocationInput.module.css b/work-around/pek/PekLocationInput.module.css new file mode 100644 index 0000000..463346b --- /dev/null +++ b/work-around/pek/PekLocationInput.module.css @@ -0,0 +1,64 @@ +.locationInputContainer { + width: 100%; + margin-bottom: 1.5rem; +} + +.fieldsRow { + display: flex; + gap: 1rem; + align-items: flex-end; +} + +.fieldWrapper { + flex: 1; +} + +.buttonsWrapper { + display: flex; + flex-direction: row; + gap: 0.5rem; + min-width: 150px; +} + +.searchButton { + white-space: nowrap; +} + +.locationButton { + white-space: nowrap; +} + +@media (max-width: 1024px) { + .fieldsRow { + flex-wrap: wrap; + } + + .buttonsWrapper { + width: 100%; + } + + .fieldWrapper { + min-width: calc(50% - 0.5rem); + } +} + +@media (max-width: 768px) { + .fieldsRow { + flex-direction: column; + } + + .fieldWrapper { + width: 100%; + min-width: 100%; + } + + .buttonsWrapper { + width: 100%; + } + + .searchButton, + .locationButton { + flex: 1; + } +} + diff --git a/work-around/pek/PekLocationInput.tsx b/work-around/pek/PekLocationInput.tsx new file mode 100644 index 0000000..ffbf062 --- /dev/null +++ b/work-around/pek/PekLocationInput.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { TextField, Button } from '../../../../../components/UiComponents'; +import { FaLocationArrow } from 'react-icons/fa'; +import { IoMdSend } from 'react-icons/io'; +import { usePekContext } from '../../../../../contexts/PekContext'; +import styles from './PekLocationInput.module.css'; + +const PekLocationInput: React.FC = () => { + const { + kanton: _kanton, + setKanton: _setKanton, + gemeinde: _gemeinde, + setGemeinde: _setGemeinde, + adresse, + setAdresse, + buildLocationString, + useCurrentLocation, + isGettingLocation, + locationError: _locationError, + searchParcel, + isSearchingParcel + } = usePekContext(); + + const handleSearch = async () => { + const locationString = buildLocationString(); + if (locationString.trim()) { + await searchParcel(locationString.trim(), true); + } + }; + + const handleUseCurrentLocation = async () => { + await useCurrentLocation(); + }; + + + return ( + + + + { + if (e.key === 'Enter') { + e.preventDefault(); + handleSearch(); + } + }} + /> + + + + Suchen + + + Meine Position + + + + + ); +}; + +export default PekLocationInput; + diff --git a/work-around/pek/PekMapView.tsx b/work-around/pek/PekMapView.tsx new file mode 100644 index 0000000..8f1035e --- /dev/null +++ b/work-around/pek/PekMapView.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { MapView, ParcelInfoPanel } from '../../../../../components/UiComponents'; +import { usePekContext } from '../../../../../contexts/PekContext'; + +const PekMapView: React.FC = () => { + const { + mapCenter, + mapZoomBounds, + parcelGeometries, + handleMapClick, + handleParcelClick, + selectedParcels, + removeParcel, + isPanelOpen, + setIsPanelOpen + } = usePekContext(); + + // Aggregate all adjacent parcels from all selected parcels + const allAdjacentParcels = React.useMemo(() => { + const adjacentSet = new Map(); + selectedParcels.forEach(parcel => { + if (parcel.adjacent_parcels) { + parcel.adjacent_parcels.forEach((adj: { id: string }) => { + if (!adjacentSet.has(adj.id)) { + adjacentSet.set(adj.id, adj); + } + }); + } + }); + return Array.from(adjacentSet.values()); + }, [selectedParcels]); + + return ( + <> + + + + + setIsPanelOpen(false)} + parcels={selectedParcels} + onRemoveParcel={removeParcel} + adjacentParcels={allAdjacentParcels} + /> + > + ); +}; + +export default PekMapView; + diff --git a/work-around/pek/PekPageWrapper.tsx b/work-around/pek/PekPageWrapper.tsx new file mode 100644 index 0000000..48f20f9 --- /dev/null +++ b/work-around/pek/PekPageWrapper.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { PekProvider } from '../../../../../contexts/PekContext'; +import PageRenderer from '../../../PageRenderer'; +import { pekPageData } from '../pek'; + +const PekPageWrapper: React.FC = () => { + // Create a version of pageData without customComponent to avoid infinite loop + const { customComponent, ...pageDataWithoutCustom } = pekPageData; + + return ( + + + + ); +}; + +export default PekPageWrapper; + diff --git a/work-around/pek/pek-tables.ts b/work-around/pek/pek-tables.ts new file mode 100644 index 0000000..45e2cfe --- /dev/null +++ b/work-around/pek/pek-tables.ts @@ -0,0 +1,208 @@ +import { GenericPageData } from '../../pageInterface'; +import { FaTable, FaPlus } from 'react-icons/fa'; +import { createProjectsTableHook, createParzellenTableHook } from '../../../../hooks/usePekTables'; +import { getUserDataCache } from '../../../../utils/userCache'; + +export const pekTablesPageData: GenericPageData = { + id: 'pek-tables', + path: 'start/real-estate/pek-tables', + name: 'Projektmanagement', + description: 'Projektmanagement mit Tabellen', + + // Parent page + parentPath: 'start.real-estate', + + // Visual + icon: FaTable, + title: 'Projektmanagement', + subtitle: 'Datenverwaltung', + + // Header buttons + headerButtons: [ + { + id: 'create-project', + label: 'Neues Projekt', + variant: 'primary', + size: 'lg', + icon: FaPlus, + formConfig: { + fields: [], // Will be generated from attributes via generateEditFieldsFromAttributes + popupTitle: 'Neues Projekt erstellen', + popupSize: 'large', + createOperationName: 'handleProjectCreate', + multiStep: true // Enable multi-step form with Step 1 (label) and Step 2 (parcel selection) + }, + disabled: (hookData: any) => { + if (!hookData?.permissions) return { disabled: false }; + const hasCreate = hookData.permissions.create !== 'n' && hookData.permissions.view; + return { disabled: !hasCreate, message: 'No permission to create projects' }; + } + } + ], + + // Content sections + content: [ + { + id: 'projektmanagement-layout', + type: 'columns', + columnsConfig: { + columns: [ + { + id: 'main-column', + width: '3fr', + content: [ + { + id: 'tables-tabs', + type: 'tabs', + tabsConfig: { + tabs: [ + { + id: 'projects', + label: 'Projekte', + content: [ + { + id: 'projects-table', + type: 'table', + tableConfig: { + hookFactory: createProjectsTableHook, + searchable: true, + filterable: true, + sortable: true, + resizable: true, + pagination: true, + pageSize: 10, + emptyMessage: 'Noch keine Projekte erstellt, erstelle jetzt dein erstes Projekt!', + actionButtons: [ + { + type: 'edit', + title: 'common.edit', + idField: 'id', + operationName: 'handleProjectUpdate', + loadingStateName: 'editingProjects', + fetchItemFunctionName: 'fetchProjectById', + 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 projects' }; + } + }, + { + type: 'delete', + title: 'common.delete', + idField: 'id', + operationName: 'handleDelete', + loadingStateName: 'deletingProjects', + 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 projects' }; + } + } + ] + } + } + ] + }, + { + id: 'parzellen', + label: 'Parzellen', + content: [ + { + id: 'parzellen-table', + type: 'table', + tableConfig: { + hookFactory: createParzellenTableHook, + searchable: true, + filterable: true, + sortable: true, + resizable: true, + pagination: true, + pageSize: 10, + emptyMessage: 'Noch keine Parzellen erstellt, erstelle jetzt dein erstes Projekt und füge eine Parzelle hinzu!', + actionButtons: [ + { + type: 'view', + title: 'common.view', + idField: 'id', + nameField: 'label', + operationName: 'handleParzelleView', + loadingStateName: 'viewingParzellen', + disabled: (hookData: any) => { + if (!hookData?.permissions) return { disabled: false }; + const hasRead = hookData.permissions.read !== 'n' && hookData.permissions.view; + return { disabled: !hasRead, message: 'No permission to view parzellen' }; + } + }, + { + type: 'edit', + title: 'common.edit', + idField: 'id', + operationName: 'handleParzelleUpdate', + loadingStateName: 'editingParzellen', + fetchItemFunctionName: 'fetchParzelleById', + 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 parzellen' }; + } + }, + { + type: 'delete', + title: 'common.delete', + idField: 'id', + operationName: 'handleDelete', + loadingStateName: 'deletingParzellen', + 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 parzellen' }; + } + } + ] + } + } + ] + } + ], + defaultTabId: 'projects' + } + } + ] + }, + { + id: 'sidebar-column', + width: '1fr', + content: [] + } + ], + gap: '1rem' + } + } + ], + + // Page behavior + persistent: false, + preload: false, + preserveState: true, + moduleEnabled: true, + + // Sidebar + order: 11, + + // Privilege checker: deny access for "user" role + privilegeChecker: async () => { + const userData = getUserDataCache(); + const roleLabels = Array.isArray(userData?.roleLabels) ? userData.roleLabels : []; + // Deny access if user has "user" role + return !roleLabels.includes('user'); + }, + + // Lifecycle hooks + onActivate: async () => { + if (import.meta.env.DEV) console.log('PEK Tables page activated'); + }, + onDeactivate: async () => { + if (import.meta.env.DEV) console.log('PEK Tables page deactivated'); + } +}; +
- MIGRATE TO FEATURE - Einfache Chat-Oberfläche -
Noch keine Nachrichten. Starten Sie eine Konversation!