arcadiasuite/client/src/pages/XosInbox.tsx

1218 lines
54 KiB
TypeScript

import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "wouter";
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
import {
MessageSquare, ArrowLeft, Search, MoreVertical, Send, Phone,
Mail, Bot, Clock, Check, CheckCheck, Paperclip,
Smile, Mic, Plus, Star, Archive, Trash2, Tag, Users, StickyNote,
Zap, ArrowRightLeft, ChevronDown, Pin, X, Calendar, Settings,
QrCode, Loader2, RefreshCw, LogOut, Wifi, WifiOff, Smartphone,
BarChart3, MessageCircle
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { apiRequest } from "@/lib/queryClient";
import { toast } from "sonner";
import QRCode from "qrcode";
interface Conversation {
id: number;
contact_id: number;
contact_name: string;
contact_email: string;
contact_avatar: string;
contact_whatsapp: string;
channel: string;
status: string;
priority: string;
subject: string;
last_message: string;
messages_count: number;
queue_id: number | null;
queue_name: string | null;
queue_color: string | null;
created_at: string;
updated_at: string;
}
interface Message {
id: number;
conversation_id: number;
direction: string;
sender_type: string;
sender_name: string;
content: string;
content_type: string;
attachments: any;
read_at: string;
delivered_at: string;
created_at: string;
}
interface Queue {
id: number;
name: string;
color: string;
users_count: number;
open_conversations: number;
}
interface InternalNote {
id: number;
content: string;
user_name: string;
is_pinned: boolean;
created_at: string;
}
interface QuickMessage {
id: number;
shortcode: string;
title: string;
content: string;
}
interface WhatsAppStatus {
status: "disconnected" | "connecting" | "qr_pending" | "connected";
qrCode: string | null;
phoneNumber: string | null;
}
interface WhatsAppTicket {
id: number;
status: string;
unreadCount: number;
lastMessageAt: string | null;
createdAt: string;
contact: {
id: number;
whatsappId: string;
name: string | null;
pushName: string | null;
phoneNumber: string | null;
} | null;
lastMessage: string | null;
lastMessageFromMe: boolean;
}
interface WhatsAppMessage {
id: number;
messageId: string;
body: string | null;
fromMe: number;
messageType: string;
status: string | null;
timestamp: string;
}
interface EmailAccount {
id: number;
email: string;
displayName: string | null;
provider: string;
status: string;
}
interface EmailMessage {
id: number;
subject: string;
fromAddress: string;
fromName: string | null;
snippet: string | null;
isRead: number;
isStarred: number;
receivedAt: string | null;
}
type SidebarView = "conversations" | "channels";
export default function XosInbox() {
const queryClient = useQueryClient();
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
const [messageText, setMessageText] = useState("");
const [filterChannel, setFilterChannel] = useState<string>("all");
const [filterQueue, setFilterQueue] = useState<string>("all");
const [showTransferDialog, setShowTransferDialog] = useState(false);
const [showQuickMessages, setShowQuickMessages] = useState(false);
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
const [newNoteContent, setNewNoteContent] = useState("");
const [transferQueueId, setTransferQueueId] = useState<string>("");
const [scheduleDate, setScheduleDate] = useState("");
const [sidebarView, setSidebarView] = useState<SidebarView>("conversations");
const [showConnectEmailDialog, setShowConnectEmailDialog] = useState(false);
const [emailForm, setEmailForm] = useState({ email: "", password: "", provider: "gmail" });
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [isPolling, setIsPolling] = useState(false);
const [selectedWhatsAppTicket, setSelectedWhatsAppTicket] = useState<number | null>(null);
const [whatsappInput, setWhatsappInput] = useState("");
const { data: conversations = [] } = useQuery<Conversation[]>({
queryKey: ["/api/xos/conversations"],
});
const { data: messages = [] } = useQuery<Message[]>({
queryKey: ["/api/xos/conversations", selectedConversation?.id, "messages"],
queryFn: async () => {
if (!selectedConversation) return [];
const res = await fetch(`/api/xos/conversations/${selectedConversation.id}/messages`);
return res.json();
},
enabled: !!selectedConversation,
});
const { data: queues = [] } = useQuery<Queue[]>({
queryKey: ["/api/xos/queues"],
});
const { data: notes = [] } = useQuery<InternalNote[]>({
queryKey: ["/api/xos/notes", { conversationId: selectedConversation?.id }],
queryFn: async () => {
if (!selectedConversation) return [];
const res = await fetch(`/api/xos/notes?conversationId=${selectedConversation.id}`);
return res.json();
},
enabled: !!selectedConversation,
});
const { data: quickMessages = [] } = useQuery<QuickMessage[]>({
queryKey: ["/api/xos/quick-messages"],
});
const { data: whatsappStatus, isLoading: loadingWhatsapp, refetch: refetchWhatsapp } = useQuery<WhatsAppStatus>({
queryKey: ["whatsapp-status"],
queryFn: async () => {
const res = await fetch("/api/whatsapp/status", { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch status");
return res.json();
},
refetchInterval: isPolling ? 3000 : false,
});
const { data: whatsappTickets = [], refetch: refetchTickets } = useQuery<WhatsAppTicket[]>({
queryKey: ["whatsapp-tickets"],
queryFn: async () => {
const res = await fetch("/api/whatsapp/tickets", { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch tickets");
return res.json();
},
enabled: whatsappStatus?.status === "connected",
refetchInterval: 10000,
});
const { data: ticketMessages = [], refetch: refetchTicketMessages } = useQuery<WhatsAppMessage[]>({
queryKey: ["ticket-messages", selectedWhatsAppTicket],
queryFn: async () => {
if (!selectedWhatsAppTicket) return [];
const res = await fetch(`/api/whatsapp/tickets/${selectedWhatsAppTicket}/messages`, { credentials: "include" });
return res.json();
},
enabled: !!selectedWhatsAppTicket,
refetchInterval: 5000,
});
const { data: emailAccounts = [] } = useQuery<EmailAccount[]>({
queryKey: ["email-accounts"],
queryFn: async () => {
const res = await fetch("/api/email/accounts", { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch email accounts");
return res.json();
},
});
useEffect(() => {
if (whatsappStatus?.status === "connected") {
setIsPolling(false);
setQrDataUrl(null);
} else if (whatsappStatus?.status === "qr_pending" && whatsappStatus.qrCode) {
QRCode.toDataURL(whatsappStatus.qrCode).then(setQrDataUrl);
}
}, [whatsappStatus]);
const connectWhatsAppMutation = useMutation({
mutationFn: async () => {
const res = await fetch("/api/whatsapp/connect", { method: "POST", credentials: "include" });
if (!res.ok) throw new Error("Failed to connect");
return res.json();
},
onSuccess: (data) => {
if (data.status === "qr_pending" && data.qrCode) {
QRCode.toDataURL(data.qrCode).then(setQrDataUrl);
setIsPolling(true);
}
queryClient.invalidateQueries({ queryKey: ["whatsapp-status"] });
},
onError: () => toast.error("Erro ao conectar WhatsApp"),
});
const disconnectWhatsAppMutation = useMutation({
mutationFn: async () => {
const res = await fetch("/api/whatsapp/disconnect", { method: "POST", credentials: "include" });
if (!res.ok) throw new Error("Failed to disconnect");
},
onSuccess: () => {
setIsPolling(false);
setQrDataUrl(null);
queryClient.invalidateQueries({ queryKey: ["whatsapp-status"] });
toast.success("WhatsApp desconectado");
},
});
const connectEmailMutation = useMutation({
mutationFn: async () => {
const res = await fetch("/api/email/accounts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(emailForm),
credentials: "include",
});
if (!res.ok) throw new Error("Failed to connect email");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["email-accounts"] });
setShowConnectEmailDialog(false);
setEmailForm({ email: "", password: "", provider: "gmail" });
toast.success("Email conectado com sucesso!");
},
onError: () => toast.error("Erro ao conectar email. Verifique as credenciais."),
});
const sendWhatsAppMutation = useMutation({
mutationFn: async ({ ticketId, message }: { ticketId: number; message: string }) => {
const res = await fetch(`/api/whatsapp/tickets/${ticketId}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
credentials: "include",
});
if (!res.ok) throw new Error("Failed to send message");
},
onSuccess: () => {
setWhatsappInput("");
refetchTicketMessages();
queryClient.invalidateQueries({ queryKey: ["whatsapp-tickets"] });
},
onError: () => toast.error("Erro ao enviar mensagem"),
});
const createNoteMutation = useMutation({
mutationFn: async (content: string) => {
return apiRequest("POST", "/api/xos/notes", {
conversationId: selectedConversation?.id,
content,
userName: "Atendente"
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/xos/notes"] });
setNewNoteContent("");
},
});
const transferMutation = useMutation({
mutationFn: async (queueId: string) => {
return apiRequest("PUT", `/api/xos/conversations/${selectedConversation?.id}/transfer`, {
queueId: parseInt(queueId)
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/xos/conversations"] });
setShowTransferDialog(false);
},
});
const scheduleMutation = useMutation({
mutationFn: async (data: { content: string; scheduledAt: string }) => {
return apiRequest("POST", "/api/xos/scheduled-messages", {
conversationId: selectedConversation?.id,
contactId: selectedConversation?.contact_id,
content: data.content,
scheduledAt: data.scheduledAt,
});
},
onSuccess: () => {
setShowScheduleDialog(false);
setMessageText("");
setScheduleDate("");
},
});
const getChannelIcon = (channel: string) => {
const icons: Record<string, any> = {
whatsapp: MessageSquare,
email: Mail,
chat: MessageSquare,
phone: Phone,
bot: Bot,
};
return icons[channel] || MessageSquare;
};
const getChannelColor = (channel: string) => {
const colors: Record<string, string> = {
whatsapp: "bg-green-100 text-green-700",
email: "bg-blue-100 text-blue-700",
chat: "bg-indigo-100 text-indigo-700",
phone: "bg-orange-100 text-orange-700",
bot: "bg-purple-100 text-purple-700",
};
return colors[channel] || "bg-gray-100 text-gray-700";
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
open: "bg-yellow-100 text-yellow-800",
pending: "bg-orange-100 text-orange-800",
resolved: "bg-green-100 text-green-800",
closed: "bg-gray-100 text-gray-800",
};
return colors[status] || "bg-gray-100 text-gray-800";
};
const getQueueColorClass = (color: string) => {
const colors: Record<string, string> = {
green: "bg-green-500",
blue: "bg-blue-500",
yellow: "bg-yellow-500",
purple: "bg-purple-500",
red: "bg-red-500",
orange: "bg-orange-500",
};
return colors[color] || "bg-slate-500";
};
const applyFilters = (convs: Conversation[]) => {
return convs.filter((c) => {
if (filterChannel !== "all" && c.channel !== filterChannel) return false;
if (filterQueue !== "all" && c.queue_id?.toString() !== filterQueue) return false;
return true;
});
};
const formatTime = (date: string) => {
const d = new Date(date);
const now = new Date();
const diff = now.getTime() - d.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours < 1) return "agora";
if (hours < 24) return `${hours}h`;
if (hours < 48) return "ontem";
return d.toLocaleDateString("pt-BR");
};
const handleQuickMessageSelect = (qm: QuickMessage) => {
setMessageText(qm.content);
setShowQuickMessages(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "/" && messageText === "") {
e.preventDefault();
setShowQuickMessages(true);
}
};
const handleSendWhatsApp = () => {
if (!whatsappInput.trim() || !selectedWhatsAppTicket) return;
sendWhatsAppMutation.mutate({ ticketId: selectedWhatsAppTicket, message: whatsappInput.trim() });
};
const getContactDisplayName = (ticket: WhatsAppTicket) => {
if (!ticket.contact) return "Desconhecido";
return ticket.contact.name || ticket.contact.pushName || ticket.contact.phoneNumber || ticket.contact.whatsappId;
};
const whatsappConversations: Conversation[] = whatsappTickets.map((ticket) => ({
id: 10000 + ticket.id,
contact_id: ticket.contact?.id || 0,
contact_name: getContactDisplayName(ticket),
contact_email: "",
contact_avatar: "",
contact_whatsapp: ticket.contact?.phoneNumber || "",
channel: "whatsapp",
status: ticket.status,
priority: ticket.unreadCount > 0 ? "high" : "normal",
subject: "WhatsApp",
last_message: ticket.lastMessage || "",
messages_count: ticket.unreadCount,
queue_id: null,
queue_name: null,
queue_color: null,
created_at: ticket.createdAt,
updated_at: ticket.lastMessageAt || ticket.createdAt,
}));
const sampleConversations: Conversation[] = [
{
id: 1, contact_id: 1, contact_name: "João Silva", contact_email: "joao@empresa.com",
contact_avatar: "", contact_whatsapp: "11999991111", channel: "whatsapp",
status: "open", priority: "high", subject: "Dúvida sobre preços",
last_message: "Olá, gostaria de saber mais sobre os planos do Arcádia Suite",
messages_count: 5, queue_id: 1, queue_name: "Vendas", queue_color: "green",
created_at: new Date(Date.now() - 3600000).toISOString(),
updated_at: new Date(Date.now() - 300000).toISOString(),
},
{
id: 2, contact_id: 2, contact_name: "Maria Santos", contact_email: "maria@startup.com",
contact_avatar: "", contact_whatsapp: "21988882222", channel: "email",
status: "open", priority: "normal", subject: "Proposta comercial",
last_message: "Segue em anexo a proposta que solicitou na última reunião",
messages_count: 3, queue_id: 1, queue_name: "Vendas", queue_color: "green",
created_at: new Date(Date.now() - 7200000).toISOString(),
updated_at: new Date(Date.now() - 1800000).toISOString(),
},
{
id: 3, contact_id: 3, contact_name: "Carlos Oliveira", contact_email: "carlos@industria.com",
contact_avatar: "", contact_whatsapp: "31977773333", channel: "chat",
status: "pending", priority: "normal", subject: "Suporte técnico",
last_message: "Estou com uma dúvida sobre a integração com o ERPNext",
messages_count: 8, queue_id: 2, queue_name: "Suporte", queue_color: "blue",
created_at: new Date(Date.now() - 86400000).toISOString(),
updated_at: new Date(Date.now() - 3600000).toISOString(),
},
];
const sampleMessages: Message[] = [
{ id: 1, conversation_id: 1, direction: "inbound", sender_type: "contact", sender_name: "João Silva",
content: "Olá! Estou interessado no Arcádia Suite para minha empresa.",
content_type: "text", attachments: null, read_at: new Date().toISOString(),
delivered_at: new Date().toISOString(), created_at: new Date(Date.now() - 3600000).toISOString() },
{ id: 2, conversation_id: 1, direction: "outbound", sender_type: "agent", sender_name: "Atendente",
content: "Olá João! Seja bem-vindo. Ficarei feliz em ajudá-lo.",
content_type: "text", attachments: null, read_at: new Date().toISOString(),
delivered_at: new Date().toISOString(), created_at: new Date(Date.now() - 3500000).toISOString() },
];
const sampleNotes: InternalNote[] = [
{ id: 1, content: "Cliente pediu desconto de 15%", user_name: "Ana", is_pinned: true, created_at: new Date(Date.now() - 7200000).toISOString() },
];
const sampleQueues: Queue[] = queues.length > 0 ? queues : [
{ id: 1, name: "Vendas", color: "green", users_count: 3, open_conversations: 12 },
{ id: 2, name: "Suporte", color: "blue", users_count: 5, open_conversations: 8 },
{ id: 3, name: "Financeiro", color: "yellow", users_count: 2, open_conversations: 4 },
{ id: 4, name: "Pós-Venda", color: "purple", users_count: 2, open_conversations: 3 },
];
const sampleQuickMessages: QuickMessage[] = quickMessages.length > 0 ? quickMessages : [
{ id: 1, shortcode: "/preco", title: "Tabela de Preços", content: "Segue nossa tabela de preços atualizada." },
{ id: 2, shortcode: "/horario", title: "Horário", content: "Atendimento: segunda a sexta, 9h às 18h." },
{ id: 3, shortcode: "/obrigado", title: "Agradecimento", content: "Obrigado pelo contato!" },
];
const allConversations = [...(conversations.length > 0 ? conversations : sampleConversations), ...whatsappConversations];
const displayConversations = applyFilters(allConversations);
const displayMessages = messages.length > 0 ? messages : (selectedConversation ? sampleMessages : []);
const displayNotes = notes.length > 0 ? notes : (selectedConversation ? sampleNotes : []);
const displayQueues = sampleQueues;
const displayQuickMessages = sampleQuickMessages;
const totalWhatsAppUnread = whatsappTickets.reduce((acc, t) => acc + t.unreadCount, 0);
const channels = [
{ id: "whatsapp", name: "WhatsApp", icon: Smartphone, color: "bg-green-500", connected: whatsappStatus?.status === "connected", count: totalWhatsAppUnread },
{ id: "email", name: "Email", icon: Mail, color: "bg-blue-500", count: emailAccounts.length },
];
return (
<BrowserFrame>
<div className="h-[calc(100vh-48px)] flex flex-col bg-slate-100">
<header className="bg-white border-b shadow-sm px-4 py-3 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/xos">
<Button variant="ghost" size="icon" data-testid="button-back">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div className="flex items-center gap-2">
<div className="bg-gradient-to-br from-green-500 to-emerald-600 p-2 rounded-lg">
<MessageSquare className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-slate-800">Inbox</h1>
<p className="text-xs text-slate-500">Central de Atendimento Omnichannel</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
{whatsappStatus?.status === "connected" && (
<Badge variant="outline" className="gap-1.5 text-green-600 border-green-300">
<Wifi className="h-3 w-3" />
WhatsApp Conectado
</Badge>
)}
{displayQueues.map((q) => (
<Badge key={q.id} variant="outline" className="gap-1.5 cursor-pointer hover:bg-slate-100"
onClick={() => setFilterQueue(filterQueue === q.id.toString() ? "all" : q.id.toString())}>
<span className={`w-2 h-2 rounded-full ${getQueueColorClass(q.color)}`} />
{q.name} ({q.open_conversations})
</Badge>
))}
</div>
</div>
</header>
<div className="flex-1 flex overflow-hidden">
<div className="w-96 bg-white border-r flex flex-col">
<div className="p-2 border-b">
<Tabs value={sidebarView} onValueChange={(v) => setSidebarView(v as SidebarView)} className="w-full">
<TabsList className="w-full grid grid-cols-2">
<TabsTrigger value="conversations" className="text-xs gap-1" data-testid="tab-conversations">
<MessageSquare className="h-3 w-3" /> Conversas
</TabsTrigger>
<TabsTrigger value="channels" className="text-xs gap-1" data-testid="tab-channels">
<Settings className="h-3 w-3" /> Canais
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{sidebarView === "conversations" && (
<>
<div className="p-3 border-b space-y-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input placeholder="Buscar conversas..." className="pl-10" data-testid="input-search" />
</div>
<div className="flex gap-2">
<Tabs value={filterChannel} onValueChange={setFilterChannel} className="w-full">
<TabsList className="w-full grid grid-cols-4">
<TabsTrigger value="all" className="text-xs" data-testid="filter-all">Todos</TabsTrigger>
<TabsTrigger value="whatsapp" className="text-xs" data-testid="filter-whatsapp">WhatsApp</TabsTrigger>
<TabsTrigger value="email" className="text-xs" data-testid="filter-email">Email</TabsTrigger>
<TabsTrigger value="chat" className="text-xs" data-testid="filter-chat">Chat</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex flex-wrap gap-1.5">
<span className="text-xs text-slate-500 mr-1">Filas:</span>
{displayQueues.map((q) => (
<Badge
key={q.id}
variant={filterQueue === q.id.toString() ? "default" : "outline"}
className={`gap-1 cursor-pointer text-xs py-0.5 ${filterQueue === q.id.toString() ? '' : 'hover:bg-slate-100'}`}
onClick={() => setFilterQueue(filterQueue === q.id.toString() ? "all" : q.id.toString())}
data-testid={`filter-queue-${q.id}`}
>
<span className={`w-2 h-2 rounded-full ${getQueueColorClass(q.color)}`} />
{q.name}
</Badge>
))}
</div>
</div>
<ScrollArea className="flex-1">
<div className="divide-y">
{displayConversations.map((conv) => {
const ChannelIcon = getChannelIcon(conv.channel);
const isSelected = selectedConversation?.id === conv.id;
return (
<div
key={conv.id}
onClick={() => {
setSelectedConversation(conv);
if (conv.channel === "whatsapp" && conv.id >= 10000) {
setSelectedWhatsAppTicket(conv.id - 10000);
} else {
setSelectedWhatsAppTicket(null);
}
}}
className={`p-3 cursor-pointer transition-colors hover:bg-slate-50 ${
isSelected ? "bg-blue-50 border-l-4 border-l-blue-500" : ""
}`}
data-testid={`conversation-${conv.id}`}
>
<div className="flex items-start gap-3">
<div className="relative">
<Avatar className="h-10 w-10 flex-shrink-0">
<AvatarFallback className="bg-gradient-to-br from-slate-500 to-slate-600 text-white">
{conv.contact_name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{conv.queue_color && (
<span className={`absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-2 border-white ${getQueueColorClass(conv.queue_color)}`} />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="font-semibold text-slate-800 truncate">{conv.contact_name}</p>
<span className="text-xs text-slate-400">{formatTime(conv.updated_at)}</span>
</div>
<div className="flex items-center gap-2 mt-1">
<span className={`p-1 rounded ${getChannelColor(conv.channel)}`}>
<ChannelIcon className="h-3 w-3" />
</span>
<p className="text-sm text-slate-500 truncate">{conv.last_message}</p>
</div>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">{conv.status}</Badge>
{conv.priority === "high" && (
<Badge variant="destructive" className="text-xs">Urgente</Badge>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
</>
)}
{sidebarView === "channels" && (
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">
<div className="text-xs font-medium text-slate-500 uppercase mb-2">Canais Conectados</div>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-500 flex items-center justify-center text-white">
<Smartphone className="h-5 w-5" />
</div>
<div className="flex-1">
<CardTitle className="text-sm">WhatsApp</CardTitle>
<CardDescription className="text-xs">
{whatsappStatus?.status === "connected" ? (
<span className="text-green-600 flex items-center gap-1">
<Wifi className="h-3 w-3" /> Conectado
</span>
) : (
<span className="text-slate-400 flex items-center gap-1">
<WifiOff className="h-3 w-3" /> Desconectado
</span>
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{whatsappStatus?.status === "connected" ? (
<div className="space-y-3">
<div className="text-sm text-slate-600">
Telefone: {whatsappStatus.phoneNumber || "N/A"}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1" onClick={() => {
refetchWhatsapp();
refetchTickets();
setSidebarView("conversations");
toast.success("Conversas sincronizadas!");
}}>
<RefreshCw className="h-3 w-3 mr-1" /> Sincronizar
</Button>
<Button variant="destructive" size="sm" onClick={() => disconnectWhatsAppMutation.mutate()}>
<LogOut className="h-3 w-3 mr-1" /> Desconectar
</Button>
</div>
</div>
) : whatsappStatus?.status === "qr_pending" && qrDataUrl ? (
<div className="text-center space-y-3">
<p className="text-sm text-slate-600">Escaneie o QR Code com seu WhatsApp</p>
<img src={qrDataUrl} alt="QR Code" className="mx-auto w-48 h-48 rounded-lg border" />
<p className="text-xs text-slate-400">Aguardando conexão...</p>
</div>
) : (
<Button
className="w-full bg-green-500 hover:bg-green-600"
onClick={() => connectWhatsAppMutation.mutate()}
disabled={connectWhatsAppMutation.isPending}
>
{connectWhatsAppMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<QrCode className="h-4 w-4 mr-2" />
)}
Conectar WhatsApp
</Button>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500 flex items-center justify-center text-white">
<Mail className="h-5 w-5" />
</div>
<div className="flex-1">
<CardTitle className="text-sm">Email</CardTitle>
<CardDescription className="text-xs">
{emailAccounts.length > 0 ? `${emailAccounts.length} conta(s)` : "Nenhuma conta"}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{emailAccounts.length > 0 ? (
<div className="space-y-2">
{emailAccounts.map((acc) => (
<div key={acc.id} className="flex items-center justify-between p-2 bg-slate-50 rounded text-sm">
<span>{acc.email}</span>
<Badge variant="outline" className="text-xs">{acc.provider}</Badge>
</div>
))}
<Button variant="outline" size="sm" className="w-full mt-2" onClick={() => setShowConnectEmailDialog(true)}>
<Plus className="h-3 w-3 mr-1" /> Adicionar Conta
</Button>
</div>
) : (
<Button className="w-full" onClick={() => setShowConnectEmailDialog(true)}>
<Plus className="h-4 w-4 mr-2" /> Adicionar Conta
</Button>
)}
</CardContent>
</Card>
<Card className="opacity-60">
<CardHeader className="pb-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-indigo-500 flex items-center justify-center text-white">
<MessageCircle className="h-5 w-5" />
</div>
<div className="flex-1">
<CardTitle className="text-sm">Chat Web</CardTitle>
<CardDescription className="text-xs">Widget para seu site</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<Button variant="outline" className="w-full" disabled>
Em breve
</Button>
</CardContent>
</Card>
</div>
</ScrollArea>
)}
</div>
<div className="flex-1 flex flex-col">
{selectedConversation ? (
<>
<div className="bg-white border-b px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-indigo-500 text-white">
{selectedConversation.contact_name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold text-slate-800">{selectedConversation.contact_name}</p>
<div className="flex items-center gap-2 text-sm text-slate-500">
<span>{selectedConversation.contact_email || selectedConversation.contact_whatsapp}</span>
<span className={`px-2 py-0.5 rounded-full text-xs ${getChannelColor(selectedConversation.channel)}`}>
{selectedConversation.channel}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowTransferDialog(true)} data-testid="button-transfer">
<ArrowRightLeft className="h-4 w-4 mr-1" /> Transferir
</Button>
<Button variant="ghost" size="icon" data-testid="button-star">
<Star className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem><Archive className="h-4 w-4 mr-2" /> Arquivar</DropdownMenuItem>
<DropdownMenuItem><Tag className="h-4 w-4 mr-2" /> Adicionar tag</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600"><Trash2 className="h-4 w-4 mr-2" /> Excluir</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<ScrollArea className="flex-1 p-4 bg-slate-50">
<div className="space-y-4 max-w-3xl mx-auto">
{selectedWhatsAppTicket ? (
ticketMessages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.fromMe ? "justify-end" : "justify-start"}`}
data-testid={`message-${msg.id}`}
>
<div className={`max-w-[70%] rounded-2xl px-4 py-2 ${
msg.fromMe
? "bg-green-600 text-white rounded-br-sm"
: "bg-white shadow-sm rounded-bl-sm"
}`}>
<p className={msg.fromMe ? "text-white" : "text-slate-800"}>
{msg.body}
</p>
<div className={`flex items-center justify-end gap-1 mt-1 ${
msg.fromMe ? "text-green-200" : "text-slate-400"
}`}>
<span className="text-xs">
{new Date(msg.timestamp).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" })}
</span>
{msg.fromMe && <CheckCheck className="h-3 w-3" />}
</div>
</div>
</div>
))
) : (
displayMessages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.direction === "outbound" ? "justify-end" : "justify-start"}`}
data-testid={`message-${msg.id}`}
>
<div className={`max-w-[70%] rounded-2xl px-4 py-2 ${
msg.direction === "outbound"
? "bg-blue-600 text-white rounded-br-sm"
: "bg-white shadow-sm rounded-bl-sm"
}`}>
{msg.direction === "inbound" && (
<p className="text-xs font-semibold text-slate-600 mb-1">{msg.sender_name}</p>
)}
<p className={msg.direction === "outbound" ? "text-white" : "text-slate-800"}>
{msg.content}
</p>
<div className={`flex items-center justify-end gap-1 mt-1 ${
msg.direction === "outbound" ? "text-blue-200" : "text-slate-400"
}`}>
<span className="text-xs">
{new Date(msg.created_at).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" })}
</span>
{msg.direction === "outbound" && (
msg.read_at ? <CheckCheck className="h-3 w-3" /> : <Check className="h-3 w-3" />
)}
</div>
</div>
</div>
))
)}
</div>
</ScrollArea>
<div className="bg-white border-t p-3">
<div className="flex items-end gap-2 max-w-3xl mx-auto">
<Button variant="ghost" size="icon" data-testid="button-attach">
<Paperclip className="h-5 w-5 text-slate-500" />
</Button>
<DropdownMenu open={showQuickMessages} onOpenChange={setShowQuickMessages}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" data-testid="button-quick-messages">
<Zap className="h-5 w-5 text-slate-500" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
<div className="px-2 py-1.5 text-xs font-semibold text-slate-500">Mensagens Rápidas</div>
{displayQuickMessages.map((qm) => (
<DropdownMenuItem key={qm.id} onClick={() => {
if (selectedWhatsAppTicket) {
setWhatsappInput(qm.content);
} else {
handleQuickMessageSelect(qm);
}
}}>
<div>
<p className="font-medium">{qm.shortcode} - {qm.title}</p>
<p className="text-xs text-slate-500 truncate">{qm.content}</p>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1 relative">
{selectedWhatsAppTicket ? (
<Textarea
placeholder="Digite sua mensagem..."
value={whatsappInput}
onChange={(e) => setWhatsappInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendWhatsApp();
}
}}
className="min-h-[44px] max-h-32 resize-none pr-20"
rows={1}
data-testid="input-whatsapp-message"
/>
) : (
<Textarea
placeholder="Digite sua mensagem... (/ para atalhos)"
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onKeyDown={handleKeyDown}
className="min-h-[44px] max-h-32 resize-none pr-20"
rows={1}
data-testid="input-message"
/>
)}
<div className="absolute right-2 bottom-2 flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowScheduleDialog(true)}>
<Clock className="h-4 w-4 text-slate-500" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Smile className="h-4 w-4 text-slate-500" />
</Button>
</div>
</div>
<Button variant="ghost" size="icon" data-testid="button-audio">
<Mic className="h-5 w-5 text-slate-500" />
</Button>
<Button
size="icon"
className={`h-11 w-11 ${selectedWhatsAppTicket ? 'bg-green-500 hover:bg-green-600' : ''}`}
onClick={selectedWhatsAppTicket ? handleSendWhatsApp : undefined}
disabled={selectedWhatsAppTicket ? sendWhatsAppMutation.isPending : false}
data-testid="button-send"
>
<Send className="h-5 w-5" />
</Button>
</div>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center bg-slate-50">
<div className="text-center text-slate-500">
<MessageSquare className="h-16 w-16 mx-auto mb-4 text-slate-300" />
<h3 className="text-lg font-medium text-slate-800">Nenhuma conversa selecionada</h3>
<p className="mt-1">Selecione uma conversa para visualizar as mensagens</p>
{whatsappStatus?.status !== "connected" && (
<Button className="mt-4" variant="outline" onClick={() => setSidebarView("channels")}>
<Settings className="h-4 w-4 mr-2" /> Configurar Canais
</Button>
)}
</div>
</div>
)}
</div>
{selectedConversation && (
<div className="w-80 bg-white border-l flex flex-col flex-shrink-0">
<ScrollArea className="flex-1">
<div className="p-4">
<div className="text-center mb-6">
<Avatar className="h-20 w-20 mx-auto mb-3">
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-indigo-500 text-white text-2xl">
{selectedConversation.contact_name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<h3 className="font-semibold text-slate-800">{selectedConversation.contact_name}</h3>
<p className="text-sm text-slate-500">{selectedConversation.contact_email || selectedConversation.contact_whatsapp}</p>
</div>
<div className="space-y-4">
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-2">Informações</p>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-slate-400" />
<span>{selectedConversation.contact_whatsapp || "Não informado"}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-slate-400" />
<span>{selectedConversation.contact_email || "Não informado"}</span>
</div>
</div>
</div>
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-2">Status & Canal</p>
<div className="flex flex-wrap gap-2">
<Badge className={getStatusColor(selectedConversation.status)}>
{selectedConversation.status}
</Badge>
<Badge className={getChannelColor(selectedConversation.channel)}>
{selectedConversation.channel}
</Badge>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-slate-500 uppercase flex items-center gap-1">
<StickyNote className="h-3 w-3" /> Notas Internas
</p>
</div>
<div className="space-y-2 mb-3">
{displayNotes.map((note) => (
<div key={note.id} className={`p-2 rounded-lg text-sm ${note.is_pinned ? 'bg-yellow-50 border border-yellow-200' : 'bg-slate-50'}`}>
<div className="flex items-start justify-between">
<p className="text-slate-700">{note.content}</p>
{note.is_pinned && <Pin className="h-3 w-3 text-yellow-600 flex-shrink-0" />}
</div>
<p className="text-xs text-slate-400 mt-1">{note.user_name} {formatTime(note.created_at)}</p>
</div>
))}
</div>
<div className="flex gap-2">
<Input
placeholder="Adicionar nota..."
value={newNoteContent}
onChange={(e) => setNewNoteContent(e.target.value)}
className="text-sm"
data-testid="input-note"
/>
<Button size="sm" onClick={() => newNoteContent && createNoteMutation.mutate(newNoteContent)} disabled={!newNoteContent}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-2">Ações Rápidas</p>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" className="w-full">
<Plus className="h-3 w-3 mr-1" /> Ticket
</Button>
<Button variant="outline" size="sm" className="w-full">
<Plus className="h-3 w-3 mr-1" /> Deal
</Button>
</div>
</div>
</div>
</div>
</ScrollArea>
</div>
)}
</div>
<Dialog open={showTransferDialog} onOpenChange={setShowTransferDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Transferir Conversa</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Transferir para Fila</label>
<Select value={transferQueueId} onValueChange={setTransferQueueId}>
<SelectTrigger data-testid="select-queue">
<SelectValue placeholder="Selecione uma fila" />
</SelectTrigger>
<SelectContent>
{displayQueues.map((q) => (
<SelectItem key={q.id} value={q.id.toString()}>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${getQueueColorClass(q.color)}`} />
{q.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowTransferDialog(false)}>Cancelar</Button>
<Button onClick={() => transferQueueId && transferMutation.mutate(transferQueueId)} disabled={!transferQueueId}>
Transferir
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showScheduleDialog} onOpenChange={setShowScheduleDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Agendar Mensagem</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Mensagem</label>
<Textarea value={messageText} onChange={(e) => setMessageText(e.target.value)} placeholder="Digite a mensagem..." />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Data e Hora</label>
<Input type="datetime-local" value={scheduleDate} onChange={(e) => setScheduleDate(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowScheduleDialog(false)}>Cancelar</Button>
<Button
disabled={!messageText || !scheduleDate || scheduleMutation.isPending}
onClick={() => scheduleMutation.mutate({ content: messageText, scheduledAt: scheduleDate })}
>
<Calendar className="h-4 w-4 mr-1" /> {scheduleMutation.isPending ? "Agendando..." : "Agendar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showConnectEmailDialog} onOpenChange={setShowConnectEmailDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Conectar Email</DialogTitle>
<DialogDescription>Adicione uma conta de email para enviar e receber mensagens</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Provedor</Label>
<Select value={emailForm.provider} onValueChange={(v) => setEmailForm({ ...emailForm, provider: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gmail">Gmail</SelectItem>
<SelectItem value="outlook">Outlook</SelectItem>
<SelectItem value="imap">IMAP (Outro)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input
type="email"
placeholder="seu@email.com"
value={emailForm.email}
onChange={(e) => setEmailForm({ ...emailForm, email: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Senha de App</Label>
<Input
type="password"
placeholder="Senha de aplicativo"
value={emailForm.password}
onChange={(e) => setEmailForm({ ...emailForm, password: e.target.value })}
/>
<p className="text-xs text-slate-500">
Para Gmail, use uma senha de app. Para outros, use sua senha normal.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowConnectEmailDialog(false)}>Cancelar</Button>
<Button
onClick={() => connectEmailMutation.mutate()}
disabled={!emailForm.email || !emailForm.password || connectEmailMutation.isPending}
>
{connectEmailMutation.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
Conectar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</BrowserFrame>
);
}