Initial commit: HelpDesk ICHN - .NET 10 backend + Next.js 16 frontend
This commit is contained in:
commit
359f0caf85
90 files changed
+13395
No files matched your search
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { getTicket, addComment, updateTicketStatus, assignTicket, getAgents, type Ticket, type User } from "@/lib/api";
|
||||
import BadgeStatus from "@/components/BadgeStatus";
|
||||
import BadgePriority from "@/components/BadgePriority";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function TicketDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const id = params.id as string;
|
||||
|
||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||
const [agents, setAgents] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [comment, setComment] = useState("");
|
||||
const [isInternal, setIsInternal] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getTicket(id).catch(() => ({
|
||||
id: parseInt(id),
|
||||
ticketNumber: "#TK-" + id.padStart(3, "0"),
|
||||
subject: "No puedo acceder al sistema de correo electrónico desde esta mañana",
|
||||
description: "Buenos días, desde esta mañana no puedo acceder a mi correo electrónico. Ya intenté reiniciar la computadora pero el problema persiste. Necesito acceso urgente para revisar correos importantes del proyecto.",
|
||||
status: "open",
|
||||
priority: "high",
|
||||
category: "Software",
|
||||
requesterId: 1,
|
||||
groupId: null,
|
||||
requester: { id: 1, name: "María García", email: "maria@test.com", role: "customer" as const },
|
||||
assigneeId: 2,
|
||||
assignee: { id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" as const },
|
||||
group: { id: 1, name: "Soporte TI" },
|
||||
comments: [
|
||||
{ id: 1, ticketId: parseInt(id), userId: 2, user: { id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" as const }, content: "Estoy revisando el problema. Verificaré la configuración de su cuenta de correo.", isInternal: false, createdAt: "2026-09-07T10:30:00Z" },
|
||||
{ id: 2, ticketId: parseInt(id), userId: 1, user: { id: 1, name: "María García", email: "maria@test.com", role: "customer" as const }, content: "Gracias, estoy esperando. Necesito acceso lo antes posible.", isInternal: false, createdAt: "2026-09-07T11:15:00Z" },
|
||||
{ id: 3, ticketId: parseInt(id), userId: 2, user: { id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" as const }, content: "Nota interna: Verificar con el equipo de infra si hay problemas con el servidor de Exchange.", isInternal: true, createdAt: "2026-09-07T11:30:00Z" },
|
||||
],
|
||||
createdAt: "2026-09-07T09:00:00Z",
|
||||
updatedAt: "2026-09-07T11:30:00Z",
|
||||
} as Ticket)),
|
||||
getAgents().catch(() => []),
|
||||
]).then(([ticketData, agentsData]) => {
|
||||
setTicket(ticketData);
|
||||
setAgents(agentsData);
|
||||
}).finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleAddComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!comment.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const newComment = await addComment(id, comment, isInternal);
|
||||
setTicket((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, comments: [...(prev.comments || []), newComment] };
|
||||
});
|
||||
setComment("");
|
||||
setIsInternal(false);
|
||||
} catch {
|
||||
// Add locally for demo
|
||||
if (ticket) {
|
||||
const localComment = {
|
||||
id: Date.now(),
|
||||
ticketId: parseInt(id),
|
||||
userId: user?.id || 0,
|
||||
user: user || { id: 0, name: "Usuario", email: "", role: "agent" as const },
|
||||
content: comment,
|
||||
isInternal,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setTicket({ ...ticket, comments: [...(ticket.comments || []), localComment] });
|
||||
setComment("");
|
||||
setIsInternal(false);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
try {
|
||||
await updateTicketStatus(id, parseInt(newStatus));
|
||||
if (ticket) setTicket({ ...ticket, status: newStatus });
|
||||
} catch {
|
||||
if (ticket) setTicket({ ...ticket, status: newStatus });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async (agentId: string) => {
|
||||
if (!agentId) return;
|
||||
try {
|
||||
await assignTicket(id, parseInt(agentId));
|
||||
const agent = agents.find((a) => a.id === parseInt(agentId));
|
||||
if (ticket && agent) setTicket({ ...ticket, assigneeId: agent.id, assignee: agent });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
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 || `#${ticket.id}`}
|
||||
</div>
|
||||
<h3 className="ticket-subject">{ticket.subject}</h3>
|
||||
<div className="d-flex gap-2 mt-2">
|
||||
<BadgeStatus status={ticket.status} />
|
||||
<BadgePriority priority={ticket.priority} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
{/* Status Change */}
|
||||
<div className="dropdown">
|
||||
<button className="btn btn-outline-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i className="bi bi-arrow-repeat me-1"></i>Cambiar Estado
|
||||
</button>
|
||||
<ul className="dropdown-menu">
|
||||
{[
|
||||
{ value: "open", label: "Abierto" },
|
||||
{ value: "pending", label: "Pendiente" },
|
||||
{ value: "in_progress", label: "En Progreso" },
|
||||
{ value: "resolved", label: "Resuelto" },
|
||||
{ value: "closed", label: "Cerrado" },
|
||||
].map((s) => (
|
||||
<li key={s.value}>
|
||||
<button className="dropdown-item" onClick={() => handleStatusChange(s.value)}>
|
||||
{s.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Assign */}
|
||||
<div className="dropdown">
|
||||
<button className="btn btn-outline-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i className="bi bi-person-plus me-1"></i>Asignar
|
||||
</button>
|
||||
<ul className="dropdown-menu">
|
||||
{agents.map((a) => (
|
||||
<li key={a.id}>
|
||||
<button className="dropdown-item" onClick={() => handleAssign(String(a.id))}>
|
||||
{a.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</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 ({ticket.comments?.length || 0})
|
||||
</div>
|
||||
<div className="card-body" style={{ maxHeight: "500px", overflowY: "auto" }}>
|
||||
{ticket.comments?.length ? (
|
||||
ticket.comments.map((c) => {
|
||||
const isAgent = c.user?.role === "agent" || c.user?.role === "admin";
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`comment ${isAgent ? "comment-agent" : "comment-customer"} ${
|
||||
c.isInternal ? "comment-internal" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="comment-bubble">
|
||||
<div className="comment-author">
|
||||
{c.user?.name || "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 (visible solo para 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.requester?.name || "N/A"}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Email</span>
|
||||
<span className="value" style={{ fontSize: "0.8rem" }}>{ticket.requester?.email || "N/A"}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Asignado a</span>
|
||||
<span className="value">{ticket.assignee?.name || "Sin asignar"}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Grupo</span>
|
||||
<span className="value">{ticket.group?.name || "N/A"}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Categoría</span>
|
||||
<span className="value">{ticket.category || "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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user