fix: Communication page, search field mapping, type compatibility
- Create Communication.tsx page with tree structure (System, KI, Kollegen) - Chat window with messages, reactions, attachments, mini-apps - WebSocket real-time updates - AI streaming integration for AI conversations - Fix search.ts: map backend fields (entity_type/entity_id/title/snippet) to frontend format (type/id/name/description/url) - Fix hooks.ts: import SearchResult from search.ts instead of redefining - Fix JSX syntax error in Communication.tsx title attribute
This commit is contained in:
@@ -24,13 +24,8 @@ export * from './automation';
|
||||
|
||||
// ── Search hook (uses dynamic import, kept inline) ──
|
||||
|
||||
export interface SearchResult {
|
||||
type: 'company' | 'contact' | 'mail' | 'file' | 'event';
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
}
|
||||
import type { SearchResult } from './search';
|
||||
export type { SearchResult };
|
||||
|
||||
export function useGlobalSearch(query: string, entityTypes?: string[]) {
|
||||
return useQuery({
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface SearchResult {
|
||||
type: 'contact' | 'mail' | 'file' | 'event';
|
||||
type: string;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
score?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
@@ -15,18 +17,55 @@ export interface SearchResponse {
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
// Map backend entity_type to frontend route URL
|
||||
const ENTITY_URL_MAP: Record<string, (id: string, data?: Record<string, unknown>) => string> = {
|
||||
contact: (id) => `/contacts/${id}`,
|
||||
company: (id) => `/contacts/${id}`,
|
||||
mail: (id) => `/mail?message_id=${id}`,
|
||||
file: (id) => `/dms?file_id=${id}`,
|
||||
event: (id) => `/calendar?event_id=${id}`,
|
||||
message: (_id, data) => data?.conversation_id ? `/communication?conversation_id=${data.conversation_id}` : '/communication',
|
||||
};
|
||||
|
||||
function mapBackendResult(r: Record<string, unknown>): SearchResult {
|
||||
const entityType = (r.entity_type as string) || 'unknown';
|
||||
const entityId = (r.entity_id as string) || (r.id as string) || '';
|
||||
const title = (r.title as string) || (r.name as string) || '';
|
||||
const snippet = (r.snippet as string) || (r.description as string) || '';
|
||||
const data = (r.data as Record<string, unknown>) || {};
|
||||
const urlFn = ENTITY_URL_MAP[entityType];
|
||||
const url = urlFn ? urlFn(entityId, data) : '#';
|
||||
return {
|
||||
type: entityType,
|
||||
id: entityId,
|
||||
name: title,
|
||||
description: snippet,
|
||||
url,
|
||||
score: (r.score as number) || 0,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function search(query: string, entityTypes?: string[], limit = 20): Promise<SearchResponse> {
|
||||
const r = await apiClient.post('/search', {
|
||||
query,
|
||||
entity_types: entityTypes,
|
||||
limit,
|
||||
});
|
||||
return r.data;
|
||||
const data = r.data;
|
||||
// Backend returns { results: [{entity_type, entity_id, title, snippet, score, data}], ... }
|
||||
const rawResults = data.results || [];
|
||||
return {
|
||||
results: rawResults.map(mapBackendResult),
|
||||
facets: data.facets,
|
||||
summary: data.summary,
|
||||
suggestions: data.suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchSuggest(q: string): Promise<string[]> {
|
||||
const r = await apiClient.get('/search/suggest', { params: { q } });
|
||||
return r.data.suggestions;
|
||||
return r.data.suggestions || [];
|
||||
}
|
||||
|
||||
export async function searchSimilar(entityType: string, entityId: string): Promise<SearchResult[]> {
|
||||
@@ -34,5 +73,12 @@ export async function searchSimilar(entityType: string, entityId: string): Promi
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
});
|
||||
return r.data.similar;
|
||||
const similar = r.data.similar || {};
|
||||
const all: SearchResult[] = [];
|
||||
for (const items of Object.values(similar) as Record<string, unknown>[][]) {
|
||||
for (const item of items) {
|
||||
all.push(mapBackendResult(item));
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,858 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import {
|
||||
MessageSquare, Bot, Users, Pin, Search, Plus, Send, Paperclip,
|
||||
Smile, MoreVertical, ChevronDown, ChevronRight, Hash, Bell,
|
||||
ExternalLink, Loader2, X, Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { streamChat, fetchAgents, type AIAgent } from '@/api/ai';
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string | null;
|
||||
is_pinned: boolean;
|
||||
is_direct: boolean;
|
||||
is_archived: boolean;
|
||||
is_locked: boolean;
|
||||
last_msg_at: string | null;
|
||||
last_msg_preview: string | null;
|
||||
last_msg_sender_type: string | null;
|
||||
unread_count: number;
|
||||
participants: Participant[];
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface Participant {
|
||||
id: string;
|
||||
participant_id: string | null;
|
||||
participant_type: string;
|
||||
display_name: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
sender_id: string | null;
|
||||
sender_type: string;
|
||||
content: string;
|
||||
content_format: string;
|
||||
created_at: string | null;
|
||||
edited_at: string | null;
|
||||
is_pinned: boolean;
|
||||
blocks: MessageBlock[];
|
||||
attachments: MessageAttachment[];
|
||||
reactions: MessageReaction[];
|
||||
reply_to_id: string | null;
|
||||
}
|
||||
|
||||
interface MessageBlock {
|
||||
id: string;
|
||||
block_type: string;
|
||||
block_data: Record<string, unknown>;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface MessageAttachment {
|
||||
id: string;
|
||||
file_id: string | null;
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
file_size: number | null;
|
||||
}
|
||||
|
||||
interface MessageReaction {
|
||||
id: string;
|
||||
message_id: string;
|
||||
user_id: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
interface MiniApp {
|
||||
app_id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
plugin_name: string;
|
||||
}
|
||||
|
||||
type ConversationCategory = 'system' | 'ai' | 'colleague';
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
function categorizeConversation(conv: Conversation): ConversationCategory {
|
||||
// System messages: created by system participant type
|
||||
if (conv.participants.some(p => p.participant_type === 'system')) return 'system';
|
||||
// AI conversations: have an AI participant
|
||||
if (conv.participants.some(p => p.participant_type === 'ai')) return 'ai';
|
||||
// Everything else is colleague chat
|
||||
return 'colleague';
|
||||
}
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) {
|
||||
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
|
||||
function getConversationDisplayName(conv: Conversation, currentUserId?: string): string {
|
||||
if (conv.title) return conv.title;
|
||||
const others = conv.participants.filter(p => p.participant_id !== currentUserId);
|
||||
if (others.length === 0) return 'Neue Konversation';
|
||||
if (others.length === 1) return others[0].display_name || 'Unbekannt';
|
||||
return others.map(p => p.display_name || '?').join(', ');
|
||||
}
|
||||
|
||||
// ─── API calls ───
|
||||
|
||||
async function fetchConversations(): Promise<Conversation[]> {
|
||||
const r = await apiClient.get('/comm/conversations');
|
||||
return r.data.items || [];
|
||||
}
|
||||
|
||||
async function fetchMessages(convId: string): Promise<Message[]> {
|
||||
const r = await apiClient.get(`/comm/conversations/${convId}/messages`);
|
||||
return r.data.items || [];
|
||||
}
|
||||
|
||||
async function sendMessage(convId: string, content: string, replyToId?: string): Promise<Message> {
|
||||
const r = await apiClient.post(`/comm/conversations/${convId}/messages`, {
|
||||
content,
|
||||
content_format: 'text',
|
||||
reply_to_id: replyToId,
|
||||
});
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async function createConversation(title: string | null, participantIds: string[], isDirect: boolean, initialMessage?: string): Promise<Conversation> {
|
||||
const r = await apiClient.post('/comm/conversations', {
|
||||
title,
|
||||
participant_ids: participantIds,
|
||||
is_direct: isDirect,
|
||||
initial_message: initialMessage,
|
||||
});
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async function pinConversation(convId: string): Promise<void> {
|
||||
await apiClient.post(`/comm/conversations/${convId}/pin`);
|
||||
}
|
||||
|
||||
async function unpinConversation(convId: string): Promise<void> {
|
||||
await apiClient.delete(`/comm/conversations/${convId}/pin`);
|
||||
}
|
||||
|
||||
async function markConversationRead(convId: string): Promise<void> {
|
||||
await apiClient.post(`/comm/conversations/${convId}/read`, {});
|
||||
}
|
||||
|
||||
async function fetchMiniApps(): Promise<MiniApp[]> {
|
||||
const r = await apiClient.get('/comm/miniapps');
|
||||
return r.data.items || [];
|
||||
}
|
||||
|
||||
async function fetchUsers(): Promise<{id: string; name: string; email: string}[]> {
|
||||
const r = await apiClient.get('/users');
|
||||
return r.data.items || r.data || [];
|
||||
}
|
||||
|
||||
// ─── Tree Node ───
|
||||
|
||||
interface TreeSection {
|
||||
category: ConversationCategory;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
conversations: Conversation[];
|
||||
}
|
||||
|
||||
function buildTree(conversations: Conversation[], currentUserId?: string): TreeSection[] {
|
||||
const system = conversations.filter(c => categorizeConversation(c) === 'system');
|
||||
const ai = conversations.filter(c => categorizeConversation(c) === 'ai');
|
||||
const colleague = conversations.filter(c => categorizeConversation(c) === 'colleague');
|
||||
|
||||
// Sort: pinned first, then by last_msg_at
|
||||
const sortFn = (a: Conversation, b: Conversation) => {
|
||||
if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1;
|
||||
const aTime = a.last_msg_at || '';
|
||||
const bTime = b.last_msg_at || '';
|
||||
return bTime.localeCompare(aTime);
|
||||
};
|
||||
|
||||
system.sort(sortFn);
|
||||
ai.sort(sortFn);
|
||||
colleague.sort(sortFn);
|
||||
|
||||
return [
|
||||
{ category: 'system', label: 'Systemnachrichten', icon: <Bell className="w-4 h-4" />, conversations: system },
|
||||
{ category: 'ai', label: 'KI Chats', icon: <Bot className="w-4 h-4" />, conversations: ai },
|
||||
{ category: 'colleague', label: 'Kollegen', icon: <Users className="w-4 h-4" />, conversations: colleague },
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Conversation Tree ───
|
||||
|
||||
interface ConversationTreeProps {
|
||||
conversations: Conversation[];
|
||||
activeConvId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onNewChat: (category: ConversationCategory) => void; loading: boolean;
|
||||
}
|
||||
|
||||
function ConversationTree({ conversations, activeConvId, onSelect, onNewChat, loading }: ConversationTreeProps) {
|
||||
const [expanded, setExpanded] = useState<Record<ConversationCategory, boolean>>({
|
||||
system: true,
|
||||
ai: true,
|
||||
colleague: true,
|
||||
});
|
||||
const tree = useMemo(() => buildTree(conversations), [conversations]);
|
||||
|
||||
const toggle = (cat: ConversationCategory) => setExpanded(s => ({ ...s, [cat]: !s[cat] }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 border-b border-secondary-200">
|
||||
<h2 className="text-sm font-semibold text-secondary-700 flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
Kommunikation
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-secondary-400" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && tree.map(section => (
|
||||
<div key={section.category} className="mb-1">
|
||||
<div
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-secondary-500 hover:bg-secondary-50 cursor-pointer select-none"
|
||||
onClick={() => toggle(section.category)}
|
||||
>
|
||||
{expanded[section.category]
|
||||
? <ChevronDown className="w-3 h-3" />
|
||||
: <ChevronRight className="w-3 h-3" />
|
||||
}
|
||||
{section.icon}
|
||||
<span className="flex-1">{section.label}</span>
|
||||
<span className="text-secondary-400">{section.conversations.length}</span>
|
||||
<button
|
||||
className="p-0.5 hover:bg-secondary-200 rounded"
|
||||
onClick={(e) => { e.stopPropagation(); onNewChat(section.category); }}
|
||||
title={`Neuer ${section.label} Chat`}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{expanded[section.category] && section.conversations.map(conv => (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => onSelect(conv.id)}
|
||||
className={clsx(
|
||||
'w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-secondary-50 transition-colors',
|
||||
activeConvId === conv.id && 'bg-primary-50 border-l-2 border-primary-500'
|
||||
)}
|
||||
>
|
||||
{conv.is_pinned && <Pin className="w-3 h-3 text-primary-500 mt-0.5 flex-shrink-0" />}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium text-secondary-900 truncate">
|
||||
{getConversationDisplayName(conv)}
|
||||
</span>
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="ml-auto text-xs bg-primary-500 text-white rounded-full px-1.5 py-0.5 flex-shrink-0">
|
||||
{conv.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{conv.last_msg_preview && (
|
||||
<p className="text-xs text-secondary-500 truncate mt-0.5">
|
||||
{conv.last_msg_preview}
|
||||
</p>
|
||||
)}
|
||||
{conv.last_msg_at && (
|
||||
<span className="text-xs text-secondary-400">{formatTime(conv.last_msg_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{expanded[section.category] && section.conversations.length === 0 && (
|
||||
<div className="px-6 py-2 text-xs text-secondary-400">Keine Konversationen</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Chat Window ───
|
||||
|
||||
interface ChatWindowProps {
|
||||
convId: string;
|
||||
category: ConversationCategory;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [aiStreaming, setAiStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [showMiniApps, setShowMiniApps] = useState(false);
|
||||
const [miniApps, setMiniApps] = useState<MiniApp[]>([]);
|
||||
const [agents, setAgents] = useState<AIAgent[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [aiSessionId, setAiSessionId] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
// Load messages
|
||||
useEffect(() => {
|
||||
if (!convId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchMessages(convId)
|
||||
.then(msgs => { setMessages(msgs); setLoading(false); })
|
||||
.catch(e => { setError(e?.message || 'Fehler beim Laden'); setLoading(false); });
|
||||
markConversationRead(convId).catch(() => {});
|
||||
}, [convId]);
|
||||
|
||||
// Load agents and mini-apps
|
||||
useEffect(() => {
|
||||
fetchAgents().then(setAgents).catch(() => {});
|
||||
fetchMiniApps().then(setMiniApps).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Auto-select default agent for AI chats
|
||||
useEffect(() => {
|
||||
if (category === 'ai' && agents.length > 0 && !selectedAgentId) {
|
||||
const def = agents.find(a => a.is_default) || agents[0];
|
||||
setSelectedAgentId(def.id);
|
||||
}
|
||||
}, [category, agents, selectedAgentId]);
|
||||
|
||||
// WebSocket connection
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/comm/ws`;
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', conversation_id: convId }));
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'message' && data.conversation_id === convId) {
|
||||
setMessages(prev => [...prev, data.message]);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
return () => { ws.close(); wsRef.current = null; };
|
||||
} catch {}
|
||||
}, [convId]);
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || sending) return;
|
||||
const content = input.trim();
|
||||
setInput('');
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (category === 'ai' && aiSessionId) {
|
||||
// AI streaming chat
|
||||
setAiStreaming(true);
|
||||
setStreamingContent('');
|
||||
let accumulated = '';
|
||||
for await (const event of streamChat(aiSessionId, content, selectedAgentId || undefined)) {
|
||||
if (event.type === 'token' && event.content) {
|
||||
accumulated += event.content;
|
||||
setStreamingContent(accumulated);
|
||||
} else if (event.type === 'done') {
|
||||
break;
|
||||
} else if (event.type === 'error') {
|
||||
setError(event.content || 'KI Fehler');
|
||||
break;
|
||||
}
|
||||
}
|
||||
setAiStreaming(false);
|
||||
setStreamingContent('');
|
||||
// Reload messages to get the AI response
|
||||
const msgs = await fetchMessages(convId);
|
||||
setMessages(msgs);
|
||||
} else {
|
||||
// Regular message
|
||||
const msg = await sendMessage(convId, content);
|
||||
setMessages(prev => [...prev, msg]);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Senden fehlgeschlagen');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const startAiSession = async () => {
|
||||
try {
|
||||
const r = await apiClient.post('/ai/sessions', {
|
||||
title: 'KI Chat',
|
||||
agent_id: selectedAgentId,
|
||||
is_sidebar: false,
|
||||
});
|
||||
setAiSessionId(r.data.id);
|
||||
} catch (e: any) {
|
||||
setError('KI Session konnte nicht gestartet werden');
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-start AI session for AI conversations
|
||||
useEffect(() => {
|
||||
if (category === 'ai' && !aiSessionId && selectedAgentId) {
|
||||
startAiSession();
|
||||
}
|
||||
}, [category, aiSessionId, selectedAgentId]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-secondary-200 bg-white">
|
||||
{onBack && (
|
||||
<button onClick={onBack} className="md:hidden p-1 hover:bg-secondary-100 rounded">
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<Hash className="w-4 h-4 text-secondary-400" />
|
||||
<span className="font-medium text-secondary-900 flex-1 truncate">
|
||||
{messages[0]?.conversation_id === convId ? 'Konversation' : 'Chat'}
|
||||
</span>
|
||||
{category === 'ai' && agents.length > 0 && (
|
||||
<select
|
||||
value={selectedAgentId || ''}
|
||||
onChange={(e) => { setSelectedAgentId(e.target.value || null); setAiSessionId(null); }}
|
||||
className="text-xs border border-secondary-200 rounded px-2 py-1 bg-white"
|
||||
>
|
||||
{agents.map(a => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowMiniApps(!showMiniApps)}
|
||||
className="p-1 hover:bg-secondary-100 rounded"
|
||||
title="Mini-Apps"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-secondary-400" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-secondary-100 rounded" title="In neuem Fenster">
|
||||
<ExternalLink className="w-4 h-4 text-secondary-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mini-Apps panel */}
|
||||
{showMiniApps && miniApps.length > 0 && (
|
||||
<div className="border-b border-secondary-200 bg-secondary-50 p-2 flex gap-2 overflow-x-auto">
|
||||
{miniApps.map(app => (
|
||||
<button
|
||||
key={app.app_id}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-secondary-200 rounded-lg text-xs hover:border-primary-300 hover:bg-primary-50 whitespace-nowrap"
|
||||
title={app.description}
|
||||
>
|
||||
<span className="text-base">{app.icon}</span>
|
||||
{app.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-secondary-400" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 rounded p-3">{error}</div>
|
||||
)}
|
||||
{!loading && messages.length === 0 && !aiStreaming && (
|
||||
<div className="text-center text-secondary-400 py-8">
|
||||
<MessageSquare className="w-10 h-10 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">Keine Nachrichten. Schreibe die erste!</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map(msg => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{aiStreaming && (
|
||||
<div className="flex gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-4 h-4 text-primary-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-secondary-500 mb-1">KI antwortet...</div>
|
||||
<div className="ai-markdown max-w-none break-words">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{streamingContent || '...'}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-secondary-200 p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<button className="p-2 hover:bg-secondary-100 rounded-lg" title="Datei anhängen">
|
||||
<Paperclip className="w-4 h-4 text-secondary-400" />
|
||||
</button>
|
||||
<button className="p-2 hover:bg-secondary-100 rounded-lg" title="Emoji">
|
||||
<Smile className="w-4 h-4 text-secondary-400" />
|
||||
</button>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={category === 'ai' ? 'Frage die KI...' : 'Nachricht schreiben...'}
|
||||
rows={1}
|
||||
className="flex-1 resize-none border border-secondary-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500 max-h-32"
|
||||
style={{ minHeight: '38px' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || sending || aiStreaming}
|
||||
className="p-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{sending || aiStreaming
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <Send className="w-4 h-4" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Message Bubble ───
|
||||
|
||||
function MessageBubble({ message }: { message: Message }) {
|
||||
const isUser = message.sender_type === 'user';
|
||||
const isAI = message.sender_type === 'ai';
|
||||
const isSystem = message.sender_type === 'system';
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-secondary-100 text-secondary-600 text-xs rounded-full px-3 py-1.5 max-w-md text-center">
|
||||
{message.content}
|
||||
{message.created_at && <span className="ml-2 text-secondary-400">{formatTime(message.created_at)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('flex gap-2', isUser && 'flex-row-reverse')}>
|
||||
<div className={clsx(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
isUser ? 'bg-accent-100' : isAI ? 'bg-primary-100' : 'bg-secondary-100'
|
||||
)}>
|
||||
{isUser ? <span className="text-xs font-medium text-accent-700">U</span>
|
||||
: isAI ? <Bot className="w-4 h-4 text-primary-600" />
|
||||
: <Users className="w-4 h-4 text-secondary-600" />}
|
||||
</div>
|
||||
<div className={clsx('flex-1 max-w-[80%]', isUser && 'flex flex-col items-end')}>
|
||||
<div className="text-xs text-secondary-500 mb-1">
|
||||
{isUser ? 'Du' : isAI ? 'KI' : 'Kollege'}
|
||||
{message.created_at && <span className="ml-2">{formatTime(message.created_at)}</span>}
|
||||
</div>
|
||||
<div className={clsx(
|
||||
'rounded-lg px-3 py-2 text-sm',
|
||||
isUser ? 'bg-primary-600 text-white' : isAI ? 'bg-primary-50 text-secondary-900' : 'bg-secondary-100 text-secondary-900'
|
||||
)}>
|
||||
{message.content_format === 'markdown' ? (
|
||||
<div className="ai-markdown max-w-none break-words">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||
)}
|
||||
{/* Mini-app blocks */}
|
||||
{message.blocks?.filter(b => b.block_type === 'miniapp').map(block => (
|
||||
<div key={block.id} className="mt-2 p-2 bg-white/20 rounded border border-primary-200">
|
||||
<div className="text-xs font-medium">Mini-App: {String(block.block_data.app_id || '')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Reactions */}
|
||||
{message.reactions && message.reactions.length > 0 && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{message.reactions.map(r => (
|
||||
<span key={r.id} className="text-xs bg-secondary-100 rounded-full px-2 py-0.5">
|
||||
{r.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── New Chat Dialog ───
|
||||
|
||||
interface NewChatDialogProps {
|
||||
category: ConversationCategory;
|
||||
onClose: () => void;
|
||||
onCreate: (title: string, participantIds: string[]) => void;
|
||||
}
|
||||
|
||||
function NewChatDialog({ category, onClose, onCreate }: NewChatDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [users, setUsers] = useState<{id: string; name: string; email: string}[]>([]);
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers().then(u => { setUsers(u); setLoading(false); }).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
if (category === 'ai') {
|
||||
onCreate(title || 'KI Chat', []);
|
||||
} else if (category === 'system') {
|
||||
onCreate(title || 'Systemnachricht', []);
|
||||
} else {
|
||||
onCreate(title, selectedUsers);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{category === 'ai' ? 'Neuer KI Chat' : category === 'system' ? 'Neue Systemnachricht' : 'Neuer Kollegen-Chat'}
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 hover:bg-secondary-100 rounded">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder={category === 'ai' ? 'KI Chat Titel' : 'Konversation Titel'}
|
||||
className="w-full border border-secondary-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
{category === 'colleague' && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-700 mb-1 block">Teilnehmer auswählen</label>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<div className="max-h-48 overflow-y-auto border border-secondary-200 rounded-lg">
|
||||
{users.map(u => (
|
||||
<label key={u.id} className="flex items-center gap-2 px-3 py-2 hover:bg-secondary-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUsers.includes(u.id)}
|
||||
onChange={e => {
|
||||
if (e.target.checked) setSelectedUsers([...selectedUsers, u.id]);
|
||||
else setSelectedUsers(selectedUsers.filter(id => id !== u.id));
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">{u.name || u.email}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={category === 'colleague' && selectedUsers.length === 0}
|
||||
className="w-full bg-primary-600 text-white rounded-lg py-2 text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
Erstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ───
|
||||
|
||||
export function CommunicationPage() {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [activeConvId, setActiveConvId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showNewChat, setShowNewChat] = useState<ConversationCategory | null>(null);
|
||||
const [mobileView, setMobileView] = useState<'list' | 'chat'>('list');
|
||||
const { registerItems, unregisterPlugin } = usePluginToolbarStore();
|
||||
|
||||
const loadConversations = useCallback(async () => {
|
||||
try {
|
||||
const convs = await fetchConversations();
|
||||
setConversations(convs);
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
console.error('Failed to load conversations:', e);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadConversations(); }, [loadConversations]);
|
||||
|
||||
// Register toolbar items
|
||||
useEffect(() => {
|
||||
registerItems('kommunikation', [
|
||||
{
|
||||
id: 'new-chat',
|
||||
plugin: 'kommunikation',
|
||||
label: 'Neuer Chat',
|
||||
type: 'button',
|
||||
group: 'actions',
|
||||
icon: <Plus className="w-3.5 h-3.5" strokeWidth={2} />,
|
||||
onClick: () => setShowNewChat('colleague'),
|
||||
},
|
||||
{
|
||||
id: 'new-ai-chat',
|
||||
plugin: 'kommunikation',
|
||||
label: 'KI Chat',
|
||||
type: 'button',
|
||||
group: 'actions',
|
||||
icon: <Bot className="w-3.5 h-3.5" strokeWidth={2} />,
|
||||
onClick: () => setShowNewChat('ai'),
|
||||
},
|
||||
{
|
||||
id: 'open-standalone',
|
||||
plugin: 'kommunikation',
|
||||
label: 'In neuem Fenster',
|
||||
type: 'button',
|
||||
group: 'actions',
|
||||
icon: <ExternalLink className="w-3.5 h-3.5" strokeWidth={2} />,
|
||||
onClick: () => window.open('/communication-standalone', '_blank', 'width=800,height=600'),
|
||||
},
|
||||
]);
|
||||
return () => unregisterPlugin('kommunikation');
|
||||
}, [registerItems, unregisterPlugin]);
|
||||
|
||||
const activeConv = conversations.find(c => c.id === activeConvId);
|
||||
const activeCategory = activeConv ? categorizeConversation(activeConv) : 'colleague';
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
setActiveConvId(id);
|
||||
setMobileView('chat');
|
||||
};
|
||||
|
||||
const handleNewChat = async (title: string, participantIds: string[]) => {
|
||||
if (!showNewChat) return;
|
||||
try {
|
||||
const category = showNewChat;
|
||||
const isDirect = category === 'colleague' && participantIds.length === 1;
|
||||
const conv = await createConversation(title || null, participantIds, isDirect);
|
||||
setConversations(prev => [...prev, conv]);
|
||||
setActiveConvId(conv.id);
|
||||
setMobileView('chat');
|
||||
} catch (e) {
|
||||
console.error('Failed to create conversation:', e);
|
||||
}
|
||||
setShowNewChat(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full" data-testid="communication-page">
|
||||
{/* Desktop: two-pane layout */}
|
||||
<div className="hidden md:flex w-full">
|
||||
<ResizablePanel
|
||||
initialWidth={288}
|
||||
minWidth={200}
|
||||
maxWidth={400}
|
||||
className="border-r border-secondary-200 bg-white"
|
||||
>
|
||||
<ConversationTree
|
||||
conversations={conversations}
|
||||
activeConvId={activeConvId}
|
||||
onSelect={handleSelect}
|
||||
onNewChat={(cat) => setShowNewChat(cat)}
|
||||
loading={loading}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<div className="flex-1">
|
||||
{activeConvId ? (
|
||||
<ChatWindow convId={activeConvId} category={activeCategory} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-secondary-400">
|
||||
<div className="text-center">
|
||||
<MessageSquare className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>Wähle eine Konversation aus</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: single pane */}
|
||||
<div className="md:hidden w-full">
|
||||
{mobileView === 'list' ? (
|
||||
<ConversationTree
|
||||
conversations={conversations}
|
||||
activeConvId={activeConvId}
|
||||
onSelect={handleSelect}
|
||||
onNewChat={(cat) => setShowNewChat(cat)}
|
||||
loading={loading}
|
||||
/>
|
||||
) : (
|
||||
activeConvId && (
|
||||
<ChatWindow
|
||||
convId={activeConvId}
|
||||
category={activeCategory}
|
||||
onBack={() => setMobileView('list')}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Chat Dialog */}
|
||||
{showNewChat && (
|
||||
<NewChatDialog
|
||||
category={showNewChat}
|
||||
onClose={() => setShowNewChat(null)}
|
||||
onCreate={handleNewChat}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommunicationPage;
|
||||
Reference in New Issue
Block a user