feat: página de plantillas de correo + prueba de envío + bitácora
This commit is contained in:
1 parent
a68999eca7
commit
ce008105c9
3 files changed
+402
No files matched your search
@@ -0,0 +1,356 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
getEmailTemplates,
|
||||
updateEmailTemplate,
|
||||
getEmailLog,
|
||||
sendTestEmail,
|
||||
type EmailTemplate,
|
||||
type EmailLogEntry,
|
||||
} from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
const PLACEHOLDERS = [
|
||||
"TicketNumber",
|
||||
"Title",
|
||||
"Description",
|
||||
"Status",
|
||||
"Priority",
|
||||
"RequesterName",
|
||||
"AssigneeName",
|
||||
"Department",
|
||||
"Link",
|
||||
"Comment",
|
||||
"CommentAuthor",
|
||||
];
|
||||
|
||||
export default function EmailPage() {
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||
const [log, setLog] = useState<EmailLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<EmailTemplate | null>(null);
|
||||
const [subject, setSubject] = useState("");
|
||||
const [bodyHtml, setBodyHtml] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modalError, setModalError] = useState("");
|
||||
const [testTo, setTestTo] = useState("");
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [t, l] = await Promise.all([getEmailTemplates(), getEmailLog(50)]);
|
||||
setTemplates(t);
|
||||
setLog(l);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Error al cargar");
|
||||
setTemplates([]);
|
||||
setLog([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (user?.role !== "admin") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
fetchData();
|
||||
}, [fetchData, authLoading, user?.role]);
|
||||
|
||||
const openEdit = (t: EmailTemplate) => {
|
||||
setEditing(t);
|
||||
setSubject(t.subject);
|
||||
setBodyHtml(t.bodyHtml);
|
||||
setIsActive(t.isActive);
|
||||
setModalError("");
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!editing) return;
|
||||
if (!subject.trim() || !bodyHtml.trim()) {
|
||||
setModalError("Asunto y cuerpo son requeridos");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setModalError("");
|
||||
try {
|
||||
await updateEmailTemplate(editing.key, {
|
||||
subject: subject.trim(),
|
||||
bodyHtml: bodyHtml.trim(),
|
||||
isActive,
|
||||
});
|
||||
setShowModal(false);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
setModalError(err instanceof Error ? err.message : "Error al guardar");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!testTo.trim() || !testTo.includes("@")) {
|
||||
setTestResult({ ok: false, text: "Destino inválido" });
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
await sendTestEmail(testTo.trim());
|
||||
setTestResult({ ok: true, text: `Correo de prueba enviado a ${testTo.trim()}` });
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, text: err instanceof Error ? err.message : "Error al enviar" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!authLoading && user?.role !== "admin") {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Correo</h1>
|
||||
</div>
|
||||
<div className="alert alert-warning" role="alert">
|
||||
<i className="bi bi-shield-lock me-2"></i>
|
||||
Solo los administradores pueden gestionar las plantillas de correo.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Plantillas de Correo</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
<i className="bi bi-exclamation-circle me-2"></i>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body p-0">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plantilla</th>
|
||||
<th>Asunto</th>
|
||||
<th>Estado</th>
|
||||
<th className="text-end">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center py-4">
|
||||
<div className="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</td>
|
||||
</tr>
|
||||
) : templates.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="empty-state">
|
||||
<i className="bi bi-envelope d-block"></i>
|
||||
<h5>Sin plantillas</h5>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
templates.map((t) => (
|
||||
<tr key={t.key}>
|
||||
<td>
|
||||
<div className="fw-semibold">{t.name}</div>
|
||||
<small className="text-muted">
|
||||
<code>{t.key}</code>
|
||||
</small>
|
||||
</td>
|
||||
<td className="text-muted small">{t.subject}</td>
|
||||
<td>
|
||||
{t.isActive ? (
|
||||
<span className="badge bg-success">Activa</span>
|
||||
) : (
|
||||
<span className="badge bg-secondary">Inactiva</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() => openEdit(t)}
|
||||
title="Editar"
|
||||
>
|
||||
<i className="bi bi-pencil"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h6 className="fw-semibold mb-3">
|
||||
<i className="bi bi-send me-2"></i>
|
||||
Probar envío
|
||||
</h6>
|
||||
{testResult && (
|
||||
<div className={`alert ${testResult.ok ? "alert-success" : "alert-danger"} py-2`} role="alert">
|
||||
{testResult.text}
|
||||
</div>
|
||||
)}
|
||||
<div className="d-flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
placeholder="destino@ejemplo.cl"
|
||||
value={testTo}
|
||||
onChange={(e) => setTestTo(e.target.value)}
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
<button className="btn btn-outline-primary" onClick={handleTest} disabled={testing}>
|
||||
{testing ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status" />
|
||||
Enviando...
|
||||
</>
|
||||
) : (
|
||||
"Enviar prueba"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<small className="text-muted d-block mt-2">
|
||||
Si falla, revisa la bitácora y la configuración SMTP del backend.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 className="fw-semibold mb-2">
|
||||
<i className="bi bi-clock-history me-2"></i>
|
||||
Últimos envíos
|
||||
</h6>
|
||||
<div className="card">
|
||||
<div className="card-body p-0">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Plantilla</th>
|
||||
<th>Ticket</th>
|
||||
<th>Destinatario</th>
|
||||
<th>Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{log.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center text-muted py-3">
|
||||
Sin envíos registrados
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
log.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className="text-muted small">
|
||||
{new Date(e.createdAt).toLocaleString("es-CL")}
|
||||
</td>
|
||||
<td>
|
||||
<code className="small">{e.template}</code>
|
||||
</td>
|
||||
<td>{e.ticketId ? `#${e.ticketId}` : "—"}</td>
|
||||
<td className="small">{e.recipient}</td>
|
||||
<td>
|
||||
{e.status === "sent" ? (
|
||||
<span className="badge bg-success">Enviado</span>
|
||||
) : e.status === "skipped" ? (
|
||||
<span className="badge bg-warning text-dark" title={e.error || ""}>
|
||||
Omitido
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge bg-danger" title={e.error || ""}>
|
||||
Error
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
show={showModal}
|
||||
title={editing ? `Editar: ${editing.name}` : "Editar plantilla"}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={saving}
|
||||
>
|
||||
{modalError && (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
{modalError}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Asunto *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Cuerpo HTML *</label>
|
||||
<textarea
|
||||
className="form-control font-monospace"
|
||||
rows={10}
|
||||
value={bodyHtml}
|
||||
onChange={(e) => setBodyHtml(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-check form-switch mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
id="tplActive"
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="tplActive">
|
||||
Activa (si no, no se envía)
|
||||
</label>
|
||||
</div>
|
||||
<div className="alert alert-light border small mb-0">
|
||||
<strong>Variables:</strong>{" "}
|
||||
{PLACEHOLDERS.map((p) => (
|
||||
<code key={p} className="me-1">{`{{${p}}}`}</code>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export default function Sidebar() {
|
||||
{ href: "/settings/departments", icon: "bi-diagram-3", label: "Departamentos" },
|
||||
{ href: "/settings/team", icon: "bi-people", label: "Equipo" },
|
||||
{ href: "/settings/sla", icon: "bi-stopwatch", label: "SLA" },
|
||||
{ href: "/settings/email", icon: "bi-envelope", label: "Correo" },
|
||||
];
|
||||
|
||||
const isActive = (href: string) =>
|
||||
|
||||
@@ -338,3 +338,48 @@ export async function updateSla(
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Correo / plantillas ====================
|
||||
export interface EmailTemplate {
|
||||
key: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
bodyHtml: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface EmailLogEntry {
|
||||
id: number;
|
||||
template: string;
|
||||
ticketId?: number;
|
||||
recipient: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function getEmailTemplates() {
|
||||
return fetchApi<EmailTemplate[]>("/api/helpdesk/email/templates");
|
||||
}
|
||||
|
||||
export async function updateEmailTemplate(
|
||||
key: string,
|
||||
data: { subject?: string; bodyHtml?: string; isActive?: boolean }
|
||||
) {
|
||||
return fetchApi<void>(`/api/helpdesk/email/templates/${encodeURIComponent(key)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEmailLog(limit = 50) {
|
||||
return fetchApi<EmailLogEntry[]>(`/api/helpdesk/email/log?limit=${limit}`);
|
||||
}
|
||||
|
||||
export async function sendTestEmail(to: string) {
|
||||
return fetchApi<{ success: boolean; message?: string }>("/api/helpdesk/email/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ to }),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user