753 lines
29 KiB
TypeScript
753 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useParams } from "next/navigation";
|
|
import { useAuth } from "@/lib/auth";
|
|
import {
|
|
getTicket,
|
|
getComments,
|
|
getTicketEmails,
|
|
addComment,
|
|
updateTicketStatus,
|
|
assignTicket,
|
|
getGroups,
|
|
getTeam,
|
|
uploadTicketAttachments,
|
|
getTicketAttachments,
|
|
downloadAttachment,
|
|
formatFileSize,
|
|
type Ticket,
|
|
type Comment,
|
|
type Group,
|
|
type TeamMember,
|
|
type TicketAttachment,
|
|
type TicketSentEmail,
|
|
type TicketReceivedEmail,
|
|
} from "@/lib/api";
|
|
import BadgeStatus from "@/components/BadgeStatus";
|
|
import BadgePriority from "@/components/BadgePriority";
|
|
import { formatDateTime } from "@/lib/format";
|
|
import {
|
|
extractLatestMessage,
|
|
extractLatestHtml,
|
|
stripHtmlToText,
|
|
cleanReplyForSend,
|
|
} from "@/lib/emailThread";
|
|
import Link from "next/link";
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: "open", label: "Abierto" },
|
|
{ value: "pending", label: "Pendiente" },
|
|
{ value: "in_progress", label: "En Progreso" },
|
|
{ value: "resolved", label: "Resuelto" },
|
|
{ value: "closed", label: "Cerrado" },
|
|
];
|
|
|
|
// Hilo único estilo chat: comentarios + correos enviados desde
|
|
// soporte + respuestas recibidas, ordenados cronológicamente. Las
|
|
// respuestas por email que ya generaron comentario se muestran una
|
|
// sola vez (versión email, con asunto y cuerpo).
|
|
// Lado del chat: equipo (agentes/admin/supervisor) a la derecha,
|
|
// solicitante a la izquierda, como en un chat.
|
|
type ThreadItem =
|
|
| { kind: "comment"; key: string; id: number; createdAt: string; userId: string; userName?: string; content: string; isInternal: boolean; side: "team" | "customer" }
|
|
| { kind: "sent"; key: string; createdAt: string; who: string; subject: string; body?: string; fallback: string; side: "team" }
|
|
| { kind: "received"; key: string; createdAt: string; who: string; subject: string; body?: string; status: string; error?: string; side: "customer" };
|
|
|
|
function isRequesterComment(userId: string, ticket: Ticket | null): boolean {
|
|
if (!ticket) return false;
|
|
return String(userId ?? "") !== "" && String(userId) === String(ticket.requesterId);
|
|
}
|
|
|
|
function buildThread(
|
|
ticket: Ticket | null,
|
|
comments: Comment[],
|
|
sent: TicketSentEmail[],
|
|
received: TicketReceivedEmail[]
|
|
): ThreadItem[] {
|
|
const linked = new Set(
|
|
received.map((r) => r.commentId).filter((v): v is number => typeof v === "number")
|
|
);
|
|
const items: ThreadItem[] = [
|
|
...comments
|
|
.filter((c) => !linked.has(c.id))
|
|
.map((c): ThreadItem => ({
|
|
kind: "comment",
|
|
key: `comment-${c.id}`,
|
|
id: c.id,
|
|
createdAt: c.createdAt,
|
|
userId: c.userId,
|
|
userName: c.userName,
|
|
content: c.content,
|
|
isInternal: c.isInternal,
|
|
side: isRequesterComment(c.userId, ticket) ? "customer" : "team",
|
|
})),
|
|
...sent.map((e): ThreadItem => ({
|
|
kind: "sent",
|
|
key: `sent-${e.id}`,
|
|
createdAt: e.createdAt,
|
|
who: e.recipient,
|
|
subject: e.subject,
|
|
body: e.body || undefined,
|
|
fallback: e.template,
|
|
side: "team",
|
|
})),
|
|
...received.map((e, i): ThreadItem => ({
|
|
kind: "received",
|
|
key: `recv-${i}-${e.createdAt}`,
|
|
createdAt: e.createdAt,
|
|
who: e.authorEmail,
|
|
subject: e.subject,
|
|
body: e.body || undefined,
|
|
status: e.status,
|
|
error: e.error,
|
|
side: "customer",
|
|
})),
|
|
];
|
|
return items.sort((a, b) => +new Date(a.createdAt) - +new Date(b.createdAt));
|
|
}
|
|
|
|
// Muestra solo el último mensaje del correo (sin historial citado) con
|
|
// opción de expandir al mensaje completo. Para enviados (HTML) recorta
|
|
// blockquotes/citas; para recibidos (texto) corta en "On ... wrote:",
|
|
// "De:", "----- Mensaje original -----", líneas ">" y firmas.
|
|
function EmailBody({ body, isHtml }: { body: string; isHtml: boolean }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
if (isHtml) {
|
|
const { previewHtml, hasMore } = extractLatestHtml(body);
|
|
const previewText = stripHtmlToText(body);
|
|
const { preview } = extractLatestMessage(previewText);
|
|
// Sin DOM o sin recorte útil: muestra texto del último mensaje
|
|
if (!previewHtml) {
|
|
return (
|
|
<div>
|
|
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>
|
|
{expanded ? previewText : preview}
|
|
</div>
|
|
{(hasMore || expanded) && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-link btn-sm p-0 mt-1 email-toggle"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
>
|
|
{expanded ? (
|
|
<><i className="bi bi-chevron-up me-1"></i>Mostrar solo el último mensaje</>
|
|
) : (
|
|
<><i className="bi bi-chevron-down me-1"></i>Ver mensaje completo</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
if (!hasMore) {
|
|
return <div className="comment-text" dangerouslySetInnerHTML={{ __html: previewHtml }} />;
|
|
}
|
|
return (
|
|
<div>
|
|
{expanded ? (
|
|
<div className="comment-text" dangerouslySetInnerHTML={{ __html: body }} />
|
|
) : (
|
|
<div className="comment-text" dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn btn-link btn-sm p-0 mt-1 email-toggle"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
>
|
|
{expanded ? (
|
|
<><i className="bi bi-chevron-up me-1"></i>Mostrar solo el último mensaje</>
|
|
) : (
|
|
<><i className="bi bi-chevron-down me-1"></i>Ver mensaje completo</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { preview, hasMore } = extractLatestMessage(body);
|
|
if (!hasMore) {
|
|
return <div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{body}</div>;
|
|
}
|
|
return (
|
|
<div>
|
|
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>
|
|
{expanded ? body : preview}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-link btn-sm p-0 mt-1 email-toggle"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
>
|
|
{expanded ? (
|
|
<><i className="bi bi-chevron-up me-1"></i>Mostrar solo el último mensaje</>
|
|
) : (
|
|
<><i className="bi bi-chevron-down me-1"></i>Ver mensaje completo</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function TicketDetailPage() {
|
|
const params = useParams();
|
|
const id = Number(params.id);
|
|
const { user } = useAuth();
|
|
const isTeam =
|
|
user?.role === "admin" || user?.role === "agent" || user?.role === "supervisor";
|
|
|
|
const [ticket, setTicket] = useState<Ticket | null>(null);
|
|
const [comments, setComments] = useState<Comment[]>([]);
|
|
const [sentEmails, setSentEmails] = useState<TicketSentEmail[]>([]);
|
|
const [receivedEmails, setReceivedEmails] = useState<TicketReceivedEmail[]>([]);
|
|
const [groups, setGroups] = useState<Group[]>([]);
|
|
const [team, setTeam] = useState<TeamMember[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [comment, setComment] = useState("");
|
|
const [isInternal, setIsInternal] = useState(false);
|
|
const [cc, setCc] = useState("");
|
|
const [files, setFiles] = useState<File[]>([]);
|
|
// Ids ya subidos pendientes de vincular (si el envío del comentario falló).
|
|
const [pendingIds, setPendingIds] = useState<number[]>([]);
|
|
const [ticketFiles, setTicketFiles] = useState<TicketAttachment[]>([]);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [acting, setActing] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [statusOpen, setStatusOpen] = useState(false);
|
|
const [assignOpen, setAssignOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
getTicket(id).catch(() => null),
|
|
getComments(id).catch(() => []),
|
|
getTicketEmails(id).catch(() => ({ sent: [], received: [] })),
|
|
getGroups().catch(() => []),
|
|
getTeam().catch(() => []),
|
|
getTicketAttachments(id).catch(() => []),
|
|
])
|
|
.then(([ticketData, commentsData, emailsData, groupsData, teamData, filesData]) => {
|
|
setTicket(ticketData);
|
|
setComments(commentsData);
|
|
setSentEmails(emailsData.sent ?? []);
|
|
setReceivedEmails(emailsData.received ?? []);
|
|
setGroups(groupsData);
|
|
setTeam(teamData);
|
|
setTicketFiles(filesData ?? []);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, [id]);
|
|
|
|
const refreshTicket = async () => {
|
|
const updated = await getTicket(id).catch(() => null);
|
|
if (updated) setTicket(updated);
|
|
};
|
|
|
|
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
|
|
const handleAddComment = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!comment.trim()) return;
|
|
// Solo se envía/guarda el mensaje escrito, sin historial pegado o
|
|
// citado (se usa para revisar casos en el futuro).
|
|
const cleaned = cleanReplyForSend(comment);
|
|
if (!cleaned) return;
|
|
if (files.length > 5) {
|
|
setError("Máximo 5 archivos por respuesta");
|
|
return;
|
|
}
|
|
const tooBig = files.find((f) => f.size > MAX_FILE_BYTES);
|
|
if (tooBig) {
|
|
setError(`${tooBig.name}: supera los 10 MB`);
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
setError("");
|
|
let newIds: number[] = [];
|
|
try {
|
|
// 1) Subir adjuntos (si hay) y 2) publicar el comentario con CC e ids.
|
|
if (files.length > 0) {
|
|
const up = await uploadTicketAttachments(id, files);
|
|
newIds = up.files.map((f) => f.id);
|
|
}
|
|
const ids = [...pendingIds, ...newIds];
|
|
await addComment(id, cleaned, isInternal, isInternal ? undefined : cc.trim() || undefined, ids.length > 0 ? ids : undefined);
|
|
const [updated, emails, atts] = await Promise.all([
|
|
getComments(id).catch(() => []),
|
|
getTicketEmails(id).catch(() => ({ sent: [], received: [] })),
|
|
getTicketAttachments(id).catch(() => []),
|
|
]);
|
|
setComments(updated);
|
|
setSentEmails(emails.sent ?? []);
|
|
setReceivedEmails(emails.received ?? []);
|
|
setTicketFiles(atts ?? []);
|
|
setComment("");
|
|
setIsInternal(false);
|
|
setCc("");
|
|
setFiles([]);
|
|
setPendingIds([]);
|
|
} catch (err: unknown) {
|
|
// Si la subida salió pero el comentario falló, los ids quedan
|
|
// pendientes y se vinculan en el próximo envío.
|
|
if (newIds.length > 0) {
|
|
setPendingIds((prev) => [...new Set([...prev, ...newIds])]);
|
|
setFiles([]);
|
|
}
|
|
setError(err instanceof Error ? err.message : "No se pudo enviar el mensaje");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleStatusChange = async (newStatus: string) => {
|
|
setStatusOpen(false);
|
|
setError("");
|
|
setActing(true);
|
|
try {
|
|
await updateTicketStatus(id, newStatus);
|
|
await refreshTicket();
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : "No se pudo cambiar el estado");
|
|
} finally {
|
|
setActing(false);
|
|
}
|
|
};
|
|
|
|
const handleAssignDepartment = async (groupId: number) => {
|
|
if (!ticket) return;
|
|
setAssignOpen(false);
|
|
setError("");
|
|
setActing(true);
|
|
try {
|
|
await assignTicket(id, ticket.assigneeId ?? 0, groupId);
|
|
await refreshTicket();
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : "No se pudo asignar el departamento");
|
|
} finally {
|
|
setActing(false);
|
|
}
|
|
};
|
|
|
|
const handleAssignAgent = async (idUsuario: string | null) => {
|
|
if (!ticket) return;
|
|
setAssignOpen(false);
|
|
setError("");
|
|
setActing(true);
|
|
try {
|
|
await assignTicket(
|
|
id,
|
|
idUsuario === null ? 0 : Number(idUsuario),
|
|
ticket.groupId ?? undefined
|
|
);
|
|
await refreshTicket();
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : "No se pudo asignar el agente");
|
|
} finally {
|
|
setActing(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="loading-spinner">
|
|
<div className="spinner-border text-primary" role="status"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!ticket) {
|
|
return (
|
|
<div className="empty-state">
|
|
<i className="bi bi-exclamation-triangle d-block"></i>
|
|
<h5>Ticket no encontrado</h5>
|
|
<Link href="/tickets" className="btn btn-primary mt-3">Volver a tickets</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Ticket Header */}
|
|
<div className="ticket-header">
|
|
<div>
|
|
<div className="ticket-number mb-1">
|
|
<Link href="/tickets" className="text-decoration-none me-2">
|
|
<i className="bi bi-arrow-left"></i>
|
|
</Link>
|
|
{ticket.ticketNumber}
|
|
</div>
|
|
<h3 className="ticket-subject">{ticket.title}</h3>
|
|
<div className="d-flex gap-2 mt-2">
|
|
<BadgeStatus status={ticket.status} />
|
|
<BadgePriority priority={ticket.priority} />
|
|
{ticket.archived && (
|
|
<span className="badge bg-secondary" title="Ticket archivado">
|
|
<i className="bi bi-archive me-1"></i>Archivado
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{isTeam && (
|
|
<div className="d-flex gap-2">
|
|
{/* Status Change (dropdown controlado por React: sin bootstrap.js) */}
|
|
<div className="position-relative">
|
|
<button
|
|
className="btn btn-outline-primary btn-sm"
|
|
disabled={acting}
|
|
onClick={() => {
|
|
setStatusOpen((o) => !o);
|
|
setAssignOpen(false);
|
|
}}
|
|
>
|
|
<i className="bi bi-arrow-repeat me-1"></i>Cambiar Estado
|
|
</button>
|
|
{statusOpen && (
|
|
<>
|
|
<div
|
|
className="position-fixed top-0 start-0 w-100 h-100"
|
|
style={{ zIndex: 1000 }}
|
|
onClick={() => setStatusOpen(false)}
|
|
/>
|
|
<ul className="dropdown-menu show position-absolute end-0" style={{ zIndex: 1001 }}>
|
|
{STATUS_OPTIONS.map((s) => (
|
|
<li key={s.value}>
|
|
<button
|
|
className={`dropdown-item ${ticket.status === s.value ? "active" : ""}`}
|
|
onClick={() => handleStatusChange(s.value)}
|
|
>
|
|
{s.label}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Assign (dropdown controlado por React: sin bootstrap.js) */}
|
|
<div className="position-relative">
|
|
<button
|
|
className="btn btn-outline-primary btn-sm"
|
|
disabled={acting}
|
|
onClick={() => {
|
|
setAssignOpen((o) => !o);
|
|
setStatusOpen(false);
|
|
}}
|
|
>
|
|
<i className="bi bi-person-plus me-1"></i>Asignar
|
|
</button>
|
|
{assignOpen && (
|
|
<>
|
|
<div
|
|
className="position-fixed top-0 start-0 w-100 h-100"
|
|
style={{ zIndex: 1000 }}
|
|
onClick={() => setAssignOpen(false)}
|
|
/>
|
|
<ul className="dropdown-menu show position-absolute end-0" style={{ zIndex: 1001, minWidth: "220px" }}>
|
|
<li>
|
|
<h6 className="dropdown-header">Departamento</h6>
|
|
</li>
|
|
{groups.length === 0 && (
|
|
<li><span className="dropdown-item-text text-muted">Sin departamentos</span></li>
|
|
)}
|
|
{groups.map((g) => (
|
|
<li key={g.id}>
|
|
<button
|
|
className={`dropdown-item ${ticket.groupId === g.id ? "active" : ""}`}
|
|
onClick={() => handleAssignDepartment(g.id)}
|
|
>
|
|
<i className="bi bi-diagram-3 me-2"></i>{g.name}
|
|
</button>
|
|
</li>
|
|
))}
|
|
<li><hr className="dropdown-divider" /></li>
|
|
<li>
|
|
<h6 className="dropdown-header">Agente</h6>
|
|
</li>
|
|
<li>
|
|
<button className="dropdown-item" onClick={() => handleAssignAgent(null)}>
|
|
<i className="bi bi-person-dash me-2"></i>Sin asignar
|
|
</button>
|
|
</li>
|
|
{team.map((m) => (
|
|
<li key={m.idUsuario}>
|
|
<button
|
|
className="dropdown-item"
|
|
onClick={() => handleAssignAgent(m.idUsuario)}
|
|
>
|
|
<i className="bi bi-person me-2"></i>
|
|
{m.nombres} {m.apPaterno}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="alert alert-danger py-2" role="alert">
|
|
<i className="bi bi-exclamation-circle me-2"></i>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="row g-4">
|
|
{/* Conversación estilo chat: equipo a la derecha, solicitante a la izquierda */}
|
|
<div className="col-lg-8">
|
|
<div className="card mb-4">
|
|
<div className="card-header d-flex align-items-center gap-2">
|
|
<i className="bi bi-chat-dots" style={{ color: "var(--primary)" }}></i>
|
|
Conversación ({buildThread(ticket, comments, sentEmails, receivedEmails).length})
|
|
<small className="text-muted fw-normal ms-auto d-none d-md-inline">
|
|
<span className="chat-legend chat-legend-left me-2">Solicitante</span>
|
|
<span className="chat-legend chat-legend-right">Equipo</span>
|
|
</small>
|
|
</div>
|
|
<div className="card-body chat-thread" style={{ maxHeight: "600px", overflowY: "auto" }}>
|
|
{(() => {
|
|
const thread = buildThread(ticket, comments, sentEmails, receivedEmails);
|
|
if (thread.length === 0) {
|
|
return (
|
|
<div className="text-center text-muted py-4">
|
|
<i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
|
|
Sin conversación aún
|
|
</div>
|
|
);
|
|
}
|
|
return thread.map((m) => {
|
|
if (m.kind === "comment") {
|
|
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
|
|
return (
|
|
<div key={m.key} className={`comment ${alignClass} ${m.isInternal ? "comment-internal" : ""}`}>
|
|
<div className="comment-bubble">
|
|
<div className="comment-author">
|
|
{m.side === "team" ? (
|
|
<><i className="bi bi-headset me-1"></i>{m.userName || "Soporte"}</>
|
|
) : (
|
|
<><i className="bi bi-person me-1"></i>{m.userName || "Solicitante"}</>
|
|
)}
|
|
{m.isInternal && (
|
|
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: "0.65rem" }}>
|
|
Nota Interna
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{m.content}</div>
|
|
{ticketFiles.filter((a) => a.commentId === m.id).map((a) => (
|
|
<button
|
|
key={a.id}
|
|
type="button"
|
|
className="badge bg-light text-dark border d-flex align-items-center gap-1 mt-1"
|
|
style={{ fontSize: "0.72rem", cursor: "pointer" }}
|
|
title={`Descargar ${a.filename} (${formatFileSize(a.size)})`}
|
|
onClick={() => {
|
|
downloadAttachment(a).catch(() => setError("No se pudo descargar el adjunto"));
|
|
}}
|
|
>
|
|
<i className="bi bi-paperclip"></i>
|
|
{a.filename} ({formatFileSize(a.size)})
|
|
<i className="bi bi-download"></i>
|
|
</button>
|
|
))}
|
|
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
const isSent = m.kind === "sent";
|
|
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
|
|
return (
|
|
<div key={m.key} className={`comment ${alignClass}`}>
|
|
<div className="comment-bubble">
|
|
<div className="comment-author">
|
|
<span
|
|
className={`badge ${isSent ? "bg-primary" : "bg-success"} me-2`}
|
|
title={isSent ? "Enviado desde soporte" : "Respuesta recibida por correo"}
|
|
>
|
|
<i className={`bi ${isSent ? "bi-send" : "bi-inbox"}`}></i>
|
|
</span>
|
|
{isSent ? `Soporte → ${m.who}` : `De: ${m.who}`}
|
|
</div>
|
|
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{m.subject}</div>
|
|
{isSent ? (
|
|
m.body ? (
|
|
<EmailBody body={m.body} isHtml={/<[a-z][\s\S]*>/i.test(m.body)} />
|
|
) : (
|
|
<div className="comment-text text-muted">Plantilla: {m.fallback}</div>
|
|
)
|
|
) : m.body ? (
|
|
<EmailBody body={m.body} isHtml={false} />
|
|
) : (
|
|
<div className="comment-text text-muted">
|
|
{m.status === "inserted"
|
|
? "Agregado como comentario"
|
|
: `Omitido: ${m.error || m.status}`}
|
|
</div>
|
|
)}
|
|
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
})()}
|
|
<small className="text-muted d-block mt-2">
|
|
Las respuestas con etiqueta HD-{String(id).padStart(6, "0")} en el asunto
|
|
llegan a este hilo (ingesta cada 5 min). Los correos muestran solo el
|
|
último mensaje; usa “Ver mensaje completo” para el historial.
|
|
</small>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Comment Form */}
|
|
<div className="comment-form">
|
|
<form onSubmit={handleAddComment}>
|
|
<div className="mb-3">
|
|
<textarea
|
|
className="form-control"
|
|
rows={3}
|
|
placeholder={isInternal ? "Nota interna (solo equipo)..." : "Escribe tu respuesta al solicitante..."}
|
|
value={comment}
|
|
onChange={(e) => setComment(e.target.value)}
|
|
required
|
|
></textarea>
|
|
{!isInternal && isTeam && (
|
|
<small className="text-muted d-block mt-1">
|
|
<i className="bi bi-envelope me-1"></i>
|
|
Respuesta pública: se envía por correo al solicitante <strong>solo el mensaje
|
|
escrito</strong> (sin historial) y queda guardada para revisar el caso en el futuro.
|
|
</small>
|
|
)}
|
|
</div>
|
|
{!isInternal && (
|
|
<div className="mb-3">
|
|
<label className="form-label" style={{ fontSize: "0.85rem" }}>
|
|
<i className="bi bi-people me-1"></i>
|
|
CC (copia por correo, separados por coma)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className="form-control form-control-sm"
|
|
placeholder="jefe@norteamericano.cl, otro@norteamericano.cl"
|
|
value={cc}
|
|
onChange={(e) => setCc(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="mb-3">
|
|
<label className="btn btn-outline-secondary btn-sm" style={{ cursor: "pointer" }}>
|
|
<i className="bi bi-paperclip me-1"></i>
|
|
Adjuntar archivos (máx. 10 MB c/u)
|
|
<input
|
|
type="file"
|
|
multiple
|
|
hidden
|
|
onChange={(e) => {
|
|
const picked = Array.from(e.target.files ?? []);
|
|
setFiles((prev) => [...prev, ...picked].slice(0, 5));
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
</label>
|
|
{files.length > 0 && (
|
|
<div className="d-flex flex-wrap gap-2 mt-2">
|
|
{files.map((f, i) => (
|
|
<span key={`${f.name}-${i}`} className="badge bg-light text-dark border d-flex align-items-center gap-1" style={{ fontSize: "0.75rem" }}>
|
|
<i className="bi bi-file-earmark"></i>
|
|
{f.name} ({formatFileSize(f.size)})
|
|
<button
|
|
type="button"
|
|
className="btn-close btn-close-sm"
|
|
style={{ fontSize: "0.6rem" }}
|
|
aria-label="Quitar archivo"
|
|
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
|
|
/>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{pendingIds.length > 0 && (
|
|
<small className="text-warning d-block mt-1">
|
|
<i className="bi bi-exclamation-triangle me-1"></i>
|
|
{pendingIds.length} archivo(s) ya subido(s): se vincularán al enviar.
|
|
</small>
|
|
)}
|
|
</div>
|
|
<div className="d-flex align-items-center justify-content-between">
|
|
<div className="form-check">
|
|
<input
|
|
className="form-check-input"
|
|
type="checkbox"
|
|
id="internalCheck"
|
|
checked={isInternal}
|
|
onChange={(e) => setIsInternal(e.target.checked)}
|
|
/>
|
|
<label className="form-check-label" htmlFor="internalCheck" style={{ fontSize: "0.85rem" }}>
|
|
<i className="bi bi-eye-slash me-1"></i>
|
|
Nota interna (solo agentes)
|
|
</label>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
className="btn btn-primary"
|
|
disabled={!comment.trim() || submitting}
|
|
>
|
|
{submitting ? (
|
|
<span className="spinner-border spinner-border-sm" role="status"></span>
|
|
) : (
|
|
<>
|
|
<i className="bi bi-send me-2"></i>Enviar
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Ticket Info Sidebar */}
|
|
<div className="col-lg-4">
|
|
<div className="ticket-info-card">
|
|
<h6>Información del Ticket</h6>
|
|
<div className="ticket-info-row">
|
|
<span className="label">Solicitante</span>
|
|
<span className="value">{ticket.requesterName || "N/A"}</span>
|
|
</div>
|
|
<div className="ticket-info-row">
|
|
<span className="label">Email</span>
|
|
<span className="value" style={{ fontSize: "0.8rem" }}>{ticket.requesterEmail || "N/A"}</span>
|
|
</div>
|
|
<div className="ticket-info-row">
|
|
<span className="label">Asignado a</span>
|
|
<span className="value">{ticket.assigneeName || "Sin asignar"}</span>
|
|
</div>
|
|
<div className="ticket-info-row">
|
|
<span className="label">Grupo</span>
|
|
<span className="value">{ticket.groupName || "N/A"}</span>
|
|
</div>
|
|
<div className="ticket-info-row">
|
|
<span className="label">Creado</span>
|
|
<span className="value">{formatDateTime(ticket.createdAt)}</span>
|
|
</div>
|
|
<div className="ticket-info-row">
|
|
<span className="label">Actualizado</span>
|
|
<span className="value">{formatDateTime(ticket.updatedAt)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="ticket-info-card">
|
|
<h6>Descripción</h6>
|
|
<p style={{ fontSize: "0.875rem", lineHeight: 1.6, marginBottom: 0 }}>
|
|
{ticket.description || "Sin descripción"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|