feat:chatbot dynamisch geladen
This commit is contained in:
parent
9b10f73d09
commit
0af4d7c30b
19 changed files with 1930 additions and 158 deletions
|
|
@ -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 />} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
444
src/hooks/useChatbot.ts
Normal 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
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export { ChatbotPage } from './ChatbotPage';
|
||||
export { PekPage } from './PekPage';
|
||||
export { SpeechPage } from './SpeechPage';
|
||||
|
|
|
|||
253
src/pages/views/chatbot/ChatbotConversationsView.tsx
Normal file
253
src/pages/views/chatbot/ChatbotConversationsView.tsx
Normal 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;
|
||||
431
src/pages/views/chatbot/ChatbotViews.module.css
Normal file
431
src/pages/views/chatbot/ChatbotViews.module.css
Normal 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);
|
||||
}
|
||||
5
src/pages/views/chatbot/index.ts
Normal file
5
src/pages/views/chatbot/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Chatbot Views Export
|
||||
*/
|
||||
|
||||
export { ChatbotConversationsView } from './ChatbotConversationsView';
|
||||
|
|
@ -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
79
work-around/chatbot.ts
Normal 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
124
work-around/pek.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
|
||||
64
work-around/pek/PekLocationInput.module.css
Normal file
64
work-around/pek/PekLocationInput.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
87
work-around/pek/PekLocationInput.tsx
Normal file
87
work-around/pek/PekLocationInput.tsx
Normal 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;
|
||||
|
||||
59
work-around/pek/PekMapView.tsx
Normal file
59
work-around/pek/PekMapView.tsx
Normal 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;
|
||||
|
||||
18
work-around/pek/PekPageWrapper.tsx
Normal file
18
work-around/pek/PekPageWrapper.tsx
Normal 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;
|
||||
|
||||
208
work-around/pek/pek-tables.ts
Normal file
208
work-around/pek/pek-tables.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
|
||||
Loading…
Reference in a new issue