Initial commit: HelpDesk ICHN - .NET 10 backend + Next.js 16 frontend

This commit is contained in:
Nurfog committed 2026-09-10 10:20:51 -03:00
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>
);
}