arcadiasuite/client/src/pages/Support.tsx

514 lines
24 KiB
TypeScript

import { BrowserFrame } from "@/components/Browser/BrowserFrame";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useToast } from "@/hooks/use-toast";
import { Plus, Ticket, MessageSquare, BookOpen, Bot, Send, Clock, AlertCircle, CheckCircle2, MoreHorizontal, ArrowRight } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
interface SupportTicket {
id: number;
title: string;
description?: string;
category: string;
priority: string;
status: string;
createdAt: string;
customerId?: number;
customerName?: string;
}
interface Conversation {
id: number;
ticketId: number;
senderType: string;
content: string;
createdAt: string;
}
interface KnowledgeBaseArticle {
id: number;
title: string;
content: string;
category?: string;
tags?: string[];
}
export default function Support() {
const { user } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [selectedTicket, setSelectedTicket] = useState<SupportTicket | null>(null);
const [newTicketOpen, setNewTicketOpen] = useState(false);
const [newArticleOpen, setNewArticleOpen] = useState(false);
const [messageInput, setMessageInput] = useState("");
const [aiQuestion, setAiQuestion] = useState("");
const [isAiLoading, setIsAiLoading] = useState(false);
const { data: tickets = [] } = useQuery<SupportTicket[]>({
queryKey: ["/api/support/tickets"],
queryFn: async () => {
const res = await fetch("/api/support/tickets", { credentials: "include" });
if (!res.ok) return [];
return res.json();
},
});
const { data: conversations = [] } = useQuery<Conversation[]>({
queryKey: ["/api/support/tickets", selectedTicket?.id, "conversations"],
queryFn: async () => {
if (!selectedTicket) return [];
const res = await fetch(`/api/support/tickets/${selectedTicket.id}/conversations`, { credentials: "include" });
if (!res.ok) return [];
return res.json();
},
enabled: !!selectedTicket,
});
const { data: articles = [] } = useQuery<KnowledgeBaseArticle[]>({
queryKey: ["/api/support/knowledge-base"],
queryFn: async () => {
const res = await fetch("/api/support/knowledge-base", { credentials: "include" });
if (!res.ok) return [];
return res.json();
},
});
const [newTicket, setNewTicket] = useState({ title: "", description: "", category: "general", priority: "medium" });
const [newArticle, setNewArticle] = useState({ title: "", content: "", category: "", tags: "" });
const createTicketMutation = useMutation({
mutationFn: async (data: typeof newTicket) => {
const res = await fetch("/api/support/tickets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
credentials: "include",
});
if (!res.ok) throw new Error("Failed to create ticket");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/support/tickets"] });
setNewTicketOpen(false);
setNewTicket({ title: "", description: "", category: "general", priority: "medium" });
toast({ title: "Ticket criado com sucesso" });
},
});
const updateTicketMutation = useMutation({
mutationFn: async ({ id, data }: { id: number; data: Partial<SupportTicket> }) => {
const res = await fetch(`/api/support/tickets/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
credentials: "include",
});
if (!res.ok) throw new Error("Failed to update ticket");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/support/tickets"] });
},
});
const sendMessageMutation = useMutation({
mutationFn: async ({ ticketId, content }: { ticketId: number; content: string }) => {
const res = await fetch(`/api/support/tickets/${ticketId}/conversations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content, senderType: "agent" }),
credentials: "include",
});
if (!res.ok) throw new Error("Failed to send message");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/support/tickets", selectedTicket?.id, "conversations"] });
setMessageInput("");
},
});
const createArticleMutation = useMutation({
mutationFn: async (data: typeof newArticle) => {
const res = await fetch("/api/support/knowledge-base", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...data, tags: data.tags.split(",").map((t) => t.trim()).filter(Boolean) }),
credentials: "include",
});
if (!res.ok) throw new Error("Failed to create article");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/support/knowledge-base"] });
setNewArticleOpen(false);
setNewArticle({ title: "", content: "", category: "", tags: "" });
toast({ title: "Artigo criado" });
},
});
const convertToWorkItemMutation = useMutation({
mutationFn: async (ticketId: number) => {
const res = await fetch(`/api/support/tickets/${ticketId}/convert-to-work-item`, {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error("Failed to convert");
return res.json();
},
onSuccess: () => {
toast({ title: "Ticket convertido em item de trabalho" });
},
});
const askAiMutation = useMutation({
mutationFn: async ({ ticketId, question }: { ticketId: number; question: string }) => {
const res = await fetch(`/api/support/tickets/${ticketId}/ai-response`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question }),
credentials: "include",
});
if (!res.ok) throw new Error("Failed to get AI response");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/support/tickets", selectedTicket?.id, "conversations"] });
setAiQuestion("");
},
});
const handleAskAi = async () => {
if (!selectedTicket || !aiQuestion.trim()) return;
setIsAiLoading(true);
try {
await askAiMutation.mutateAsync({ ticketId: selectedTicket.id, question: aiQuestion });
} finally {
setIsAiLoading(false);
}
};
const statusColors: Record<string, string> = {
open: "bg-blue-100 text-blue-600",
in_progress: "bg-yellow-100 text-yellow-700",
waiting_customer: "bg-purple-100 text-purple-600",
resolved: "bg-green-100 text-green-600",
closed: "bg-slate-100 text-slate-600",
};
const priorityColors: Record<string, string> = {
low: "border-l-green-400",
medium: "border-l-yellow-400",
high: "border-l-orange-400",
critical: "border-l-red-500",
};
const openTickets = tickets.filter((t) => t.status === "open" || t.status === "in_progress");
const closedTickets = tickets.filter((t) => t.status === "resolved" || t.status === "closed");
return (
<BrowserFrame>
<div className="flex h-full bg-slate-50">
<aside className="w-80 border-r bg-white flex flex-col">
<div className="p-4 border-b flex items-center justify-between">
<h2 className="font-semibold text-lg flex items-center gap-2" data-testid="text-support-title">
<Ticket className="h-5 w-5" />
Suporte
</h2>
<Dialog open={newTicketOpen} onOpenChange={setNewTicketOpen}>
<DialogTrigger asChild>
<Button size="sm" data-testid="button-new-ticket">
<Plus className="h-4 w-4 mr-1" /> Novo
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Novo Ticket</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Título</Label>
<Input value={newTicket.title} onChange={(e) => setNewTicket({ ...newTicket, title: e.target.value })} data-testid="input-ticket-title" />
</div>
<div>
<Label>Descrição</Label>
<Textarea value={newTicket.description} onChange={(e) => setNewTicket({ ...newTicket, description: e.target.value })} data-testid="input-ticket-description" />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>Categoria</Label>
<Select value={newTicket.category} onValueChange={(v) => setNewTicket({ ...newTicket, category: v })}>
<SelectTrigger data-testid="select-ticket-category"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="general">Geral</SelectItem>
<SelectItem value="technical">Técnico</SelectItem>
<SelectItem value="billing">Faturamento</SelectItem>
<SelectItem value="feature">Funcionalidade</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Prioridade</Label>
<Select value={newTicket.priority} onValueChange={(v) => setNewTicket({ ...newTicket, priority: v })}>
<SelectTrigger data-testid="select-ticket-priority"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="low">Baixa</SelectItem>
<SelectItem value="medium">Média</SelectItem>
<SelectItem value="high">Alta</SelectItem>
<SelectItem value="critical">Crítica</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button onClick={() => createTicketMutation.mutate(newTicket)} className="w-full" data-testid="button-save-ticket">
Criar Ticket
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<ScrollArea className="flex-1">
<div className="p-3 space-y-4">
<div>
<span className="text-xs font-medium text-slate-500 uppercase mb-2 block">Abertos ({openTickets.length})</span>
<div className="space-y-2">
{openTickets.map((ticket) => (
<Card
key={ticket.id}
className={`p-3 cursor-pointer border-l-4 ${priorityColors[ticket.priority]} ${selectedTicket?.id === ticket.id ? "ring-2 ring-primary" : "hover:bg-slate-50"}`}
onClick={() => setSelectedTicket(ticket)}
data-testid={`ticket-${ticket.id}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm truncate">{ticket.title}</h4>
<p className="text-xs text-slate-400">#{ticket.id} · {ticket.category}</p>
</div>
<Badge className={statusColors[ticket.status]} variant="secondary">
{ticket.status.replace("_", " ")}
</Badge>
</div>
</Card>
))}
{openTickets.length === 0 && <p className="text-xs text-slate-400 text-center py-2">Nenhum ticket aberto</p>}
</div>
</div>
{closedTickets.length > 0 && (
<div>
<span className="text-xs font-medium text-slate-500 uppercase mb-2 block">Resolvidos ({closedTickets.length})</span>
<div className="space-y-2">
{closedTickets.slice(0, 5).map((ticket) => (
<Card
key={ticket.id}
className={`p-3 cursor-pointer opacity-60 ${selectedTicket?.id === ticket.id ? "ring-2 ring-primary" : "hover:bg-slate-50"}`}
onClick={() => setSelectedTicket(ticket)}
data-testid={`ticket-${ticket.id}`}
>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5" />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm truncate">{ticket.title}</h4>
<p className="text-xs text-slate-400">#{ticket.id}</p>
</div>
</div>
</Card>
))}
</div>
</div>
)}
</div>
</ScrollArea>
</aside>
<main className="flex-1 overflow-hidden flex flex-col">
<Tabs defaultValue="ticket" className="flex-1 flex flex-col">
<div className="border-b bg-white px-4 py-2">
<TabsList>
<TabsTrigger value="ticket" data-testid="tab-ticket">Ticket</TabsTrigger>
<TabsTrigger value="knowledge" data-testid="tab-knowledge">Base de Conhecimento</TabsTrigger>
</TabsList>
</div>
<TabsContent value="ticket" className="flex-1 overflow-hidden flex flex-col p-0">
{!selectedTicket ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<Ticket className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-slate-600">Selecione um Ticket</h3>
<p className="text-sm text-slate-400">Escolha um ticket na lista para ver os detalhes</p>
</div>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="p-4 border-b bg-white">
<div className="flex items-start justify-between">
<div>
<h2 className="text-lg font-semibold">{selectedTicket.title}</h2>
<div className="flex items-center gap-2 mt-1">
<Badge className={statusColors[selectedTicket.status]}>{selectedTicket.status}</Badge>
<span className="text-sm text-slate-400">#{selectedTicket.id} · {selectedTicket.category}</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreHorizontal className="h-4 w-4 mr-1" /> Ações
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => updateTicketMutation.mutate({ id: selectedTicket.id, data: { status: "in_progress" } })}>
<Clock className="h-4 w-4 mr-2" /> Em Andamento
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateTicketMutation.mutate({ id: selectedTicket.id, data: { status: "resolved" } })}>
<CheckCircle2 className="h-4 w-4 mr-2" /> Resolver
</DropdownMenuItem>
<DropdownMenuItem onClick={() => convertToWorkItemMutation.mutate(selectedTicket.id)}>
<ArrowRight className="h-4 w-4 mr-2" /> Converter em Tarefa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{selectedTicket.description && (
<p className="text-sm text-slate-600 mt-2">{selectedTicket.description}</p>
)}
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-4">
{conversations.map((conv) => (
<div
key={conv.id}
className={`flex ${conv.senderType === "customer" ? "justify-start" : "justify-end"}`}
data-testid={`conversation-${conv.id}`}
>
<div className={`max-w-[80%] rounded-lg p-3 ${conv.senderType === "customer" ? "bg-slate-100" : conv.senderType === "ai" ? "bg-purple-100" : "bg-primary text-primary-foreground"}`}>
<div className="flex items-center gap-2 mb-1">
{conv.senderType === "ai" && <Bot className="h-3 w-3" />}
<span className="text-xs opacity-75">{conv.senderType === "customer" ? "Cliente" : conv.senderType === "ai" ? "AI Agent" : "Agente"}</span>
</div>
<p className="text-sm whitespace-pre-wrap">{conv.content}</p>
</div>
</div>
))}
</div>
</ScrollArea>
<div className="p-4 border-t bg-white space-y-3">
<div className="flex gap-2">
<Input
placeholder="Digite uma mensagem..."
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && messageInput.trim() && sendMessageMutation.mutate({ ticketId: selectedTicket.id, content: messageInput })}
data-testid="input-message"
/>
<Button onClick={() => messageInput.trim() && sendMessageMutation.mutate({ ticketId: selectedTicket.id, content: messageInput })} data-testid="button-send-message">
<Send className="h-4 w-4" />
</Button>
</div>
<div className="flex gap-2">
<Input
placeholder="Perguntar ao AI Agent..."
value={aiQuestion}
onChange={(e) => setAiQuestion(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleAskAi()}
className="bg-purple-50 border-purple-200"
data-testid="input-ai-question"
/>
<Button onClick={handleAskAi} disabled={isAiLoading} variant="secondary" className="bg-purple-100 hover:bg-purple-200" data-testid="button-ask-ai">
<Bot className="h-4 w-4 mr-1" /> {isAiLoading ? "..." : "AI"}
</Button>
</div>
</div>
</div>
)}
</TabsContent>
<TabsContent value="knowledge" className="flex-1 overflow-auto p-4">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<BookOpen className="h-5 w-5" />
Base de Conhecimento
</h2>
<Dialog open={newArticleOpen} onOpenChange={setNewArticleOpen}>
<DialogTrigger asChild>
<Button size="sm" data-testid="button-new-article">
<Plus className="h-4 w-4 mr-1" /> Novo Artigo
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Novo Artigo</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Título</Label>
<Input value={newArticle.title} onChange={(e) => setNewArticle({ ...newArticle, title: e.target.value })} data-testid="input-article-title" />
</div>
<div>
<Label>Categoria</Label>
<Input value={newArticle.category} onChange={(e) => setNewArticle({ ...newArticle, category: e.target.value })} data-testid="input-article-category" />
</div>
<div>
<Label>Tags (separadas por vírgula)</Label>
<Input value={newArticle.tags} onChange={(e) => setNewArticle({ ...newArticle, tags: e.target.value })} placeholder="tag1, tag2, tag3" data-testid="input-article-tags" />
</div>
<div>
<Label>Conteúdo</Label>
<Textarea value={newArticle.content} onChange={(e) => setNewArticle({ ...newArticle, content: e.target.value })} rows={8} data-testid="input-article-content" />
</div>
<Button onClick={() => createArticleMutation.mutate(newArticle)} className="w-full" data-testid="button-save-article">
Criar Artigo
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{articles.length === 0 ? (
<Card className="p-8 text-center">
<BookOpen className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500">Nenhum artigo na base de conhecimento</p>
<p className="text-xs text-slate-400 mt-1">Crie artigos para ajudar o AI Agent a responder melhor</p>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{articles.map((article) => (
<Card key={article.id} className="p-4" data-testid={`article-${article.id}`}>
<h3 className="font-medium mb-2">{article.title}</h3>
{article.category && <Badge variant="outline" className="mb-2">{article.category}</Badge>}
<p className="text-sm text-slate-600 line-clamp-3">{article.content}</p>
{article.tags && article.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{article.tags.map((tag, i) => (
<Badge key={i} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
)}
</Card>
))}
</div>
)}
</div>
</TabsContent>
</Tabs>
</main>
</div>
</BrowserFrame>
);
}