feat:chatbot dynamisch geladen

This commit is contained in:
Ida Dittrich 2026-01-29 15:37:38 +01:00
parent 9b10f73d09
commit 0af4d7c30b
19 changed files with 1930 additions and 158 deletions

View file

@ -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) */}
{/* ============================================== */}
<Route path="chatbot" element={<ChatbotPage />} />
<Route path="pek" element={<PekPage />} />
<Route path="speech" element={<SpeechPage />} />

View file

@ -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<void> {
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<ChatbotWorkflow> {
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<boolean> {
try {
await request({
url: `/api/chatbot/${workflowId}`,
url: `/api/chatbot/${instanceId}/${workflowId}`,
method: 'delete'
});
return true;

View file

@ -171,14 +171,55 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
try {
console.log('📡 featuresApi: Fetching /api/features/my');
const response = await api.get<FeaturesMyResponse>('/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;

444
src/hooks/useChatbot.ts Normal file
View file

@ -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<void>;
createNewThread: () => void;
sendMessage: (input: string, files?: Array<{ id: string; name: string }>) => Promise<void>;
stopStreaming: () => Promise<void>;
deleteThread: (workflowId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
// 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<ChatbotWorkflow[]>([]);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [loadingThreads, setLoadingThreads] = useState(false);
// Messages state
const [messages, setMessages] = useState<Message[]>([]);
const [loadingMessages, setLoadingMessages] = useState(false);
// Current workflow state
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
// Error state
const [error, setError] = useState<string | null>(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
};
};
}

View file

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

View file

@ -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 = () => (
<PlaceholderView title="Konversationen" description="Chat-Konversationen" />
);
// ChatbotConversationsView is imported above
const ChatbotSettings: React.FC = () => (
<PlaceholderView title="Chatbot Einstellungen" description="Konfiguration des Chatbots" />
@ -90,7 +91,7 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
files: ChatworkflowFiles,
},
chatbot: {
conversations: ChatbotConversations,
conversations: ChatbotConversationsView,
settings: ChatbotSettings,
},
};
@ -110,6 +111,25 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ 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 <NotFound />;

View file

@ -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<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!inputValue.trim() || isLoading) return;
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: inputValue,
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
setInputValue('');
setIsLoading(true);
// Simulate API call - replace with actual chatbot API
try {
// TODO: Replace with actual chatbot API call
await new Promise(resolve => setTimeout(resolve, 1000));
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: 'Dies ist eine Platzhalter-Antwort. Der Chatbot wird zu einer Feature-Instanz migriert.',
timestamp: new Date()
};
setMessages(prev => [...prev, assistantMessage]);
} catch (error) {
console.error('Error:', error);
} finally {
setIsLoading(false);
}
};
return (
<div className={styles.page}>
<header className={styles.header}>
<h1>Chatbot</h1>
<p className={styles.subtitle}>
<span className={styles.migrateTag}>MIGRATE TO FEATURE</span>
Einfache Chat-Oberfläche
</p>
</header>
<main className={styles.chatContainer}>
<div className={styles.messagesArea}>
{messages.length === 0 ? (
<div className={styles.emptyChat}>
<p>Noch keine Nachrichten. Starten Sie eine Konversation!</p>
</div>
) : (
messages.map(message => (
<div
key={message.id}
className={`${styles.message} ${styles[message.role]}`}
>
<div className={styles.messageContent}>
{message.content}
</div>
<div className={styles.messageTime}>
{message.timestamp.toLocaleTimeString('de-DE')}
</div>
</div>
))
)}
{isLoading && (
<div className={`${styles.message} ${styles.assistant}`}>
<div className={styles.typing}>
<span></span><span></span><span></span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSubmit} className={styles.inputArea}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Nachricht eingeben..."
disabled={isLoading}
className={styles.chatInput}
/>
<button
type="submit"
disabled={!inputValue.trim() || isLoading}
className={styles.sendButton}
>
Senden
</button>
</form>
</main>
</div>
);
};
export default ChatbotPage;

View file

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

View file

@ -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<string | null>(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 (
<div className={styles.chatbotView}>
{/* Chat History Sidebar */}
<aside className={styles.chatHistory}>
<div className={styles.chatHistoryHeader}>
<h2 className={styles.chatHistoryTitle}>Konversationen</h2>
<button
className={styles.newChatButton}
onClick={createNewThread}
title="Neue Konversation"
>
<LuMessageSquare /> Neu
</button>
</div>
{loadingThreads ? (
<div className={styles.loading}>
<div className={styles.spinner} />
<span>Lade Konversationen...</span>
</div>
) : error ? (
<div className={styles.error}>
<p>{error}</p>
<button className={styles.retryButton} onClick={refreshThreads}>
Erneut versuchen
</button>
</div>
) : threads.length === 0 ? (
<div className={styles.emptyState}>
<LuMessageSquare className={styles.emptyIcon} />
<p>Noch keine Konversationen vorhanden.</p>
<p className={styles.emptyHint}>Starte eine neue Konversation, um zu beginnen.</p>
</div>
) : (
<div className={styles.threadList}>
{threads.map((thread) => (
<div
key={thread.id}
className={`${styles.threadItem} ${selectedThreadId === thread.id ? styles.selected : ''}`}
onClick={() => selectThread(thread.id)}
>
<div className={styles.threadContent}>
<div className={styles.threadTitle}>{getThreadTitle(thread)}</div>
<div className={styles.threadMeta}>
{formatDate(thread.lastActivity || thread.startedAt)}
</div>
</div>
<button
className={styles.deleteButton}
onClick={(e) => handleDeleteThread(e, thread.id)}
disabled={deletingId === thread.id}
title="Löschen"
>
{deletingId === thread.id ? (
<div className={styles.spinner} />
) : (
<LuTrash2 />
)}
</button>
</div>
))}
</div>
)}
</aside>
{/* Main Chat Area */}
<main className={styles.chatArea}>
{/* Messages Area */}
<div className={styles.messagesArea}>
{loadingMessages && messages.length === 0 ? (
<div className={styles.loading}>
<div className={styles.spinner} />
<span>Lade Nachrichten...</span>
</div>
) : messages.length === 0 ? (
<div className={`${messagesStyles.messagesContainer} ${messagesStyles.emptyContainer}`}>
<div className={messagesStyles.emptyState}>
Noch keine Nachrichten. Starte eine Konversation!
</div>
</div>
) : (
<AutoScroll
scrollDependency={messages.length + (isStreaming ? 1 : 0)}
>
<div className={messagesStyles.messagesContainer}>
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
showDocuments={true}
showMetadata={false}
showProgress={false}
/>
))}
{isStreaming && (
<div className={styles.typingIndicator}>
<div className={styles.typingBubble}>
<div className={styles.typingDots}>
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
)}
</div>
</AutoScroll>
)}
</div>
{/* Input Form */}
<form onSubmit={handleSubmit} className={styles.inputForm}>
<TextField
value={inputValue}
onChange={setInputValue}
placeholder="Nachricht eingeben..."
disabled={isStreaming}
className={styles.inputField}
size="md"
/>
{isStreaming ? (
<Button
type="button"
onClick={handleStop}
variant="danger"
size="md"
icon={MdStop}
disabled={!isStreaming}
>
Stoppen
</Button>
) : (
<Button
type="submit"
disabled={!inputValue.trim() || isStreaming}
variant="primary"
size="md"
icon={IoMdSend}
>
Senden
</Button>
)}
</form>
</main>
</div>
);
};
export default ChatbotConversationsView;

