319 lines
14 KiB
TypeScript
319 lines
14 KiB
TypeScript
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>
|
||
);
|
||
};
|