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

+1 -1
View File
@@ -76,7 +76,7 @@ Base: `NEXT_PUBLIC_API_URL/api/helpdesk`
- `POST /login {idUsuario, password}` · `GET /me` - `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 /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 /kb/published` · `GET /kb/search?q=` · `GET /kb/categories`
- `GET /stats` - `GET /stats`
- `GET|POST /groups` · `PUT|DELETE /groups/{id}` (admin) - `GET|POST /groups` · `PUT|DELETE /groups/{id}` (admin)
+118 -6
View File
@@ -12,10 +12,15 @@ import {
assignTicket, assignTicket,
getGroups, getGroups,
getTeam, getTeam,
uploadTicketAttachments,
getTicketAttachments,
downloadAttachment,
formatFileSize,
type Ticket, type Ticket,
type Comment, type Comment,
type Group, type Group,
type TeamMember, type TeamMember,
type TicketAttachment,
type TicketSentEmail, type TicketSentEmail,
type TicketReceivedEmail, type TicketReceivedEmail,
} from "@/lib/api"; } from "@/lib/api";
@@ -45,7 +50,7 @@ const STATUS_OPTIONS = [
// Lado del chat: equipo (agentes/admin/supervisor) a la derecha, // Lado del chat: equipo (agentes/admin/supervisor) a la derecha,
// solicitante a la izquierda, como en un chat. // solicitante a la izquierda, como en un chat.
type ThreadItem = 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: "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" }; | { 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 => ({ .map((c): ThreadItem => ({
kind: "comment", kind: "comment",
key: `comment-${c.id}`, key: `comment-${c.id}`,
id: c.id,
createdAt: c.createdAt, createdAt: c.createdAt,
userId: c.userId, userId: c.userId,
userName: c.userName, userName: c.userName,
@@ -200,6 +206,11 @@ export default function TicketDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const [isInternal, setIsInternal] = useState(false); 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 [submitting, setSubmitting] = useState(false);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -213,14 +224,16 @@ export default function TicketDetailPage() {
getTicketEmails(id).catch(() => ({ sent: [], received: [] })), getTicketEmails(id).catch(() => ({ sent: [], received: [] })),
getGroups().catch(() => []), getGroups().catch(() => []),
getTeam().catch(() => []), getTeam().catch(() => []),
getTicketAttachments(id).catch(() => []),
]) ])
.then(([ticketData, commentsData, emailsData, groupsData, teamData]) => { .then(([ticketData, commentsData, emailsData, groupsData, teamData, filesData]) => {
setTicket(ticketData); setTicket(ticketData);
setComments(commentsData); setComments(commentsData);
setSentEmails(emailsData.sent ?? []); setSentEmails(emailsData.sent ?? []);
setReceivedEmails(emailsData.received ?? []); setReceivedEmails(emailsData.received ?? []);
setGroups(groupsData); setGroups(groupsData);
setTeam(teamData); setTeam(teamData);
setTicketFiles(filesData ?? []);
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
@@ -230,6 +243,8 @@ export default function TicketDetailPage() {
if (updated) setTicket(updated); if (updated) setTicket(updated);
}; };
const MAX_FILE_BYTES = 10 * 1024 * 1024;
const handleAddComment = async (e: React.FormEvent) => { const handleAddComment = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!comment.trim()) return; if (!comment.trim()) return;
@@ -237,21 +252,48 @@ export default function TicketDetailPage() {
// citado (se usa para revisar casos en el futuro). // citado (se usa para revisar casos en el futuro).
const cleaned = cleanReplyForSend(comment); const cleaned = cleanReplyForSend(comment);
if (!cleaned) return; 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); setSubmitting(true);
setError(""); setError("");
let newIds: number[] = [];
try { try {
await addComment(id, cleaned, isInternal); // 1) Subir adjuntos (si hay) y 2) publicar el comentario con CC e ids.
const [updated, emails] = await Promise.all([ 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(() => []), getComments(id).catch(() => []),
getTicketEmails(id).catch(() => ({ sent: [], received: [] })), getTicketEmails(id).catch(() => ({ sent: [], received: [] })),
getTicketAttachments(id).catch(() => []),
]); ]);
setComments(updated); setComments(updated);
setSentEmails(emails.sent ?? []); setSentEmails(emails.sent ?? []);
setReceivedEmails(emails.received ?? []); setReceivedEmails(emails.received ?? []);
setTicketFiles(atts ?? []);
setComment(""); setComment("");
setIsInternal(false); setIsInternal(false);
} catch { setCc("");
setError("No se pudo enviar el mensaje"); 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 { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -495,6 +537,22 @@ export default function TicketDetailPage() {
)} )}
</div> </div>
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{m.content}</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 className="comment-time">{formatDateTime(m.createdAt)}</div>
</div> </div>
</div> </div>
@@ -564,6 +622,60 @@ export default function TicketDetailPage() {
</small> </small>
)} )}
</div> </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="d-flex align-items-center justify-content-between">
<div className="form-check"> <div className="form-check">
<input <input
+60 -2
View File
@@ -136,14 +136,72 @@ export async function getComments(ticketId: number) {
export async function addComment( export async function addComment(
ticketId: number, ticketId: number,
content: string, content: string,
isInternal: boolean = false isInternal: boolean = false,
cc?: string,
attachmentIds?: number[]
) { ) {
return fetchApi<{ id: number }>(`/api/helpdesk/comments/${ticketId}`, { return fetchApi<{ id: number }>(`/api/helpdesk/comments/${ticketId}`, {
method: "POST", 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 ==================== // ==================== Historial de correos del ticket ====================
// Integración email <-> ticket: enviados desde soporte (mail.log) + // Integración email <-> ticket: enviados desde soporte (mail.log) +
// respuestas recibidas (helpdesk.email_inbound). Visible para quien // respuestas recibidas (helpdesk.email_inbound). Visible para quien