Files
helpdesk/frontend/src/app/(app)/tickets/[id]/page.tsx
T

510 lines
19 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,
type Ticket,
type Comment,
type Group,
type TeamMember,
type TicketSentEmail,
type TicketReceivedEmail,
} from "@/lib/api";
import BadgeStatus from "@/components/BadgeStatus";
import BadgePriority from "@/components/BadgePriority";
import { formatDateTime } from "@/lib/format";
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 Freshdesk: 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).
type ThreadItem =
| { kind: "comment"; key: string; createdAt: string; userName?: string; content: string; isInternal: boolean }
| { kind: "sent"; key: string; createdAt: string; who: string; subject: string; body?: string; fallback: string }
| { kind: "received"; key: string; createdAt: string; who: string; subject: string; body?: string; status: string; error?: string };
function buildThread(
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}`,
createdAt: c.createdAt,
userName: c.userName,
content: c.content,
isInternal: c.isInternal,
})),
...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,
})),
...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,
})),
];
return items.sort((a, b) => +new Date(a.createdAt) - +new Date(b.createdAt));
}
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 [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(() => []),
])
.then(([ticketData, commentsData, emailsData, groupsData, teamData]) => {
setTicket(ticketData);
setComments(commentsData);
setSentEmails(emailsData.sent ?? []);
setReceivedEmails(emailsData.received ?? []);
setGroups(groupsData);
setTeam(teamData);
})
.finally(() => setLoading(false));
}, [id]);
const refreshTicket = async () => {
const updated = await getTicket(id).catch(() => null);
if (updated) setTicket(updated);
};
const handleAddComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!comment.trim()) return;
setSubmitting(true);
try {
await addComment(id, comment, isInternal);
const updated = await getComments(id).catch(() => []);
setComments(updated);
setComment("");
setIsInternal(false);
} catch {
// ignore
} 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} />
</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 Freshdesk: comentarios + hilo de correos */}
<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(comments, sentEmails, receivedEmails).length})
</div>
<div className="card-body" style={{ maxHeight: "600px", overflowY: "auto" }}>
{(() => {
const thread = buildThread(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") {
return (
<div key={m.key} className={`comment comment-agent ${m.isInternal ? "comment-internal" : ""}`}>
<div className="comment-bubble">
<div className="comment-author">
{m.userName || "Usuario"}
{m.isInternal && (
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: "0.65rem" }}>
Nota Interna
</span>
)}
</div>
<div className="comment-text">{m.content}</div>
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
</div>
</div>
);
}
const isSent = m.kind === "sent";
return (
<div key={m.key} className="comment comment-agent">
<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 ? (
<div
className="comment-text"
dangerouslySetInnerHTML={{ __html: m.body }}
/>
) : (
<div className="comment-text text-muted">Plantilla: {m.fallback}</div>
)
) : m.body ? (
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{m.body}</div>
) : (
<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).
</small>
</div>
</div>
{/* Comment Form */}
<div className="comment-form">
<form onSubmit={handleAddComment}>
<div className="mb-3">
<textarea
className="form-control"
rows={3}
placeholder="Escribe tu comentario..."
value={comment}
onChange={(e) => setComment(e.target.value)}
required
></textarea>
</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>
);
}