"use client"; import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { AnimatePresence, motion } from "framer-motion"; import { ArrowLeft, Send, StickyNote, Paperclip, X, MessageSquareText } from "lucide-react"; import { useTicketEvents } from "@/lib/events/use-ticket-events"; import { formatRelativeTime } from "@/lib/format"; import { Avatar } from "@/components/avatar"; import { AttachmentChip } from "@/components/attachment-chip"; import { TagPicker } from "./tag-picker"; import type { TicketDTO, MessageDTO, TicketStatus } from "@/lib/tickets/types"; const STATUS_LABEL: Record = { new: "Новая", open: "Открыта", pending: "В ожидании", closed: "Закрыта", }; export function TicketThread({ ticket: initialTicket, initialMessages, agents, currentUser, }: { ticket: TicketDTO; initialMessages: MessageDTO[]; agents: { id: string; name: string }[]; currentUser: { id: string; role: "admin" | "agent" }; }) { const isAdmin = currentUser.role === "admin"; const [ticket, setTicket] = useState(initialTicket); const [messages, setMessages] = useState(initialMessages); const [draft, setDraft] = useState(""); const [visibility, setVisibility] = useState<"public" | "internal">("public"); const [pendingFile, setPendingFile] = useState(null); const [sending, setSending] = useState(false); const [cannedResponses, setCannedResponses] = useState<{ id: string; title: string; body: string }[]>([]); const [cannedOpen, setCannedOpen] = useState(false); const bottomRef = useRef(null); const fileInputRef = useRef(null); const cannedRef = useRef(null); useEffect(() => { fetch("/api/canned-responses") .then((res) => res.json()) .then((data) => setCannedResponses(data.responses ?? [])); }, []); useEffect(() => { function onClickOutside(e: MouseEvent) { if (cannedRef.current && !cannedRef.current.contains(e.target as Node)) setCannedOpen(false); } document.addEventListener("mousedown", onClickOutside); return () => document.removeEventListener("mousedown", onClickOutside); }, []); function insertCanned(body: string) { setDraft((prev) => (prev.trim() ? `${prev}\n${body}` : body)); setCannedOpen(false); } useTicketEvents("/api/events", (event) => { if (event.type === "ticket.updated" && event.ticket.id === ticket.id) { setTicket(event.ticket); } if (event.type === "message.created" && event.ticketId === ticket.id) { // Internal notes are admin-only — an agent's SSE connection still // receives every event on the bus, so this has to be filtered here // too, not just at the initial server-rendered load. if (event.message.visibility === "internal" && !isAdmin) return; setMessages((prev) => (prev.some((m) => m.id === event.message.id) ? prev : [...prev, event.message])); } }); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages.length]); async function sendReply() { if (!draft.trim() && !pendingFile) return; setSending(true); let res: Response; if (pendingFile) { const formData = new FormData(); formData.append("file", pendingFile); formData.append("caption", draft); formData.append("visibility", visibility); res = await fetch(`/api/tickets/${ticket.id}/attachments`, { method: "POST", body: formData }); } else { res = await fetch(`/api/tickets/${ticket.id}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body: draft, visibility }), }); } setSending(false); if (res.ok) { setDraft(""); setPendingFile(null); if (fileInputRef.current) fileInputRef.current.value = ""; } } async function updateStatus(status: TicketStatus) { const res = await fetch(`/api/tickets/${ticket.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status }), }); if (res.ok) { const { ticket: updated } = await res.json(); setTicket(updated); } } async function updateAssignee(assigneeId: string) { const res = await fetch(`/api/tickets/${ticket.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ assigneeId: assigneeId || null }), }); if (res.ok) { const { ticket: updated } = await res.json(); setTicket(updated); } } return (
Дашборд

{ticket.subject}

{ticket.customerName} setTicket((t) => ({ ...t, tags }))} />
{messages.map((message) => ( ))}
{/* Internal notes are admin-only — agents only ever reply to the client, so there's nothing to toggle. */} {isAdmin && (
)} {pendingFile && (
{pendingFile.name}
)}
setPendingFile(e.target.files?.[0] ?? null)} />