Files
leocrm/frontend/src/pages/Communication.tsx
T

860 lines
30 KiB
TypeScript

import { asError } from '@/utils/errorTypes';
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: unknown) { const errObj = asError(e);
setError(errObj?.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: unknown) { const errObj = asError(e);
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;