572 lines
18 KiB
TypeScript
572 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import {
|
|
getEmailTemplates,
|
|
createEmailTemplate,
|
|
updateEmailTemplate,
|
|
getEmailLog,
|
|
sendTestEmail,
|
|
syncInboundEmail,
|
|
type EmailTemplate,
|
|
type EmailLogEntry,
|
|
} from "@/lib/api";
|
|
import { useAuth } from "@/lib/auth";
|
|
import { formatDateTime } from "@/lib/format";
|
|
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 [logModule, setLogModule] = useState("helpdesk");
|
|
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("");
|
|
// Crear plantilla (POST /api/email/templates)
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [newKey, setNewKey] = useState("");
|
|
const [newName, setNewName] = useState("");
|
|
const [newSubject, setNewSubject] = useState("");
|
|
const [newBody, setNewBody] = useState("");
|
|
const [newModule, setNewModule] = useState("helpdesk");
|
|
const [creating, setCreating] = useState(false);
|
|
const [createError, setCreateError] = useState("");
|
|
const [testTo, setTestTo] = useState("");
|
|
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
|
|
const [testing, setTesting] = useState(false);
|
|
const [syncing, setSyncing] = useState(false);
|
|
const [syncResult, setSyncResult] = useState<{ ok: boolean; text: string } | null>(null);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const [t, l] = await Promise.all([
|
|
getEmailTemplates(),
|
|
getEmailLog(50, logModule),
|
|
]);
|
|
setTemplates(t);
|
|
setLog(l);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Error al cargar");
|
|
setTemplates([]);
|
|
setLog([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [logModule]);
|
|
|
|
useEffect(() => {
|
|
if (authLoading || user?.role !== "admin") return;
|
|
Promise.all([getEmailTemplates(), getEmailLog(50, logModule)])
|
|
.then(([t, l]) => {
|
|
setTemplates(t);
|
|
setLog(l);
|
|
setError("");
|
|
})
|
|
.catch((err) => {
|
|
setError(err instanceof Error ? err.message : "Error al cargar");
|
|
setTemplates([]);
|
|
setLog([]);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, [authLoading, user?.role, logModule]);
|
|
|
|
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 openCreate = () => {
|
|
setNewKey("");
|
|
setNewName("");
|
|
setNewSubject("");
|
|
setNewBody("");
|
|
setNewModule("helpdesk");
|
|
setCreateError("");
|
|
setShowCreate(true);
|
|
};
|
|
|
|
const handleCreate = async () => {
|
|
const key = newKey.trim().toLowerCase();
|
|
const modName = (newModule.trim().toLowerCase() || "helpdesk");
|
|
if (!/^[a-z0-9_]{2,64}$/.test(key)) {
|
|
setCreateError("Key inválida ([a-z0-9_], 2-64)");
|
|
return;
|
|
}
|
|
if (!newName.trim() || !newSubject.trim() || !newBody.trim()) {
|
|
setCreateError("Nombre, asunto y cuerpo son requeridos");
|
|
return;
|
|
}
|
|
if (!/^[a-z0-9-]{2,32}$/.test(modName)) {
|
|
setCreateError("Módulo inválido ([a-z0-9-], 2-32)");
|
|
return;
|
|
}
|
|
setCreating(true);
|
|
setCreateError("");
|
|
try {
|
|
await createEmailTemplate({
|
|
key,
|
|
name: newName.trim(),
|
|
subject: newSubject.trim(),
|
|
bodyHtml: newBody.trim(),
|
|
module: modName,
|
|
});
|
|
setShowCreate(false);
|
|
fetchData();
|
|
} catch (err) {
|
|
setCreateError(err instanceof Error ? err.message : "Error al crear");
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const handleTest = async () => {
|
|
if (!testTo.trim() || !testTo.includes("@")) {
|
|
setTestResult({ ok: false, text: "Destino inválido" });
|
|
return;
|
|
}
|
|
setTesting(true);
|
|
setTestResult(null);
|
|
try {
|
|
const r = await sendTestEmail(testTo.trim());
|
|
const sent = typeof r.sent === "number" ? ` (${r.sent} enviado(s))` : "";
|
|
setTestResult({ ok: true, text: `Correo de prueba enviado a ${testTo.trim()}${sent} vía módulo helpdesk.` });
|
|
fetchData();
|
|
} catch (err) {
|
|
setTestResult({ ok: false, text: err instanceof Error ? err.message : "Error al enviar" });
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
};
|
|
|
|
const handleSync = async () => {
|
|
setSyncing(true);
|
|
setSyncResult(null);
|
|
try {
|
|
const r = await syncInboundEmail();
|
|
setSyncResult({
|
|
ok: r.success,
|
|
text: r.success
|
|
? `Revisados: ${r.checkedCount}, agregados al ticket: ${r.inserted}, omitidos: ${r.skipped}${r.error ? ` (${r.error})` : ""}`
|
|
: r.error || "Error en la sincronización",
|
|
});
|
|
} catch (err) {
|
|
setSyncResult({ ok: false, text: err instanceof Error ? err.message : "Error en la sincronización" });
|
|
} finally {
|
|
setSyncing(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 d-flex justify-content-between align-items-center">
|
|
<h1>Plantillas de Correo</h1>
|
|
<button className="btn btn-primary btn-sm" onClick={openCreate}>
|
|
<i className="bi bi-plus-lg me-1"></i>
|
|
Nueva plantilla
|
|
</button>
|
|
</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>Módulo</th>
|
|
<th>Asunto</th>
|
|
<th>Estado</th>
|
|
<th className="text-end">Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={5} 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={5} 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>
|
|
<code className="small">{t.module ?? "helpdesk"}</code>
|
|
</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">
|
|
Va por el route propio <code>/api/correos</code> → <code>POST /api/email/send</code> con
|
|
módulo <code>helpdesk</code>. Requiere <code>MAIL_API_KEY</code> en el servidor.
|
|
Si falla, revisa la bitácora y la configuración SMTP del backend.
|
|
</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card mb-4">
|
|
<div className="card-body">
|
|
<h6 className="fw-semibold mb-3">
|
|
<i className="bi bi-inbox me-2"></i>
|
|
Respuestas por correo
|
|
</h6>
|
|
{syncResult && (
|
|
<div className={`alert ${syncResult.ok ? "alert-success" : "alert-danger"} py-2`} role="alert">
|
|
{syncResult.text}
|
|
</div>
|
|
)}
|
|
<div className="d-flex gap-2">
|
|
<button className="btn btn-outline-primary" onClick={handleSync} disabled={syncing}>
|
|
{syncing ? (
|
|
<>
|
|
<span className="spinner-border spinner-border-sm me-2" role="status" />
|
|
Sincronizando...
|
|
</>
|
|
) : (
|
|
"Sincronizar ahora"
|
|
)}
|
|
</button>
|
|
</div>
|
|
<small className="text-muted d-block mt-2">
|
|
Lee la casilla Gmail y agrega como comentarios las respuestas con
|
|
etiqueta [HelpDesk HD-000123]. El worker lo hace solo cada 5 minutos.
|
|
Requiere scope gmail.readonly autorizado en admin.google.com.
|
|
</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="d-flex justify-content-between align-items-center mb-2">
|
|
<h6 className="fw-semibold mb-0">
|
|
<i className="bi bi-clock-history me-2"></i>
|
|
Últimos envíos
|
|
</h6>
|
|
<select
|
|
className="form-select form-select-sm"
|
|
style={{ maxWidth: 220 }}
|
|
value={logModule}
|
|
onChange={(e) => {
|
|
setLogModule(e.target.value);
|
|
setLoading(true);
|
|
}}
|
|
title="Filtrar bitácora por módulo"
|
|
>
|
|
<option value="helpdesk">Solo helpdesk</option>
|
|
<option value="">Todos los módulos</option>
|
|
</select>
|
|
</div>
|
|
<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>Módulo</th>
|
|
<th>Ticket</th>
|
|
<th>Destinatario</th>
|
|
<th>Estado</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{log.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} 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">
|
|
{formatDateTime(e.createdAt)}
|
|
</td>
|
|
<td>
|
|
<code className="small">{e.template}</code>
|
|
</td>
|
|
<td>
|
|
<code className="small">{e.module ?? "helpdesk"}</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>
|
|
|
|
<Modal
|
|
show={showCreate}
|
|
title="Nueva plantilla"
|
|
onClose={() => setShowCreate(false)}
|
|
onSubmit={handleCreate}
|
|
submitLabel="Crear"
|
|
submitting={creating}
|
|
>
|
|
{createError && (
|
|
<div className="alert alert-danger py-2" role="alert">
|
|
{createError}
|
|
</div>
|
|
)}
|
|
<div className="mb-3">
|
|
<label className="form-label">Key * <small className="text-muted">([a-z0-9_])</small></label>
|
|
<input
|
|
type="text"
|
|
className="form-control font-monospace"
|
|
value={newKey}
|
|
onChange={(e) => setNewKey(e.target.value)}
|
|
placeholder="ticket_created"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">Nombre *</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
placeholder="Ticket creado"
|
|
/>
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">Módulo *</label>
|
|
<input
|
|
type="text"
|
|
className="form-control font-monospace"
|
|
value={newModule}
|
|
onChange={(e) => setNewModule(e.target.value)}
|
|
placeholder="helpdesk"
|
|
/>
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">Asunto *</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={newSubject}
|
|
onChange={(e) => setNewSubject(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">Cuerpo HTML *</label>
|
|
<textarea
|
|
className="form-control font-monospace"
|
|
rows={8}
|
|
value={newBody}
|
|
onChange={(e) => setNewBody(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="alert alert-light border small mb-0">
|
|
<strong>Variables:</strong> se renderizan como <code>{"{{Clave}}"}</code> (case-sensitive).
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|