feat(respuesta): CC y adjuntos (10MB) con descarga en el hilo

This commit is contained in:
Nurfog committed 2026-10-22 13:16:29 -03:00
1 parent dd1e1a231a
commit f88d79e2e8
3 files changed
+179 -9

No files matched your search

+118 -6
View File
@@ -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<File[]>([]);
// Ids ya subidos pendientes de vincular (si el envío del comentario falló).
const [pendingIds, setPendingIds] = useState<number[]>([]);
const [ticketFiles, setTicketFiles] = useState<TicketAttachment[]>([]);
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() {
)}
</div>
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{m.content}</div>
{ticketFiles.filter((a) => a.commentId === m.id).map((a) => (
<button
key={a.id}
type="button"
className="badge bg-light text-dark border d-flex align-items-center gap-1 mt-1"
style={{ fontSize: "0.72rem", cursor: "pointer" }}
title={`Descargar ${a.filename} (${formatFileSize(a.size)})`}
onClick={() => {
downloadAttachment(a).catch(() => setError("No se pudo descargar el adjunto"));
}}
>
<i className="bi bi-paperclip"></i>
{a.filename} ({formatFileSize(a.size)})
<i className="bi bi-download"></i>
</button>
))}
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
</div>
</div>
@@ -564,6 +622,60 @@ export default function TicketDetailPage() {
</small>
)}
</div>
{!isInternal && (
<div className="mb-3">
<label className="form-label" style={{ fontSize: "0.85rem" }}>
<i className="bi bi-people me-1"></i>
CC (copia por correo, separados por coma)
</label>
<input
type="text"
className="form-control form-control-sm"
placeholder="jefe@norteamericano.cl, otro@norteamericano.cl"
value={cc}
onChange={(e) => setCc(e.target.value)}
/>
</div>
)}
<div className="mb-3">
<label className="btn btn-outline-secondary btn-sm" style={{ cursor: "pointer" }}>
<i className="bi bi-paperclip me-1"></i>
Adjuntar archivos (máx. 10 MB c/u)
<input
type="file"
multiple
hidden
onChange={(e) => {
const picked = Array.from(e.target.files ?? []);
setFiles((prev) => [...prev, ...picked].slice(0, 5));
e.target.value = "";
}}
/>
</label>
{files.length > 0 && (
<div className="d-flex flex-wrap gap-2 mt-2">
{files.map((f, i) => (
<span key={`${f.name}-${i}`} className="badge bg-light text-dark border d-flex align-items-center gap-1" style={{ fontSize: "0.75rem" }}>
<i className="bi bi-file-earmark"></i>
{f.name} ({formatFileSize(f.size)})
<button
type="button"
className="btn-close btn-close-sm"
style={{ fontSize: "0.6rem" }}
aria-label="Quitar archivo"
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
/>
</span>
))}
</div>
)}
{pendingIds.length > 0 && (
<small className="text-warning d-block mt-1">
<i className="bi bi-exclamation-triangle me-1"></i>
{pendingIds.length} archivo(s) ya subido(s): se vincularán al enviar.
</small>
)}
</div>
<div className="d-flex align-items-center justify-content-between">
<div className="form-check">
<input
+60 -2
View File
@@ -136,14 +136,72 @@ export async function getComments(ticketId: number) {
export async function addComment(
ticketId: number,
content: string,
isInternal: boolean = false
isInternal: boolean = false,
cc?: string,
attachmentIds?: number[]
) {
return fetchApi<{ id: number }>(`/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<TicketAttachment[]>(`/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