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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user