View file

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

View file

@ -0,0 +1,5 @@
/**
* Chatbot Views Export
*/
export { ChatbotConversationsView } from './ChatbotConversationsView';

View file

@ -95,6 +95,20 @@ export const FeatureProvider: React.FC<FeatureProviderProps> = ({ 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<FeatureProviderProps> = ({ 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'],
});
}
});
});
});

79
work-around/chatbot.ts Normal file
View file

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

124
work-around/pek.ts Normal file
View file

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

View file

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

View file

@ -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 (
<div className={styles.locationInputContainer}>
<div className={styles.fieldsRow}>
<div className={styles.fieldWrapper}>
<TextField
value={adresse}
onChange={setAdresse}
placeholder="z.B. Bundesplatz 3"
label="Adresse oder Parzelle"
disabled={isGettingLocation || isSearchingParcel}
size="md"
type="text"
name="adresse"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
}}
/>
</div>
<div className={styles.buttonsWrapper}>
<Button
variant="primary"
size="md"
icon={IoMdSend}
onClick={handleSearch}
disabled={!buildLocationString().trim() || isGettingLocation || isSearchingParcel}
loading={isSearchingParcel}
className={styles.searchButton}
>
Suchen
</Button>
<Button
variant="secondary"
size="md"
icon={FaLocationArrow}
onClick={handleUseCurrentLocation}
disabled={isGettingLocation || isSearchingParcel}
loading={isGettingLocation}
className={styles.locationButton}
>
Meine Position
</Button>
</div>
</div>
</div>
);
};
export default PekLocationInput;

View file

@ -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<string, any>();
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 (
<>
<div style={{ marginBottom: '1.5rem' }}>
<MapView
parcels={parcelGeometries}
center={mapCenter || undefined}
zoomBounds={mapZoomBounds || undefined}
onMapClick={handleMapClick}
onParcelClick={handleParcelClick}
height="600px"
emptyMessage="Klicken Sie auf die Karte, um einen Standort auszuwählen, oder suchen Sie nach einer Adresse oben."
/>
</div>
<ParcelInfoPanel
isOpen={isPanelOpen}
onClose={() => setIsPanelOpen(false)}
parcels={selectedParcels}
onRemoveParcel={removeParcel}
adjacentParcels={allAdjacentParcels}
/>
</>
);
};
export default PekMapView;

View file

@ -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 (
<PekProvider>
<PageRenderer pageData={pageDataWithoutCustom} />
</PekProvider>
);
};
export default PekPageWrapper;

View file

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