Files
top-tickets/src/app/(admin)/tickets/[id]/ticket-thread.tsx
T
ogrechkoandClaude Sonnet 5 b92d9801ff Surface channel delivery failures on agent replies instead of swallowing them
The previous fix made a broken SMTP/Telegram account non-fatal — the
reply gets recorded either way now — but that also made delivery
failures completely invisible: an agent sees their reply "sent"
successfully with no indication the customer never actually received it.
Added messages.deliveryFailed, set whenever the outbound send throws, and
a warning badge on the message bubble so this is now visible instead of
silent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-10-07 08:26:54 +00:00

389 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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, TriangleAlert } 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 { MessageBodyHtml } from "@/components/message-body-html";
import { TagPicker } from "./tag-picker";
import type { TicketDTO, MessageDTO, TicketStatus } from "@/lib/tickets/types";
const STATUS_LABEL: Record<TicketStatus, string> = {
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<File | null>(null);
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [cannedResponses, setCannedResponses] = useState<{ id: string; title: string; body: string }[]>([]);
const [cannedOpen, setCannedOpen] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const cannedRef = useRef<HTMLDivElement>(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);
setSendError(null);
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 = "";
} else {
const data = await res.json().catch(() => null);
setSendError(data?.error ?? "Не удалось отправить сообщение");
}
}
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 (
<div className="flex h-[calc(100vh-11rem)] flex-col sm:h-[calc(100vh-6.5rem)]">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<Link href="/dashboard" className="mb-1 flex items-center gap-1 text-xs text-text-muted hover:text-text">
<ArrowLeft size={13} />
Дашборд
</Link>
<h1 className="truncate font-display text-lg font-bold">{ticket.subject}</h1>
<span className="mb-2 flex items-center gap-1.5">
<Avatar name={ticket.customerName} size={18} />
<span className="text-sm text-text-muted">{ticket.customerName}</span>
</span>
<TagPicker
ticketId={ticket.id}
selectedTags={ticket.tags}
onChange={(tags) => setTicket((t) => ({ ...t, tags }))}
/>
</div>
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
<select
value={ticket.status}
onChange={(e) => updateStatus(e.target.value as TicketStatus)}
className="min-w-0 flex-1 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent sm:flex-none"
>
{Object.entries(STATUS_LABEL).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<select
value={ticket.assigneeId ?? ""}
onChange={(e) => updateAssignee(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent sm:flex-none"
>
<option value="">Не назначено</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</div>
</div>
<div className="card flex-1 overflow-y-auto p-4">
<AnimatePresence initial={false}>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</AnimatePresence>
<div ref={bottomRef} />
</div>
<div className="mt-3">
{/* Internal notes are admin-only — agents only ever reply to the client, so there's nothing to toggle. */}
{isAdmin && (
<div className="mb-1.5 flex gap-1">
<button
type="button"
onClick={() => setVisibility("public")}
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
visibility === "public" ? "bg-accent-soft text-accent-soft-text" : "text-text-faint hover:text-text-muted"
}`}
>
Ответ клиенту
</button>
<button
type="button"
onClick={() => setVisibility("internal")}
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
visibility === "internal" ? "bg-warning-soft text-warning-soft-text" : "text-text-faint hover:text-text-muted"
}`}
>
Заметка для команды
</button>
</div>
)}
{sendError && <p className="mb-1.5 text-xs text-danger-soft-text">{sendError}</p>}
{pendingFile && (
<div className="mb-1.5 flex items-center gap-2 rounded-md border border-border bg-surface-hover px-2.5 py-1.5 text-xs">
<Paperclip size={12} />
<span className="flex-1 truncate">{pendingFile.name}</span>
<button type="button" onClick={() => setPendingFile(null)} className="text-text-muted hover:text-text">
<X size={13} />
</button>
</div>
)}
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => setPendingFile(e.target.files?.[0] ?? null)}
/>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendReply();
}
}}
onPaste={(e) => {
// Ctrl+V a screenshot straight from the clipboard — no need to
// save it to disk first and click "attach file". Only kicks in
// when the clipboard actually holds image data; a normal text
// paste falls through untouched.
const item = Array.from(e.clipboardData.items).find((i) => i.kind === "file" && i.type.startsWith("image/"));
if (!item) return;
const file = item.getAsFile();
if (!file) return;
e.preventDefault();
const extension = item.type.split("/")[1] ?? "png";
setPendingFile(new File([file], `screenshot-${Date.now()}.${extension}`, { type: item.type }));
}}
rows={2}
placeholder={visibility === "internal" ? "Заметка — клиент её не увидит…" : "Ответить клиенту…"}
className={`flex-1 resize-none rounded-md border px-3 py-2 text-sm outline-none ${
visibility === "internal"
? "border-warning-soft-text/30 bg-warning-soft focus:border-warning"
: "border-border bg-surface focus:border-accent"
}`}
/>
<div ref={cannedRef} className="relative self-end">
<button
type="button"
onClick={() => setCannedOpen((v) => !v)}
className="btn btn-ghost"
title="Шаблоны ответов"
>
<MessageSquareText size={15} />
</button>
{cannedOpen && (
<div className="card absolute bottom-full right-0 z-20 mb-2 max-h-64 w-72 overflow-y-auto p-1">
{cannedResponses.length === 0 && (
<p className="p-2 text-xs text-text-faint">Шаблонов пока нет</p>
)}
{cannedResponses.map((c) => (
<button
key={c.id}
type="button"
onClick={() => insertCanned(c.body)}
className="block w-full rounded-md px-3 py-2 text-left text-sm hover:bg-surface-hover"
>
<p className="font-medium">{c.title}</p>
<p className="truncate text-xs text-text-faint">{c.body}</p>
</button>
))}
</div>
)}
</div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="btn btn-ghost self-end"
title="Прикрепить файл"
>
<Paperclip size={15} />
</button>
<button
onClick={sendReply}
disabled={sending || (!draft.trim() && !pendingFile)}
className={visibility === "internal" ? "btn self-end bg-warning text-white hover:brightness-95" : "btn btn-primary self-end"}
>
{visibility === "internal" ? <StickyNote size={15} /> : <Send size={15} />}
</button>
</div>
</div>
</div>
);
}
function MessageBubble({ message }: { message: MessageDTO }) {
const isAgent = message.authorType === "agent";
const isSystem = message.authorType === "system";
if (isSystem) {
return (
<p className="my-2 text-center text-xs text-text-faint">{message.body}</p>
);
}
if (message.visibility === "internal") {
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
className="mb-3 rounded-lg border border-dashed border-warning-soft-text/40 bg-warning-soft px-3 py-2"
>
<p className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-warning-soft-text">
<StickyNote size={11} />
Внутренняя заметка
</p>
{message.bodyHtml ? (
<MessageBodyHtml html={message.bodyHtml} className="mb-1" />
) : (
<p className="mb-1 whitespace-pre-wrap text-sm">{message.body}</p>
)}
{message.attachments
.filter((a) => !a.isInline)
.map((a) => (
<AttachmentChip key={a.id} attachment={a} />
))}
<p className="text-[11px] text-warning-soft-text/80">
{message.authorName} · {formatRelativeTime(message.createdAt)}
</p>
</motion.div>
);
}
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
className={`mb-3 flex ${isAgent ? "justify-end" : "justify-start"}`}
>
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isAgent ? "bg-accent text-white" : "bg-surface-hover"}`}>
{message.bodyHtml ? (
<MessageBodyHtml html={message.bodyHtml} className="mb-1" />
) : (
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
)}
{message.attachments
.filter((a) => !a.isInline)
.map((a) => (
<AttachmentChip key={a.id} attachment={a} />
))}
<p className={`text-[11px] ${isAgent ? "text-white/70" : "text-text-faint"}`}>
{message.authorName} · {formatRelativeTime(message.createdAt)}
</p>
{message.deliveryFailed && (
<p
className="mt-1 flex items-center gap-1 text-[11px] font-medium text-danger"
title="Сообщение сохранено, но не дошло до клиента — проверьте настройки почты/Telegram"
>
<TriangleAlert size={11} />
Не доставлено клиенту
</p>
)}
</div>
</motion.div>
);
}