feat(respuesta): CC y adjuntos (10MB) con descarga en el hilo
This commit is contained in:
1 parent
dd1e1a231a
commit
f88d79e2e8
3 files changed
+179
-9
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user