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) ──
|
// ── Search hook (uses dynamic import, kept inline) ──
|
||||||
|
|
||||||
export interface SearchResult {
|
import type { SearchResult } from './search';
|
||||||
type: 'company' | 'contact' | 'mail' | 'file' | 'event';
|
export type { SearchResult };
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGlobalSearch(query: string, entityTypes?: string[]) {
|
export function useGlobalSearch(query: string, entityTypes?: string[]) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
type: 'contact' | 'mail' | 'file' | 'event';
|
type: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
score?: number;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResponse {
|
export interface SearchResponse {
|
||||||
@@ -15,18 +17,55 @@ export interface SearchResponse {
|
|||||||
suggestions?: string[];
|
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> {
|
export async function search(query: string, entityTypes?: string[], limit = 20): Promise<SearchResponse> {
|
||||||
const r = await apiClient.post('/search', {
|
const r = await apiClient.post('/search', {
|
||||||
query,
|
query,
|
||||||
entity_types: entityTypes,
|
entity_types: entityTypes,
|
||||||
limit,
|
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[]> {
|
export async function searchSuggest(q: string): Promise<string[]> {
|
||||||
const r = await apiClient.get('/search/suggest', { params: { q } });
|
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[]> {
|
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_type: entityType,
|
||||||
entity_id: entityId,
|
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