feat: correo via banco mail, rol supervisor y lint en cero

This commit is contained in:
Nurfog committed 2026-09-15 17:28:52 -03:00
1 parent 2502de4e06
commit b1cddbac6c
15 files changed
+677 -107

No files matched your search

+330 -19
View File
@@ -3,12 +3,17 @@
import { useEffect, useState, useCallback } from "react";
import {
getEmailTemplates,
createEmailTemplate,
updateEmailTemplate,
getEmailLog,
sendTestEmail,
syncInboundEmail,
getEmailModules,
registerEmailModule,
revokeEmailModule,
type EmailTemplate,
type EmailLogEntry,
type EmailModule,
} from "@/lib/api";
import { useAuth } from "@/lib/auth";
import Modal from "@/components/Modal";
@@ -31,6 +36,8 @@ export default function EmailPage() {
const { user, isLoading: authLoading } = useAuth();
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [log, setLog] = useState<EmailLogEntry[]>([]);
const [modules, setModules] = useState<EmailModule[]>([]);
const [logModule, setLogModule] = useState("helpdesk");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showModal, setShowModal] = useState(false);
@@ -40,6 +47,24 @@ export default function EmailPage() {
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("");
// Módulos (GET/POST/DELETE /api/email/modules)
const [newModuleName, setNewModuleName] = useState("");
const [registering, setRegistering] = useState(false);
const [registerResult, setRegisterResult] = useState<{
ok: boolean;
text: string;
apiKey?: string;
} | null>(null);
const [revoking, setRevoking] = useState<string | null>(null);
const [testTo, setTestTo] = useState("");
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
const [testing, setTesting] = useState(false);
@@ -50,26 +75,45 @@ export default function EmailPage() {
setLoading(true);
setError("");
try {
const [t, l] = await Promise.all([getEmailTemplates(), getEmailLog(50)]);
const [t, l, m] = await Promise.all([
getEmailTemplates(),
getEmailLog(50, logModule),
getEmailModules(),
]);
setTemplates(t);
setLog(l);
setModules(m);
} catch (err) {
setError(err instanceof Error ? err.message : "Error al cargar");
setTemplates([]);
setLog([]);
setModules([]);
} finally {
setLoading(false);
}
}, []);
}, [logModule]);
useEffect(() => {
if (authLoading) return;
if (user?.role !== "admin") {
setLoading(false);
return;
}
fetchData();
}, [fetchData, authLoading, user?.role]);
if (authLoading || user?.role !== "admin") return;
Promise.all([
getEmailTemplates(),
getEmailLog(50, logModule),
getEmailModules(),
])
.then(([t, l, m]) => {
setTemplates(t);
setLog(l);
setModules(m);
setError("");
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Error al cargar");
setTemplates([]);
setLog([]);
setModules([]);
})
.finally(() => setLoading(false));
}, [authLoading, user?.role, logModule]);
const openEdit = (t: EmailTemplate) => {
setEditing(t);
@@ -103,6 +147,87 @@ export default function EmailPage() {
}
};
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 handleRegister = async () => {
const modName = newModuleName.trim().toLowerCase();
if (!/^[a-z0-9-]{2,32}$/.test(modName)) {
setRegisterResult({ ok: false, text: "Módulo inválido ([a-z0-9-], 2-32)" });
return;
}
setRegistering(true);
setRegisterResult(null);
try {
const r = await registerEmailModule(modName);
setRegisterResult({
ok: true,
text: `Clave de "${r.module}" generada. Se muestra una sola vez.`,
apiKey: r.apiKey,
});
setNewModuleName("");
fetchData();
} catch (err) {
setRegisterResult({ ok: false, text: err instanceof Error ? err.message : "Error al registrar" });
} finally {
setRegistering(false);
}
};
const handleRevoke = async (modName: string) => {
if (!window.confirm(`¿Revocar la clave del módulo "${modName}"? Dejará de poder usar POST /api/email/send.`)) return;
setRevoking(modName);
try {
await revokeEmailModule(modName);
fetchData();
} catch (err) {
setRegisterResult({ ok: false, text: err instanceof Error ? err.message : "Error al revocar" });
} finally {
setRevoking(null);
}
};
const handleTest = async () => {
if (!testTo.trim() || !testTo.includes("@")) {
setTestResult({ ok: false, text: "Destino inválido" });
@@ -111,8 +236,9 @@ export default function EmailPage() {
setTesting(true);
setTestResult(null);
try {
await sendTestEmail(testTo.trim());
setTestResult({ ok: true, text: `Correo de prueba enviado a ${testTo.trim()}` });
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" });
@@ -155,8 +281,12 @@ export default function EmailPage() {
return (
<div>
<div className="page-header">
<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 && (
@@ -173,6 +303,7 @@ export default function EmailPage() {
<thead>
<tr>
<th>Plantilla</th>
<th>Módulo</th>
<th>Asunto</th>
<th>Estado</th>
<th className="text-end">Acciones</th>
@@ -181,13 +312,13 @@ export default function EmailPage() {
<tbody>
{loading ? (
<tr>
<td colSpan={4} className="text-center py-4">
<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={4} className="empty-state">
<td colSpan={5} className="empty-state">
<i className="bi bi-envelope d-block"></i>
<h5>Sin plantillas</h5>
</td>
@@ -201,6 +332,9 @@ export default function EmailPage() {
<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 ? (
@@ -227,6 +361,95 @@ export default function EmailPage() {
</div>
</div>
<div className="card mb-4">
<div className="card-body">
<h6 className="fw-semibold mb-3">
<i className="bi bi-key me-2"></i>
Módulos autorizados
</h6>
{registerResult && (
<div className={`alert ${registerResult.ok ? "alert-success" : "alert-danger"} py-2`} role="alert">
{registerResult.text}
{registerResult.apiKey && (
<div className="mt-2">
<code className="user-select-all">{registerResult.apiKey}</code>
<div className="small mt-1">
Guárdala en el <code>.env</code> del módulo como <code>MAIL_API_KEY</code>. No se vuelve a mostrar.
Registrar de nuevo rota la clave anterior.
</div>
</div>
)}
</div>
)}
{modules.length > 0 && (
<div className="table-responsive mb-3">
<table className="table table-sm table-hover align-middle mb-0">
<thead>
<tr>
<th>Módulo</th>
<th>Estado</th>
<th className="text-end">Acciones</th>
</tr>
</thead>
<tbody>
{modules.map((m) => (
<tr key={m.module}>
<td>
<code className="small">{m.module}</code>
</td>
<td>
{m.isActive ? (
<span className="badge bg-success">Activo</span>
) : (
<span className="badge bg-secondary">Revocado</span>
)}
</td>
<td className="text-end">
<button
className="btn btn-sm btn-outline-danger"
onClick={() => handleRevoke(m.module)}
disabled={revoking === m.module}
title="Revocar clave"
>
{revoking === m.module ? (
<span className="spinner-border spinner-border-sm" role="status" />
) : (
<i className="bi bi-trash"></i>
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="d-flex gap-2">
<input
type="text"
className="form-control"
placeholder="nombre-modulo (ej: webpay)"
value={newModuleName}
onChange={(e) => setNewModuleName(e.target.value)}
style={{ maxWidth: 320 }}
/>
<button className="btn btn-outline-primary" onClick={handleRegister} disabled={registering}>
{registering ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" />
Generando...
</>
) : (
"Registrar / rotar clave"
)}
</button>
</div>
<small className="text-muted d-block mt-2">
La clave plana se muestra una sola vez (<code>POST /api/email/modules</code>).
</small>
</div>
</div>
<div className="card mb-4">
<div className="card-body">
<h6 className="fw-semibold mb-3">
@@ -259,6 +482,8 @@ export default function EmailPage() {
</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>
@@ -295,10 +520,25 @@ export default function EmailPage() {
</div>
</div>
<h6 className="fw-semibold mb-2">
<i className="bi bi-clock-history me-2"></i>
Últimos envíos
</h6>
<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">
@@ -307,6 +547,7 @@ export default function EmailPage() {
<tr>
<th>Fecha</th>
<th>Plantilla</th>
<th>Módulo</th>
<th>Ticket</th>
<th>Destinatario</th>
<th>Estado</th>
@@ -315,7 +556,7 @@ export default function EmailPage() {
<tbody>
{log.length === 0 ? (
<tr>
<td colSpan={5} className="text-center text-muted py-3">
<td colSpan={6} className="text-center text-muted py-3">
Sin envíos registrados
</td>
</tr>
@@ -328,6 +569,9 @@ export default function EmailPage() {
<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>
@@ -403,6 +647,73 @@ export default function EmailPage() {
))}
</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>
);
}