feat: historial de correos en ticket + fechas fijas a America/Santiago

This commit is contained in:
Nurfog committed 2026-09-25 10:11:49 -03:00
1 parent eaa96fbc24
commit 38e21d20dd
6 files changed
+132 -7

No files matched your search

@@ -12,6 +12,7 @@ import {
type EmailLogEntry, type EmailLogEntry,
} from "@/lib/api"; } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { formatDateTime } from "@/lib/format";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
const PLACEHOLDERS = [ const PLACEHOLDERS = [
@@ -415,7 +416,7 @@ export default function EmailPage() {
log.map((e) => ( log.map((e) => (
<tr key={e.id}> <tr key={e.id}>
<td className="text-muted small"> <td className="text-muted small">
{new Date(e.createdAt).toLocaleString("es-CL")} {formatDateTime(e.createdAt)}
</td> </td>
<td> <td>
<code className="small">{e.template}</code> <code className="small">{e.template}</code>
+81 -4
View File
@@ -6,6 +6,7 @@ import { useAuth } from "@/lib/auth";
import { import {
getTicket, getTicket,
getComments, getComments,
getTicketEmails,
addComment, addComment,
updateTicketStatus, updateTicketStatus,
assignTicket, assignTicket,
@@ -15,9 +16,12 @@ import {
type Comment, type Comment,
type Group, type Group,
type TeamMember, type TeamMember,
type TicketSentEmail,
type TicketReceivedEmail,
} from "@/lib/api"; } from "@/lib/api";
import BadgeStatus from "@/components/BadgeStatus"; import BadgeStatus from "@/components/BadgeStatus";
import BadgePriority from "@/components/BadgePriority"; import BadgePriority from "@/components/BadgePriority";
import { formatDateTime } from "@/lib/format";
import Link from "next/link"; import Link from "next/link";
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
@@ -37,6 +41,8 @@ export default function TicketDetailPage() {
const [ticket, setTicket] = useState<Ticket | null>(null); const [ticket, setTicket] = useState<Ticket | null>(null);
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [sentEmails, setSentEmails] = useState<TicketSentEmail[]>([]);
const [receivedEmails, setReceivedEmails] = useState<TicketReceivedEmail[]>([]);
const [groups, setGroups] = useState<Group[]>([]); const [groups, setGroups] = useState<Group[]>([]);
const [team, setTeam] = useState<TeamMember[]>([]); const [team, setTeam] = useState<TeamMember[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -52,12 +58,15 @@ export default function TicketDetailPage() {
Promise.all([ Promise.all([
getTicket(id).catch(() => null), getTicket(id).catch(() => null),
getComments(id).catch(() => []), getComments(id).catch(() => []),
getTicketEmails(id).catch(() => ({ sent: [], received: [] })),
getGroups().catch(() => []), getGroups().catch(() => []),
getTeam().catch(() => []), getTeam().catch(() => []),
]) ])
.then(([ticketData, commentsData, groupsData, teamData]) => { .then(([ticketData, commentsData, emailsData, groupsData, teamData]) => {
setTicket(ticketData); setTicket(ticketData);
setComments(commentsData); setComments(commentsData);
setSentEmails(emailsData.sent ?? []);
setReceivedEmails(emailsData.received ?? []);
setGroups(groupsData); setGroups(groupsData);
setTeam(teamData); setTeam(teamData);
}) })
@@ -303,7 +312,7 @@ export default function TicketDetailPage() {
</div> </div>
<div className="comment-text">{c.content}</div> <div className="comment-text">{c.content}</div>
<div className="comment-time"> <div className="comment-time">
{new Date(c.createdAt).toLocaleString("es-CL")} {formatDateTime(c.createdAt)}
</div> </div>
</div> </div>
</div> </div>
@@ -317,6 +326,74 @@ export default function TicketDetailPage() {
</div> </div>
</div> </div>
{/* Historial de correos: enviados desde soporte + respuestas
recibidas, integrados al ticket (hilo email <-> ticket) */}
<div className="card mb-4">
<div className="card-header d-flex align-items-center gap-2">
<i className="bi bi-envelope-at" style={{ color: "var(--primary)" }}></i>
Historial de correos ({sentEmails.length + receivedEmails.length})
</div>
<div className="card-body" style={{ maxHeight: "400px", overflowY: "auto" }}>
{sentEmails.length === 0 && receivedEmails.length === 0 ? (
<div className="text-center text-muted py-4">
<i className="bi bi-envelope fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
Sin correos asociados a este ticket
</div>
) : (
[...sentEmails.map((e) => ({
key: `sent-${e.id}`,
dir: "sent" as const,
who: e.recipient,
subject: e.subject,
extra: e.template,
status: e.status,
error: e.error,
createdAt: e.createdAt,
})), ...receivedEmails.map((e, i) => ({
key: `recv-${i}-${e.createdAt}`,
dir: "received" as const,
who: e.authorEmail,
subject: e.subject,
extra: e.status === "inserted" ? "Agregado como comentario" : (e.error || e.status),
status: e.status,
error: e.error,
createdAt: e.createdAt,
}))]
.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt))
.map((m) => (
<div key={m.key} className="d-flex gap-2 mb-3">
<span
className={`badge ${m.dir === "sent" ? "bg-primary" : "bg-success"} align-self-start mt-1`}
title={m.dir === "sent" ? "Enviado desde soporte" : "Respuesta recibida"}
>
<i className={`bi ${m.dir === "sent" ? "bi-send" : "bi-inbox"}`}></i>
</span>
<div className="flex-grow-1">
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>
{m.dir === "sent" ? "Para: " : "De: "}{m.who}
</div>
<div className="text-muted" style={{ fontSize: "0.85rem" }}>{m.subject}</div>
<div className="d-flex gap-2 align-items-center flex-wrap">
<small className="text-muted">{formatDateTime(m.createdAt)}</small>
<small className="text-muted">·</small>
<small className="text-muted">{m.extra}</small>
{m.status !== "sent" && m.status !== "inserted" && (
<span className="badge bg-warning text-dark" title={m.error || ""}>
{m.status}
</span>
)}
</div>
</div>
</div>
))
)}
<small className="text-muted d-block mt-2">
Las respuestas con etiqueta HD-{String(id).padStart(6, "0")} en el asunto
se agregan solas como comentarios (ingesta cada 5 min).
</small>
</div>
</div>
{/* Comment Form */} {/* Comment Form */}
<div className="comment-form"> <div className="comment-form">
<form onSubmit={handleAddComment}> <form onSubmit={handleAddComment}>
@@ -384,11 +461,11 @@ export default function TicketDetailPage() {
</div> </div>
<div className="ticket-info-row"> <div className="ticket-info-row">
<span className="label">Creado</span> <span className="label">Creado</span>
<span className="value">{new Date(ticket.createdAt).toLocaleString("es-CL")}</span> <span className="value">{formatDateTime(ticket.createdAt)}</span>
</div> </div>
<div className="ticket-info-row"> <div className="ticket-info-row">
<span className="label">Actualizado</span> <span className="label">Actualizado</span>
<span className="value">{new Date(ticket.updatedAt).toLocaleString("es-CL")}</span> <span className="value">{formatDateTime(ticket.updatedAt)}</span>
</div> </div>
</div> </div>
+2 -1
View File
@@ -7,6 +7,7 @@ import { useAuth } from "@/lib/auth";
import BadgeStatus from "@/components/BadgeStatus"; import BadgeStatus from "@/components/BadgeStatus";
import BadgePriority from "@/components/BadgePriority"; import BadgePriority from "@/components/BadgePriority";
import PaginationComponent from "@/components/Pagination"; import PaginationComponent from "@/components/Pagination";
import { formatDate } from "@/lib/format";
import Link from "next/link"; import Link from "next/link";
export default function TicketsPage() { export default function TicketsPage() {
@@ -204,7 +205,7 @@ export default function TicketsPage() {
<td><BadgePriority priority={ticket.priority} /></td> <td><BadgePriority priority={ticket.priority} /></td>
<td>{ticket.assigneeName || <span className="text-muted">Sin asignar</span>}</td> <td>{ticket.assigneeName || <span className="text-muted">Sin asignar</span>}</td>
<td className="text-muted" style={{ fontSize: "0.85rem" }}> <td className="text-muted" style={{ fontSize: "0.85rem" }}>
{new Date(ticket.updatedAt).toLocaleDateString("es-CL")} {formatDate(ticket.updatedAt)}
</td> </td>
</tr> </tr>
)) ))
+2 -1
View File
@@ -9,6 +9,7 @@ import {
markAllNotificationsRead, markAllNotificationsRead,
type AppNotification, type AppNotification,
} from "@/lib/api"; } from "@/lib/api";
import { formatDate } from "@/lib/format";
const POLL_MS = 60000; const POLL_MS = 60000;
@@ -38,7 +39,7 @@ function timeAgo(iso: string): string {
if (hours < 24) return `hace ${hours} h`; if (hours < 24) return `hace ${hours} h`;
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
if (days < 7) return `hace ${days} d`; if (days < 7) return `hace ${days} d`;
return new Date(iso).toLocaleDateString("es-CL"); return formatDate(iso);
} }
export default function NotificationBell() { export default function NotificationBell() {
+29
View File
@@ -129,6 +129,35 @@ export async function addComment(
}); });
} }
// ==================== Historial de correos del ticket ====================
// Integración email <-> ticket: enviados desde soporte (mail.log) +
// respuestas recibidas (helpdesk.email_inbound). Visible para quien
// puede ver el ticket (equipo + solicitante).
export interface TicketSentEmail {
id: number;
template: string;
recipient: string;
subject: string;
status: string;
error?: string;
createdAt: string;
}
export interface TicketReceivedEmail {
authorId?: string;
authorEmail: string;
subject: string;
status: string;
error?: string;
createdAt: string;
}
export async function getTicketEmails(ticketId: number) {
return fetchApi<{ sent: TicketSentEmail[]; received: TicketReceivedEmail[] }>(
`/api/helpdesk/ticket/${ticketId}/emails`
);
}
// ==================== Knowledge Base ==================== // ==================== Knowledge Base ====================
export async function getKBArticles() { export async function getKBArticles() {
return fetchApi<KBArticle[]>("/api/helpdesk/kb/published"); return fetchApi<KBArticle[]>("/api/helpdesk/kb/published");
+16
View File
@@ -0,0 +1,16 @@
// Formato de fechas siempre en America/Santiago, independiente de la
// zona horaria del equipo que abre el portal. La API devuelve UTC con
// sufijo Z (timestamptz) y el navegador convierte a esta zona explícita.
export const DISPLAY_TZ = "America/Santiago";
export function formatDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("es-CL", { timeZone: DISPLAY_TZ });
}
export function formatDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("es-CL", { timeZone: DISPLAY_TZ });
}