From f88d79e2e881f5fe8adf2034324e2f0c7ada4a54 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Thu, 22 Oct 2026 13:16:29 -0300 Subject: [PATCH] feat(respuesta): CC y adjuntos (10MB) con descarga en el hilo --- README.md | 2 +- frontend/src/app/(app)/tickets/[id]/page.tsx | 124 ++++++++++++++++++- frontend/src/lib/api.ts | 62 +++++++++- 3 files changed, 179 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ccfcabe..300a48d 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Base: `NEXT_PUBLIC_API_URL/api/helpdesk` - `POST /login {idUsuario, password}` · `GET /me` - `GET /tickets?...` (con `archived=false` solo activos [default UI], `true` solo archivados, ausente todos) · `GET /ticket/{id}` · `POST /tickets` · `PUT /ticket/{id}` (+ `/status`, `/assign`, `/archive`) · `DELETE /ticket/{id}` -- `GET|POST /comments/{ticketId}` +- `GET|POST /comments/{ticketId}` (POST acepta `cc` y `attachmentIds`, solo públicas) · `POST /ticket/{id}/attachments` (multipart, máx 5 × 10 MB) · `GET /ticket/{id}/attachments` · `GET /attachments/{fileId}` (descarga) - `GET /kb/published` · `GET /kb/search?q=` · `GET /kb/categories` - `GET /stats` - `GET|POST /groups` · `PUT|DELETE /groups/{id}` (admin) diff --git a/frontend/src/app/(app)/tickets/[id]/page.tsx b/frontend/src/app/(app)/tickets/[id]/page.tsx index e76248f..22d1419 100644 --- a/frontend/src/app/(app)/tickets/[id]/page.tsx +++ b/frontend/src/app/(app)/tickets/[id]/page.tsx @@ -12,10 +12,15 @@ import { assignTicket, getGroups, getTeam, + uploadTicketAttachments, + getTicketAttachments, + downloadAttachment, + formatFileSize, type Ticket, type Comment, type Group, type TeamMember, + type TicketAttachment, type TicketSentEmail, type TicketReceivedEmail, } from "@/lib/api"; @@ -45,7 +50,7 @@ const STATUS_OPTIONS = [ // Lado del chat: equipo (agentes/admin/supervisor) a la derecha, // solicitante a la izquierda, como en un chat. type ThreadItem = - | { kind: "comment"; key: string; createdAt: string; userId: string; userName?: string; content: string; isInternal: boolean; side: "team" | "customer" } + | { kind: "comment"; key: string; id: number; createdAt: string; userId: string; userName?: string; content: string; isInternal: boolean; side: "team" | "customer" } | { kind: "sent"; key: string; createdAt: string; who: string; subject: string; body?: string; fallback: string; side: "team" } | { kind: "received"; key: string; createdAt: string; who: string; subject: string; body?: string; status: string; error?: string; side: "customer" }; @@ -69,6 +74,7 @@ function buildThread( .map((c): ThreadItem => ({ kind: "comment", key: `comment-${c.id}`, + id: c.id, createdAt: c.createdAt, userId: c.userId, userName: c.userName, @@ -200,6 +206,11 @@ export default function TicketDetailPage() { const [loading, setLoading] = useState(true); const [comment, setComment] = useState(""); const [isInternal, setIsInternal] = useState(false); + const [cc, setCc] = useState(""); + const [files, setFiles] = useState([]); + // Ids ya subidos pendientes de vincular (si el envío del comentario falló). + const [pendingIds, setPendingIds] = useState([]); + const [ticketFiles, setTicketFiles] = useState([]); const [submitting, setSubmitting] = useState(false); const [acting, setActing] = useState(false); const [error, setError] = useState(""); @@ -213,14 +224,16 @@ export default function TicketDetailPage() { getTicketEmails(id).catch(() => ({ sent: [], received: [] })), getGroups().catch(() => []), getTeam().catch(() => []), + getTicketAttachments(id).catch(() => []), ]) - .then(([ticketData, commentsData, emailsData, groupsData, teamData]) => { + .then(([ticketData, commentsData, emailsData, groupsData, teamData, filesData]) => { setTicket(ticketData); setComments(commentsData); setSentEmails(emailsData.sent ?? []); setReceivedEmails(emailsData.received ?? []); setGroups(groupsData); setTeam(teamData); + setTicketFiles(filesData ?? []); }) .finally(() => setLoading(false)); }, [id]); @@ -230,6 +243,8 @@ export default function TicketDetailPage() { if (updated) setTicket(updated); }; + const MAX_FILE_BYTES = 10 * 1024 * 1024; + const handleAddComment = async (e: React.FormEvent) => { e.preventDefault(); if (!comment.trim()) return; @@ -237,21 +252,48 @@ export default function TicketDetailPage() { // citado (se usa para revisar casos en el futuro). const cleaned = cleanReplyForSend(comment); if (!cleaned) return; + if (files.length > 5) { + setError("Máximo 5 archivos por respuesta"); + return; + } + const tooBig = files.find((f) => f.size > MAX_FILE_BYTES); + if (tooBig) { + setError(`${tooBig.name}: supera los 10 MB`); + return; + } setSubmitting(true); setError(""); + let newIds: number[] = []; try { - await addComment(id, cleaned, isInternal); - const [updated, emails] = await Promise.all([ + // 1) Subir adjuntos (si hay) y 2) publicar el comentario con CC e ids. + if (files.length > 0) { + const up = await uploadTicketAttachments(id, files); + newIds = up.files.map((f) => f.id); + } + const ids = [...pendingIds, ...newIds]; + await addComment(id, cleaned, isInternal, isInternal ? undefined : cc.trim() || undefined, ids.length > 0 ? ids : undefined); + const [updated, emails, atts] = await Promise.all([ getComments(id).catch(() => []), getTicketEmails(id).catch(() => ({ sent: [], received: [] })), + getTicketAttachments(id).catch(() => []), ]); setComments(updated); setSentEmails(emails.sent ?? []); setReceivedEmails(emails.received ?? []); + setTicketFiles(atts ?? []); setComment(""); setIsInternal(false); - } catch { - setError("No se pudo enviar el mensaje"); + setCc(""); + setFiles([]); + setPendingIds([]); + } catch (err: unknown) { + // Si la subida salió pero el comentario falló, los ids quedan + // pendientes y se vinculan en el próximo envío. + if (newIds.length > 0) { + setPendingIds((prev) => [...new Set([...prev, ...newIds])]); + setFiles([]); + } + setError(err instanceof Error ? err.message : "No se pudo enviar el mensaje"); } finally { setSubmitting(false); } @@ -495,6 +537,22 @@ export default function TicketDetailPage() { )}
{m.content}
+ {ticketFiles.filter((a) => a.commentId === m.id).map((a) => ( + + ))}
{formatDateTime(m.createdAt)}
@@ -564,6 +622,60 @@ export default function TicketDetailPage() { )} + {!isInternal && ( +
+ + setCc(e.target.value)} + /> +
+ )} +
+ + {files.length > 0 && ( +
+ {files.map((f, i) => ( + + + {f.name} ({formatFileSize(f.size)}) +
+ )} + {pendingIds.length > 0 && ( + + + {pendingIds.length} archivo(s) ya subido(s): se vincularán al enviar. + + )} +
(`/api/helpdesk/comments/${ticketId}`, { method: "POST", - body: JSON.stringify({ content, isInternal }), + body: JSON.stringify({ content, isInternal, cc, attachmentIds }), }); } +// ==================== Adjuntos del ticket ==================== +export interface TicketAttachment { + id: number; + commentId?: number | null; + filename: string; + contentType: string; + size: number; + uploadedBy: string; + createdAt: string; +} + +export async function uploadTicketAttachments(ticketId: number, files: File[]) { + const token = + typeof window !== "undefined" ? localStorage.getItem("helpdesk_token") : null; + const form = new FormData(); + files.forEach((f) => form.append("files", f)); + const res = await fetch(`${API_BASE}/api/helpdesk/ticket/${ticketId}/attachments`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: form, + }); + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.message || `API Error: ${res.status}`); + } + return (await res.json()) as { files: { id: number; filename: string; size: number }[] }; +} + +export async function getTicketAttachments(ticketId: number) { + return fetchApi(`/api/helpdesk/ticket/${ticketId}/attachments`); +} + +export async function downloadAttachment(attachment: TicketAttachment) { + const token = + typeof window !== "undefined" ? localStorage.getItem("helpdesk_token") : null; + const res = await fetch(`${API_BASE}/api/helpdesk/attachments/${attachment.id}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) throw new Error(`No se pudo descargar (${res.status})`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = attachment.filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 5000); +} + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + // ==================== Historial de correos del ticket ==================== // Integración email <-> ticket: enviados desde soporte (mail.log) + // respuestas recibidas (helpdesk.email_inbound). Visible para quien