krona-front/src/features/communications/CommunicationsModule.tsx

319 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState } from 'react';
import { INITIAL_CONVERSATIONS, ConversationItem } from '../../mock/mockData';
import { mockApi } from '../../mock/mockApi';
import { Badge, Button, MaturityBadge, useToast } from '../../shared/ui';
import {
Send,
Search,
CheckCheck,
Sparkles,
Info,
} from 'lucide-react';
export const CommunicationsModule: React.FC = () => {
const { showToast } = useToast();
const [conversations, setConversations] = useState<ConversationItem[]>(INITIAL_CONVERSATIONS);
const [activeConvId, setActiveConvId] = useState<string>(INITIAL_CONVERSATIONS[0].id);
const [replyText, setReplyText] = useState('');
const [channelFilter, setChannelFilter] = useState<string>('all');
const [isTyping, setIsTyping] = useState(false);
const [showCrmDrawer, setShowCrmDrawer] = useState(true);
const activeConv = conversations.find((c) => c.id === activeConvId) || conversations[0];
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyText.trim()) return;
const userText = replyText;
setReplyText('');
// Immediately post agent message
await mockApi.sendMessage(activeConvId, userText);
const updated = await mockApi.getMessages();
setConversations(updated);
// Trigger simulated customer auto-reply
setIsTyping(true);
setTimeout(async () => {
setIsTyping(false);
const simulatedReplies = [
'Спасибо за быстрый ответ! Будем ждать вас.',
'Отлично, подтверждаю заказ. Оплату проведу при получении.',
'Спасибо большое! Подскажите ещё, есть ли у вас десерты без сахара?',
'Супер, всё понятно, спасибо вашей поддержке!',
];
const autoText = simulatedReplies[Math.floor(Math.random() * simulatedReplies.length)];
const currentConv = conversations.find(c => c.id === activeConvId);
if (currentConv) {
const replyMsg = {
id: `m-reply-${Date.now()}`,
sender: 'client' as const,
senderName: currentConv.clientName,
text: autoText,
time: new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }),
};
setConversations(prev => prev.map(c => {
if (c.id === activeConvId) {
return {
...c,
lastMessage: autoText,
time: 'Только что',
messages: [...c.messages, replyMsg],
};
}
return c;
}));
showToast({
title: `Новый ответ от ${currentConv.clientName}`,
message: autoText,
type: 'info',
});
}
}, 750);
};
const getChannelBadge = (channel: ConversationItem['channel']) => {
switch (channel) {
case 'telegram':
return <Badge variant="gold" size="sm">Telegram Bot</Badge>;
case 'whatsapp':
return <Badge variant="green" size="sm">WhatsApp</Badge>;
case 'max':
return <Badge variant="info" size="sm">MAX</Badge>;
case 'internal':
return <Badge variant="warning" size="sm">Командный чат</Badge>;
default:
return <Badge variant="neutral" size="sm">Чат</Badge>;
}
};
const filteredConversations = conversations.filter((c) => {
if (channelFilter === 'all') return true;
return c.channel === channelFilter;
});
return (
<div className="space-y-4 animate-fade-in">
{/* Top Header with MaturityBadge */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-krona-primary">
Единый центр коммуникаций & Омниканальный Inbox
</h2>
<MaturityBadge level="target" size="sm" />
</div>
<p className="text-xs text-krona-secondary mt-0.5">
Telegram, WhatsApp, MAX Messenger, Web-чат и внутренние командные каналы в одном окне
</p>
</div>
<div className="flex items-center gap-2 text-xs">
<Button
variant="secondary"
size="sm"
onClick={() => setShowCrmDrawer(!showCrmDrawer)}
>
<Info className="w-3.5 h-3.5 mr-1" />
{showCrmDrawer ? 'Скрыть CRM-карточку' : 'Показать CRM-карточку'}
</Button>
<Badge variant="green" size="md">
Онлайн: 4 канала активны
</Badge>
</div>
</div>
<div className="h-[calc(100vh-13rem)] flex flex-col md:flex-row gap-3 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
{/* Left: Conversations List */}
<div className="w-full md:w-80 lg:w-96 border-r border-krona-border flex flex-col bg-krona-workspace/50">
<div className="p-3 border-b border-krona-border space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Диалоги ({filteredConversations.length})
</span>
</div>
{/* Channels Filter */}
<div className="flex items-center gap-1 overflow-x-auto text-[11px] pb-1">
{[
{ id: 'all', label: 'Все' },
{ id: 'telegram', label: 'Telegram' },
{ id: 'whatsapp', label: 'WhatsApp' },
{ id: 'max', label: 'MAX' },
{ id: 'internal', label: 'Команда' },
].map((ch) => (
<button
key={ch.id}
onClick={() => setChannelFilter(ch.id)}
className={`px-2 py-0.5 rounded transition-colors whitespace-nowrap ${
channelFilter === ch.id
? 'bg-[#2F684C] text-white font-medium shadow-sm'
: 'bg-krona-surface text-krona-secondary hover:text-krona-primary border border-krona-border/60'
}`}
>
{ch.label}
</button>
))}
</div>
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
<input
type="text"
placeholder="Поиск диалогов..."
className="w-full pl-8 pr-3 py-1.5 text-xs bg-krona-surface border border-krona-border rounded-lg text-krona-primary placeholder:text-krona-secondary focus:outline-none"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-krona-border/40">
{filteredConversations.map((conv) => (
<div
key={conv.id}
onClick={() => setActiveConvId(conv.id)}
className={`p-3 cursor-pointer transition-colors ${
conv.id === activeConvId
? 'bg-krona-surface font-medium border-l-2 border-krona-gold'
: 'hover:bg-krona-surface/60 text-krona-secondary'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-xs text-krona-primary truncate">
{conv.clientName}
</span>
<span className="text-[10px] text-krona-secondary">{conv.time}</span>
</div>
<p className="text-xs text-krona-secondary truncate mb-1.5">
{conv.lastMessage}
</p>
<div className="flex items-center justify-between">
{getChannelBadge(conv.channel)}
{conv.unreadCount > 0 && (
<span className="w-4 h-4 rounded-full bg-krona-gold text-white text-[10px] font-bold flex items-center justify-center">
{conv.unreadCount}
</span>
)}
</div>
</div>
))}
</div>
</div>
{/* Center: Active Conversation Thread */}
<div className="flex-1 flex flex-col min-w-0 bg-krona-surface">
{/* Chat header */}
<div className="p-3.5 border-b border-krona-border flex items-center justify-between bg-krona-surface/80">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-krona-workspace border border-krona-border flex items-center justify-center font-bold text-xs text-krona-primary">
{activeConv.avatar}
</div>
<div>
<h4 className="text-xs font-bold text-krona-primary">{activeConv.clientName}</h4>
<span className="text-[10px] text-krona-secondary">Канал связи: {activeConv.channel}</span>
</div>
</div>
{getChannelBadge(activeConv.channel)}
</div>
{/* Message bubbles */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{activeConv.messages.map((msg) => (
<div
key={msg.id}
className={`flex flex-col ${msg.sender === 'agent' ? 'items-end' : 'items-start'}`}
>
<span className="text-[10px] text-krona-secondary mb-0.5 px-1">{msg.senderName}</span>
<div
className={`p-3 rounded-xl max-w-[75%] text-xs leading-relaxed shadow-krona-soft ${
msg.sender === 'agent'
? 'bg-[#2F684C] text-[#F8F6F0] rounded-br-none'
: 'bg-krona-workspace border border-krona-border text-krona-primary rounded-bl-none'
}`}
>
<p>{msg.text}</p>
<div className="flex items-center justify-end gap-1 mt-1 text-[9px] opacity-70">
<span>{msg.time}</span>
{msg.sender === 'agent' && <CheckCheck className="w-3 h-3" />}
</div>
</div>
</div>
))}
{isTyping && (
<div className="flex items-center gap-2 text-xs text-krona-secondary bg-krona-workspace p-2 rounded-lg w-48 animate-pulse">
<span className="w-2 h-2 rounded-full bg-krona-gold animate-ping"></span>
<span>Собеседник печатает...</span>
</div>
)}
</div>
{/* Reply form */}
<div className="p-3 border-t border-krona-border bg-krona-workspace/50">
<form onSubmit={handleSend} className="flex items-center gap-2">
<input
type="text"
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Напишите ответ клиенту или коллегам (симуляция авто-ответа)..."
className="flex-1 bg-krona-surface border border-krona-border rounded-lg px-3 py-2 text-xs text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-green"
/>
<Button type="submit" variant="primary" size="sm" className="px-3">
<Send className="w-3.5 h-3.5 mr-1" />
Отправить
</Button>
</form>
</div>
</div>
{/* Right CRM Context Drawer */}
{showCrmDrawer && (
<div className="hidden lg:flex w-72 border-l border-krona-border flex-col bg-krona-surface p-3.5 space-y-4 text-xs">
<div className="flex items-center justify-between pb-2 border-b border-krona-border">
<span className="font-bold text-krona-primary uppercase text-[11px]">CRM Контекст</span>
<Badge variant="gold" size="sm">VIP Клиент</Badge>
</div>
<div className="space-y-2">
<div>
<span className="text-[10px] text-krona-secondary">ФИО клиента:</span>
<div className="font-bold text-krona-primary">{activeConv.clientName}</div>
</div>
<div>
<span className="text-[10px] text-krona-secondary">Телефон:</span>
<div className="font-mono text-krona-primary">+7 (913) 482-19-02</div>
</div>
<div>
<span className="text-[10px] text-krona-secondary">LTV (Общий объем заказов):</span>
<div className="font-bold text-krona-green font-mono">48 200 ₽ (14 чеков)</div>
</div>
<div>
<span className="text-[10px] text-krona-secondary">Любимые позиции:</span>
<div className="text-krona-secondary text-[11px]">Филадельфия Премиум, Том Ям</div>
</div>
</div>
<div className="p-2.5 bg-krona-workspace rounded-lg border border-krona-border space-y-1">
<div className="font-semibold text-krona-primary flex items-center gap-1">
<Sparkles className="w-3 h-3 text-krona-gold" />
AI Подсказка оператору:
</div>
<p className="text-[11px] text-krona-secondary">
Гость часто заказывает по пятницам в 19:30. Предложите сет Осенний со скидкой постоянного клиента.
</p>
</div>
<div className="pt-2 border-t border-krona-border space-y-2">
<Button variant="outline" size="sm" className="w-full text-xs">
Открыть сделку в CRM →
</Button>
</div>
</div>
)}
</div>
</div>
);
};