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

406 lines
14 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useAuth } from "@/lib/auth";
import {
getTicket,
getComments,
addComment,
updateTicketStatus,
assignTicket,
getGroups,
getTeam,
type Ticket,
type Comment,
type Group,
type TeamMember,
} from "@/lib/api";
import BadgeStatus from "@/components/BadgeStatus";
import BadgePriority from "@/components/BadgePriority";
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" },
];
export default function TicketDetailPage() {
const params = useParams();
const id = Number(params.id);
const { user } = useAuth();
const isTeam = user?.role === "admin" || user?.role === "agent";
const [ticket, setTicket] = useState<Ticket | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
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(() => []),
getGroups().catch(() => []),
getTeam().catch(() => []),
])
.then(([ticketData, commentsData, groupsData, teamData]) => {
setTicket(ticketData);
setComments(commentsData);
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: any) {
setError(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: any) {
setError(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: any) {
setError(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">
{/* Comments Timeline */}
<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>
Comentarios ({comments.length})
</div>
<div className="card-body" style={{ maxHeight: "500px", overflowY: "auto" }}>
{comments.length > 0 ? (
comments.map((c) => (
<div
key={c.id}
className={`comment comment-agent ${c.isInternal ? "comment-internal" : ""}`}
>
<div className="comment-bubble">
<div className="comment-author">
{c.userName || "Usuario"}
{c.isInternal && (
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: "0.65rem" }}>
Nota Interna
</span>
)}
</div>
<div className="comment-text">{c.content}</div>
<div className="comment-time">
{new Date(c.createdAt).toLocaleString("es-CL")}
</div>
</div>
</div>
))
) : (
<div className="text-center text-muted py-4">
<i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
No hay comentarios aún
</div>
)}
</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">{new Date(ticket.createdAt).toLocaleString("es-CL")}</span>
</div>
<div className="ticket-info-row">
<span className="label">Actualizado</span>
<span className="value">{new Date(ticket.updatedAt).toLocaleString("es-CL")}</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>
);
}