Add chat widget improvements and chat history component
Widget improvements: - Pulsating badge with unread message count on floating button - Clickable ticket reference link in chat header - ExternalLink icon on hover Desktop (Raven) improvements: - Track previous unread count for new message detection - Send native Windows notifications for new messages - Focus chat window when new messages arrive Chat history: - New query getTicketChatHistory for fetching chat sessions and messages - New component TicketChatHistory displaying chat sessions - Sessions can be expanded/collapsed to view messages - Pagination support for long conversations - Added to both dashboard and portal ticket views 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b194d77d57
commit
d766de4fda
6 changed files with 453 additions and 9 deletions
|
|
@ -267,6 +267,7 @@ impl ChatPollerHandle {
|
|||
pub struct ChatRuntime {
|
||||
inner: Arc<Mutex<Option<ChatPollerHandle>>>,
|
||||
last_sessions: Arc<Mutex<Vec<ChatSession>>>,
|
||||
last_unread_count: Arc<Mutex<u32>>,
|
||||
}
|
||||
|
||||
impl ChatRuntime {
|
||||
|
|
@ -274,6 +275,7 @@ impl ChatRuntime {
|
|||
Self {
|
||||
inner: Arc::new(Mutex::new(None)),
|
||||
last_sessions: Arc::new(Mutex::new(Vec::new())),
|
||||
last_unread_count: Arc::new(Mutex::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,6 +303,7 @@ impl ChatRuntime {
|
|||
let base_clone = sanitized_base.clone();
|
||||
let token_clone = token.clone();
|
||||
let last_sessions = self.last_sessions.clone();
|
||||
let last_unread_count = self.last_unread_count.clone();
|
||||
|
||||
let join_handle = tauri::async_runtime::spawn(async move {
|
||||
crate::log_info!("Chat polling iniciado");
|
||||
|
|
@ -379,10 +382,15 @@ impl ChatRuntime {
|
|||
}
|
||||
|
||||
// Verificar mensagens nao lidas e emitir evento
|
||||
let prev_unread = *last_unread_count.lock();
|
||||
let new_messages = result.total_unread > prev_unread;
|
||||
*last_unread_count.lock() = result.total_unread;
|
||||
|
||||
if result.total_unread > 0 {
|
||||
crate::log_info!(
|
||||
"Chat: {} mensagens nao lidas",
|
||||
result.total_unread
|
||||
"Chat: {} mensagens nao lidas (prev={})",
|
||||
result.total_unread,
|
||||
prev_unread
|
||||
);
|
||||
let _ = app.emit(
|
||||
"raven://chat/unread-update",
|
||||
|
|
@ -391,6 +399,37 @@ impl ChatRuntime {
|
|||
"sessions": result.sessions
|
||||
}),
|
||||
);
|
||||
|
||||
// Notificar novas mensagens (apenas se aumentou)
|
||||
if new_messages && prev_unread > 0 {
|
||||
let new_count = result.total_unread - prev_unread;
|
||||
let notification_title = "Nova mensagem de suporte";
|
||||
let notification_body = if new_count == 1 {
|
||||
"Voce recebeu 1 nova mensagem no chat".to_string()
|
||||
} else {
|
||||
format!("Voce recebeu {} novas mensagens no chat", new_count)
|
||||
};
|
||||
if let Err(e) = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title(notification_title)
|
||||
.body(¬ification_body)
|
||||
.show()
|
||||
{
|
||||
crate::log_warn!(
|
||||
"Falha ao enviar notificacao de nova mensagem: {e}"
|
||||
);
|
||||
}
|
||||
|
||||
// Focar janela de chat se existir
|
||||
if let Some(session) = result.sessions.first() {
|
||||
let label = format!("chat-{}", session.ticket_id);
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Sem sessoes ativas
|
||||
|
|
|
|||
|
|
@ -594,3 +594,93 @@ export const listAgentSessions = query({
|
|||
return result.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
||||
},
|
||||
})
|
||||
|
||||
// Historico de sessoes de chat de um ticket (para exibicao no painel)
|
||||
export const getTicketChatHistory = query({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
viewerId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, { ticketId, viewerId }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
return { sessions: [], totalMessages: 0 }
|
||||
}
|
||||
|
||||
const viewer = await ctx.db.get(viewerId)
|
||||
if (!viewer || viewer.tenantId !== ticket.tenantId) {
|
||||
return { sessions: [], totalMessages: 0 }
|
||||
}
|
||||
|
||||
// Buscar todas as sessoes do ticket (ativas e finalizadas)
|
||||
const sessions = await ctx.db
|
||||
.query("liveChatSessions")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
||||
.collect()
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return { sessions: [], totalMessages: 0 }
|
||||
}
|
||||
|
||||
// Buscar todas as mensagens do ticket
|
||||
const allMessages = await ctx.db
|
||||
.query("ticketChatMessages")
|
||||
.withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId))
|
||||
.collect()
|
||||
|
||||
// Agrupar mensagens por sessao (baseado no timestamp)
|
||||
// Mensagens entre startedAt e endedAt pertencem a sessao
|
||||
const sessionResults = await Promise.all(
|
||||
sessions
|
||||
.sort((a, b) => b.startedAt - a.startedAt) // Mais recente primeiro
|
||||
.map(async (session) => {
|
||||
const sessionMessages = allMessages.filter((msg) => {
|
||||
// Mensagem criada durante a sessao
|
||||
if (msg.createdAt < session.startedAt) return false
|
||||
if (session.endedAt && msg.createdAt > session.endedAt) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// Obter nome da maquina
|
||||
let machineName = "Cliente"
|
||||
if (ticket.machineId) {
|
||||
const machine = await ctx.db.get(ticket.machineId)
|
||||
if (machine?.hostname) {
|
||||
machineName = machine.hostname
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session._id,
|
||||
agentName: session.agentSnapshot?.name ?? "Agente",
|
||||
agentEmail: session.agentSnapshot?.email ?? null,
|
||||
agentAvatarUrl: session.agentSnapshot?.avatarUrl ?? null,
|
||||
machineName,
|
||||
status: session.status,
|
||||
startedAt: session.startedAt,
|
||||
endedAt: session.endedAt ?? null,
|
||||
messageCount: sessionMessages.length,
|
||||
messages: sessionMessages
|
||||
.sort((a, b) => a.createdAt - b.createdAt)
|
||||
.map((msg) => ({
|
||||
id: msg._id,
|
||||
body: msg.body,
|
||||
authorName: msg.authorSnapshot?.name ?? "Usuario",
|
||||
authorId: String(msg.authorId),
|
||||
createdAt: msg.createdAt,
|
||||
attachments: (msg.attachments ?? []).map((att) => ({
|
||||
storageId: att.storageId,
|
||||
name: att.name,
|
||||
type: att.type ?? null,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
sessions: sessionResults,
|
||||
totalMessages: allMessages.length,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
FileText,
|
||||
Image as ImageIcon,
|
||||
Download,
|
||||
ExternalLink,
|
||||
} from "lucide-react"
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 4000
|
||||
|
|
@ -444,10 +445,16 @@ export function ChatWidget() {
|
|||
)}
|
||||
</div>
|
||||
{activeSession && (
|
||||
<p className="text-xs text-slate-500">
|
||||
#{activeSession.ticketRef}
|
||||
{machineHostname && ` - ${machineHostname}`}
|
||||
</p>
|
||||
<a
|
||||
href={`/dashboard/tickets/${activeTicketId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-1 text-xs text-slate-500 hover:text-primary transition-colors"
|
||||
>
|
||||
<span>#{activeSession.ticketRef}</span>
|
||||
{machineHostname && <span> - {machineHostname}</span>}
|
||||
<ExternalLink className="size-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -725,9 +732,15 @@ export function ChatWidget() {
|
|||
>
|
||||
<MessageCircle className="size-6" />
|
||||
{totalUnread > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex size-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
|
||||
{totalUnread}
|
||||
</span>
|
||||
<>
|
||||
{/* Anel pulsante externo */}
|
||||
<span className="absolute -right-1 -top-1 flex size-6 items-center justify-center">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
|
||||
<span className="relative flex size-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
|
||||
{totalUnread > 99 ? "99+" : totalUnread}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card"
|
||||
import { TicketChatHistory } from "@/components/tickets/ticket-chat-history"
|
||||
import { TicketCustomFieldsList } from "@/components/tickets/ticket-custom-fields"
|
||||
import { mapTicketCustomFields, formatTicketCustomFieldValue } from "@/lib/ticket-custom-fields"
|
||||
|
||||
|
|
@ -504,6 +505,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
<TicketCsatCard ticket={ticket} />
|
||||
</div>
|
||||
) : null}
|
||||
<TicketChatHistory ticketId={ticketId} />
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between px-5 py-4">
|
||||
|
|
|
|||
298
src/components/tickets/ticket-chat-history.tsx
Normal file
298
src/components/tickets/ticket-chat-history.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery, useAction } from "convex/react"
|
||||
import { formatDistanceToNow, format } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
MessageCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
User,
|
||||
Headphones,
|
||||
Clock,
|
||||
Download,
|
||||
FileText,
|
||||
Paperclip,
|
||||
} from "lucide-react"
|
||||
|
||||
type ChatHistoryProps = {
|
||||
ticketId: string
|
||||
}
|
||||
|
||||
type ChatMessage = {
|
||||
id: string
|
||||
body: string
|
||||
authorName: string
|
||||
authorId: string
|
||||
createdAt: number
|
||||
attachments: Array<{
|
||||
storageId: string
|
||||
name: string
|
||||
type: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
type ChatSession = {
|
||||
sessionId: string
|
||||
agentName: string
|
||||
agentEmail: string | null
|
||||
agentAvatarUrl: string | null
|
||||
machineName: string
|
||||
status: string
|
||||
startedAt: number
|
||||
endedAt: number | null
|
||||
messageCount: number
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
|
||||
const MESSAGES_PER_PAGE = 20
|
||||
|
||||
function MessageAttachmentPreview({ attachment }: { attachment: { storageId: string; name: string; type: string | null } }) {
|
||||
const getFileUrl = useAction(api.files.getUrl)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleDownload = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = await getFileUrl({ storageId: attachment.storageId as Id<"_storage"> })
|
||||
if (url) {
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = attachment.name
|
||||
a.target = "_blank"
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao baixar arquivo:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isImage = attachment.type?.startsWith("image/")
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-md border border-slate-200 bg-slate-50 px-2 py-1 text-xs hover:bg-slate-100 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-3" />
|
||||
) : isImage ? (
|
||||
<FileText className="size-3 text-slate-500" />
|
||||
) : (
|
||||
<Paperclip className="size-3 text-slate-500" />
|
||||
)}
|
||||
<span className="max-w-[100px] truncate">{attachment.name}</span>
|
||||
<Download className="size-3 text-slate-400" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatSessionCard({ session, isExpanded, onToggle }: { session: ChatSession; isExpanded: boolean; onToggle: () => void }) {
|
||||
const [visibleMessages, setVisibleMessages] = useState(MESSAGES_PER_PAGE)
|
||||
|
||||
const isActive = session.status === "ACTIVE"
|
||||
const duration = session.endedAt
|
||||
? Math.round((session.endedAt - session.startedAt) / 60000)
|
||||
: Math.round((Date.now() - session.startedAt) / 60000)
|
||||
|
||||
const loadMore = () => {
|
||||
setVisibleMessages((prev) => prev + MESSAGES_PER_PAGE)
|
||||
}
|
||||
|
||||
const displayedMessages = session.messages.slice(0, visibleMessages)
|
||||
const hasMore = session.messages.length > visibleMessages
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
{/* Header da sessao */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-full",
|
||||
isActive ? "bg-emerald-100 text-emerald-600" : "bg-slate-100 text-slate-600"
|
||||
)}
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
Chat com {session.agentName}
|
||||
</p>
|
||||
{isActive && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||
<span className="size-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Ativo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span>{format(session.startedAt, "dd/MM/yyyy 'as' HH:mm", { locale: ptBR })}</span>
|
||||
<span>-</span>
|
||||
<span>{session.messageCount} mensagens</span>
|
||||
<span>-</span>
|
||||
<span>{duration} min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="size-4 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Mensagens */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-slate-100">
|
||||
{session.messages.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-6 text-sm text-slate-400">
|
||||
Nenhuma mensagem nesta sessao
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="max-h-[400px] overflow-y-auto p-4 space-y-3 bg-slate-50/50">
|
||||
{displayedMessages.map((msg) => {
|
||||
// Verificar se eh do agente (nao eh da maquina)
|
||||
const isAgent = msg.authorName === session.agentName
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn("flex gap-2", isAgent ? "flex-row" : "flex-row-reverse")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full",
|
||||
isAgent ? "bg-primary/10 text-primary" : "bg-slate-200 text-slate-600"
|
||||
)}
|
||||
>
|
||||
{isAgent ? <Headphones className="size-3.5" /> : <User className="size-3.5" />}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[80%] rounded-2xl px-3 py-2",
|
||||
isAgent
|
||||
? "rounded-bl-md bg-white border border-slate-200 text-slate-900"
|
||||
: "rounded-br-md bg-slate-700 text-white"
|
||||
)}
|
||||
>
|
||||
<p className="mb-0.5 text-xs font-medium opacity-70">
|
||||
{msg.authorName}
|
||||
</p>
|
||||
{msg.body && (
|
||||
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
|
||||
)}
|
||||
{msg.attachments.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{msg.attachments.map((att, i) => (
|
||||
<MessageAttachmentPreview key={i} attachment={att} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className={cn("mt-1 text-right text-xs", isAgent ? "text-slate-400" : "text-white/60")}>
|
||||
{format(msg.createdAt, "HH:mm", { locale: ptBR })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Carregar mais */}
|
||||
{hasMore && (
|
||||
<div className="border-t border-slate-100 p-3 text-center">
|
||||
<Button variant="ghost" size="sm" onClick={loadMore}>
|
||||
Carregar mais ({session.messages.length - visibleMessages} restantes)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Rodape com info de encerramento */}
|
||||
{session.endedAt && (
|
||||
<div className="border-t border-slate-100 px-4 py-2 bg-slate-50">
|
||||
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Clock className="size-3" />
|
||||
Encerrado em {format(session.endedAt, "dd/MM/yyyy 'as' HH:mm", { locale: ptBR })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TicketChatHistory({ ticketId }: ChatHistoryProps) {
|
||||
const { convexUserId } = useAuth()
|
||||
const [expandedSessions, setExpandedSessions] = useState<Set<string>>(new Set())
|
||||
|
||||
const chatHistory = useQuery(
|
||||
api.liveChat.getTicketChatHistory,
|
||||
convexUserId
|
||||
? { ticketId: ticketId as Id<"tickets">, viewerId: convexUserId as Id<"users"> }
|
||||
: "skip"
|
||||
)
|
||||
|
||||
const toggleSession = (sessionId: string) => {
|
||||
setExpandedSessions((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(sessionId)) {
|
||||
next.delete(sessionId)
|
||||
} else {
|
||||
next.add(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Nao mostrar se nao ha historico
|
||||
if (!chatHistory || chatHistory.sessions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="border-b border-slate-100 bg-slate-50/50 px-4 py-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-semibold text-slate-900">
|
||||
<MessageCircle className="size-4" />
|
||||
Historico de Chat
|
||||
<span className="ml-auto text-xs font-normal text-slate-500">
|
||||
{chatHistory.sessions.length} {chatHistory.sessions.length === 1 ? "sessao" : "sessoes"} - {chatHistory.totalMessages} mensagens
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{chatHistory.sessions.map((session: ChatSession) => (
|
||||
<ChatSessionCard
|
||||
key={session.sessionId}
|
||||
session={session}
|
||||
isExpanded={expandedSessions.has(session.sessionId)}
|
||||
onToggle={() => toggleSession(session.sessionId)}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
|||
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
|
||||
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
|
||||
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card";
|
||||
import { TicketChatHistory } from "@/components/tickets/ticket-chat-history";
|
||||
import { useAuth } from "@/lib/auth-client";
|
||||
|
||||
export function TicketDetailView({ id }: { id: string }) {
|
||||
|
|
@ -107,6 +108,7 @@ export function TicketDetailView({ id }: { id: string }) {
|
|||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="space-y-6">
|
||||
<TicketComments ticket={ticket} />
|
||||
<TicketChatHistory ticketId={id} />
|
||||
<TicketTimeline ticket={ticket} />
|
||||
</div>
|
||||
<TicketDetailsPanel ticket={ticket} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue