feat: plantillas de correo en portal IT (listar, crear, editar)
This commit is contained in:
1 parent
9710d995b8
commit
b6ca254a82
7 files changed
+666
-2
No files matched your search
@@ -17,6 +17,7 @@
|
||||
| `/actualizar` | sí | Redeploy + webhook + toggle visibilidad |
|
||||
| `/eliminar` | sí | Baja de módulos (con/sin código) |
|
||||
| `/correo` | sí | Claves de correo por módulo (`POST /api/email/modules`, banco `mail.*`) |
|
||||
| `/correo/plantillas` | sí | Plantillas del banco `mail.templates` (listar, crear, editar, activar) |
|
||||
| `/api/modules`, `/api/stats` | no/sí | Proxy al deployer (token server-side) |
|
||||
| `/api/deployer/*` | sí | Proxy genérico al deployer |
|
||||
| `/api/auth`, `/api/me` | — | Sesión HMAC 12h en cookie httpOnly |
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
assertIT,
|
||||
helpdeskFetch,
|
||||
asResponse,
|
||||
errResponse,
|
||||
} from "@/lib/correo-server";
|
||||
|
||||
// PUT: actualiza subject / bodyHtml / isActive de una plantilla.
|
||||
// Backend: PUT /api/email/templates/{key} (solo admin).
|
||||
export async function PUT(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ key: string }> }
|
||||
): Promise<Response> {
|
||||
try {
|
||||
await assertIT(req);
|
||||
const key = (await params).key.trim().toLowerCase();
|
||||
if (!/^[a-z0-9_]{2,64}$/.test(key)) {
|
||||
return NextResponse.json(
|
||||
{ message: "Key inválida ([a-z0-9_], 2-64)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
subject?: string;
|
||||
bodyHtml?: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (body.subject !== undefined) payload.subject = body.subject;
|
||||
if (body.bodyHtml !== undefined) payload.bodyHtml = body.bodyHtml;
|
||||
if (body.isActive !== undefined) payload.isActive = body.isActive;
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "Nada que actualizar (subject, bodyHtml, isActive)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return await asResponse(
|
||||
await helpdeskFetch(`/api/email/templates/${encodeURIComponent(key)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
return errResponse(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
assertIT,
|
||||
helpdeskFetch,
|
||||
asResponse,
|
||||
errResponse,
|
||||
} from "@/lib/correo-server";
|
||||
|
||||
const KEY_RE = /^[a-z0-9_]{2,64}$/;
|
||||
const MODULE_RE = /^[a-z0-9-]{2,32}$/;
|
||||
|
||||
// GET: lista plantillas del banco único mail.templates.
|
||||
// Backend: GET /api/email/templates (módulo autorizado o admin;
|
||||
// aquí siempre vamos como admin vía cuenta de servicio).
|
||||
export async function GET(req: Request): Promise<Response> {
|
||||
try {
|
||||
await assertIT(req);
|
||||
return await asResponse(await helpdeskFetch("/api/email/templates"));
|
||||
} catch (e) {
|
||||
return errResponse(e);
|
||||
}
|
||||
}
|
||||
|
||||
// POST {key, name, subject, bodyHtml, module?}: crea plantilla.
|
||||
// Valida formato antes de proxear (mismas reglas que EmailController).
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
try {
|
||||
await assertIT(req);
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
key?: string;
|
||||
name?: string;
|
||||
subject?: string;
|
||||
bodyHtml?: string;
|
||||
module?: string;
|
||||
};
|
||||
const key = (body.key ?? "").trim().toLowerCase();
|
||||
const name = (body.name ?? "").trim();
|
||||
const subject = (body.subject ?? "").trim();
|
||||
const bodyHtml = (body.bodyHtml ?? "").trim();
|
||||
const modName = (body.module ?? "shared").trim().toLowerCase();
|
||||
if (!KEY_RE.test(key)) {
|
||||
return NextResponse.json(
|
||||
{ message: "Key inválida ([a-z0-9_], 2-64)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!name || !subject || !bodyHtml) {
|
||||
return NextResponse.json(
|
||||
{ message: "name, subject y bodyHtml son requeridos" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!MODULE_RE.test(modName)) {
|
||||
return NextResponse.json(
|
||||
{ message: "Módulo inválido ([a-z0-9-], 2-32)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return await asResponse(
|
||||
await helpdeskFetch("/api/email/templates", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key, name, subject, bodyHtml, module: modName }),
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
return errResponse(e);
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import SamShell from "@/components/SamShell";
|
||||
import { refreshCorreo, useCorreoModulos } from "@/lib/correo";
|
||||
import { refreshModules, useModules, useMe } from "@/lib/store";
|
||||
@@ -82,8 +83,23 @@ export default function Correo() {
|
||||
allowLogout
|
||||
backHref="/"
|
||||
>
|
||||
<div className="alert alert-light section-header mb-3 py-2 px-3">
|
||||
<i className="bx bx-envelope me-1" /> Correo · Módulos autorizados
|
||||
<div className="alert alert-light section-header mb-3 py-2 px-3 d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<span>
|
||||
<i className="bx bx-envelope me-1" /> Correo · Módulos autorizados
|
||||
</span>
|
||||
<span className="d-flex gap-2">
|
||||
<Link href="/correo" className="btn btn-sm btn-primary">
|
||||
<i className="bx bx-envelope me-1" />
|
||||
Módulos
|
||||
</Link>
|
||||
<Link
|
||||
href="/correo/plantillas"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
>
|
||||
<i className="bx bx-file-blank me-1" />
|
||||
Plantillas
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import SamShell from "@/components/SamShell";
|
||||
import {
|
||||
refreshPlantillas,
|
||||
useCorreoPlantillas,
|
||||
type CorreoTemplate,
|
||||
} from "@/lib/correo";
|
||||
import { useModules, useMe } from "@/lib/store";
|
||||
import { itMenu } from "@/lib/sam";
|
||||
|
||||
// Plantillas de correo (banco único mail.templates).
|
||||
// Lista (GET), crea (POST key/name/subject/bodyHtml/module) y
|
||||
// edita (PUT subject/bodyHtml/isActive). Las variables son
|
||||
// {{Clave}} (case-sensitive) y las pone cada módulo al enviar.
|
||||
export default function Plantillas() {
|
||||
const { data, error } = useModules();
|
||||
const me = useMe();
|
||||
const modules = data?.modules ?? [];
|
||||
const plantillas = useCorreoPlantillas();
|
||||
const rows = plantillas.data ?? [];
|
||||
const rowsData = plantillas.data;
|
||||
|
||||
const [q, setQ] = useState("");
|
||||
const [sel, setSel] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
// Form crear.
|
||||
const [fKey, setFKey] = useState("");
|
||||
const [fName, setFName] = useState("");
|
||||
const [fSubject, setFSubject] = useState("");
|
||||
const [fBody, setFBody] = useState("");
|
||||
const [fModule, setFModule] = useState("shared");
|
||||
|
||||
// Form editar.
|
||||
const [eSubject, setESubject] = useState("");
|
||||
const [eBody, setEBody] = useState("");
|
||||
const [eActive, setEActive] = useState(true);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = rowsData ?? [];
|
||||
const needle = q.trim().toLowerCase();
|
||||
if (!needle) return list;
|
||||
return list.filter(
|
||||
(t) =>
|
||||
t.key.toLowerCase().includes(needle) ||
|
||||
t.name.toLowerCase().includes(needle) ||
|
||||
t.subject.toLowerCase().includes(needle)
|
||||
);
|
||||
}, [rowsData, q]);
|
||||
|
||||
const selected: CorreoTemplate | null =
|
||||
rows.find((t) => t.key === sel) ?? null;
|
||||
|
||||
const pick = (t: CorreoTemplate) => {
|
||||
setSel(t.key);
|
||||
setESubject(t.subject);
|
||||
setEBody(t.bodyHtml);
|
||||
setEActive(t.isActive);
|
||||
setMsg(null);
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
const key = fKey.trim().toLowerCase();
|
||||
if (!/^[a-z0-9_]{2,64}$/.test(key)) {
|
||||
setMsg({ ok: false, text: "Key inválida ([a-z0-9_], 2-64)" });
|
||||
return;
|
||||
}
|
||||
if (!fName.trim() || !fSubject.trim() || !fBody.trim()) {
|
||||
setMsg({ ok: false, text: "Nombre, asunto y cuerpo son requeridos" });
|
||||
return;
|
||||
}
|
||||
setBusy("create");
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await fetch("/api/correo/plantillas", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key,
|
||||
name: fName.trim(),
|
||||
subject: fSubject.trim(),
|
||||
bodyHtml: fBody.trim(),
|
||||
module: fModule.trim().toLowerCase() || "shared",
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.message || j.error || `HTTP ${res.status}`);
|
||||
setMsg({ ok: true, text: `Plantilla "${key}" creada.` });
|
||||
setFKey("");
|
||||
setFName("");
|
||||
setFSubject("");
|
||||
setFBody("");
|
||||
refreshPlantillas();
|
||||
} catch (e) {
|
||||
setMsg({ ok: false, text: e instanceof Error ? e.message : "Error" });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!selected) return;
|
||||
if (!eSubject.trim() || !eBody.trim()) {
|
||||
setMsg({ ok: false, text: "Asunto y cuerpo son requeridos" });
|
||||
return;
|
||||
}
|
||||
setBusy(`save:${selected.key}`);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/correo/plantillas/${encodeURIComponent(selected.key)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
subject: eSubject.trim(),
|
||||
bodyHtml: eBody.trim(),
|
||||
isActive: eActive,
|
||||
}),
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.message || j.error || `HTTP ${res.status}`);
|
||||
setMsg({ ok: true, text: `Plantilla "${selected.key}" actualizada.` });
|
||||
refreshPlantillas();
|
||||
} catch (e) {
|
||||
setMsg({ ok: false, text: e instanceof Error ? e.message : "Error" });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (t: CorreoTemplate) => {
|
||||
setBusy(`toggle:${t.key}`);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/correo/plantillas/${encodeURIComponent(t.key)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isActive: !t.isActive }),
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.message || j.error || `HTTP ${res.status}`);
|
||||
// Si es la seleccionada, refleja el cambio en el form.
|
||||
if (sel === t.key) setEActive(!t.isActive);
|
||||
setMsg({
|
||||
ok: true,
|
||||
text: `Plantilla "${t.key}" ${!t.isActive ? "activada" : "desactivada"}.`,
|
||||
});
|
||||
refreshPlantillas();
|
||||
} catch (e) {
|
||||
setMsg({ ok: false, text: e instanceof Error ? e.message : "Error" });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SamShell
|
||||
brand="IT"
|
||||
subtitle="Correo · Plantillas"
|
||||
menu={itMenu(modules)}
|
||||
activeHref="/correo/plantillas"
|
||||
topUser={me?.email || "SAM"}
|
||||
allowLogout
|
||||
backHref="/"
|
||||
>
|
||||
<div className="alert alert-light section-header mb-3 py-2 px-3 d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<span>
|
||||
<i className="bx bx-file-blank me-1" /> Correo · Plantillas
|
||||
</span>
|
||||
<span className="d-flex gap-2">
|
||||
<Link href="/correo" className="btn btn-sm btn-outline-secondary">
|
||||
<i className="bx bx-envelope me-1" />
|
||||
Módulos
|
||||
</Link>
|
||||
<Link
|
||||
href="/correo/plantillas"
|
||||
className="btn btn-sm btn-primary"
|
||||
>
|
||||
<i className="bx bx-file-blank me-1" />
|
||||
Plantillas
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
{plantillas.error && (
|
||||
<div className="alert alert-danger">{plantillas.error}</div>
|
||||
)}
|
||||
{msg && (
|
||||
<div className={`alert ${msg.ok ? "alert-success" : "alert-danger"}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="app-card p-3 mb-3">
|
||||
<div className="d-flex gap-2 flex-wrap align-items-center mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
style={{ maxWidth: 320 }}
|
||||
placeholder="Buscar por key, nombre o asunto…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => refreshPlantillas()}
|
||||
title="Recargar"
|
||||
>
|
||||
<i className="bx bx-refresh me-1" />
|
||||
Recargar
|
||||
</button>
|
||||
<small className="text-muted ms-auto">
|
||||
{filtered.length} de {rows.length} plantillas · variables{" "}
|
||||
<code>{"{{Clave}}"}</code>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Nombre</th>
|
||||
<th>Asunto</th>
|
||||
<th>Estado</th>
|
||||
<th className="text-end">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((t) => (
|
||||
<tr
|
||||
key={t.key}
|
||||
className={sel === t.key ? "table-primary" : ""}
|
||||
>
|
||||
<td>
|
||||
<code className="small">{t.key}</code>
|
||||
</td>
|
||||
<td className="small">{t.name}</td>
|
||||
<td className="small text-truncate" style={{ maxWidth: 280 }}>
|
||||
{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">
|
||||
<div className="d-flex gap-1 justify-content-end">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
disabled={busy !== null}
|
||||
onClick={() => pick(t)}
|
||||
title="Ver / editar"
|
||||
>
|
||||
<i className="bx bx-edit" />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${t.isActive ? "btn-outline-secondary" : "btn-outline-success"}`}
|
||||
disabled={busy !== null}
|
||||
onClick={() => toggleActive(t)}
|
||||
title={t.isActive ? "Desactivar" : "Activar"}
|
||||
>
|
||||
{busy === `toggle:${t.key}` ? (
|
||||
<span className="spinner-border spinner-border-sm" />
|
||||
) : (
|
||||
<i
|
||||
className={`bx ${t.isActive ? "bx-pause" : "bx-play"}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center text-muted py-4">
|
||||
{rows.length === 0
|
||||
? "Sin plantillas en el banco."
|
||||
: "Sin coincidencias."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="app-card p-3 mb-3">
|
||||
<h6 className="fw-bold mb-3">
|
||||
<i className="bx bx-edit me-1" />
|
||||
Editar <code>{selected.key}</code>
|
||||
</h6>
|
||||
<div className="row g-3">
|
||||
<div className="col-md-6">
|
||||
<label className="form-label small fw-bold">Asunto</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={eSubject}
|
||||
onChange={(e) => setESubject(e.target.value)}
|
||||
/>
|
||||
<label className="form-label small fw-bold mt-3">
|
||||
Cuerpo HTML (usa <code>{"{{Clave}}"}</code>)
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control font-monospace small"
|
||||
rows={10}
|
||||
value={eBody}
|
||||
onChange={(e) => setEBody(e.target.value)}
|
||||
/>
|
||||
<div className="form-check mt-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="tpl-active"
|
||||
checked={eActive}
|
||||
onChange={(e) => setEActive(e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label small" htmlFor="tpl-active">
|
||||
Plantilla activa (los envíos con plantilla inactiva fallan)
|
||||
</label>
|
||||
</div>
|
||||
<div className="d-flex gap-2 mt-3">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={busy !== null}
|
||||
onClick={save}
|
||||
>
|
||||
{busy === `save:${selected.key}` ? (
|
||||
<span className="spinner-border spinner-border-sm" />
|
||||
) : (
|
||||
<>
|
||||
<i className="bx bx-save me-1" />
|
||||
Guardar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
onClick={() => setSel(null)}
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label small fw-bold">Vista previa</label>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="small text-muted mb-1">Asunto:</div>
|
||||
<div className="fw-bold mb-3">{eSubject || "—"}</div>
|
||||
<div className="small text-muted mb-1">Cuerpo:</div>
|
||||
<div
|
||||
className="small"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: eBody || "<span class='text-muted'>—</span>",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="app-card p-3">
|
||||
<h6 className="fw-bold mb-3">
|
||||
<i className="bx bx-plus me-1" />
|
||||
Nueva plantilla
|
||||
</h6>
|
||||
<div className="row g-2">
|
||||
<div className="col-md-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="key (ej: pago_ok)"
|
||||
value={fKey}
|
||||
onChange={(e) => setFKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Nombre (ej: Pago recibido)"
|
||||
value={fName}
|
||||
onChange={(e) => setFName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Módulo (default: shared)"
|
||||
value={fModule}
|
||||
onChange={(e) => setFModule(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Asunto (ej: Pago {{Folio}} recibido)"
|
||||
value={fSubject}
|
||||
onChange={(e) => setFSubject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<textarea
|
||||
className="form-control font-monospace small"
|
||||
rows={4}
|
||||
placeholder="Cuerpo HTML con {{variables}} (ej: <p>Hola {{Nombre}}, …</p>)"
|
||||
value={fBody}
|
||||
onChange={(e) => setFBody(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex gap-2 mt-3 align-items-center flex-wrap">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={busy !== null}
|
||||
onClick={create}
|
||||
>
|
||||
{busy === "create" ? (
|
||||
<span className="spinner-border spinner-border-sm" />
|
||||
) : (
|
||||
<>
|
||||
<i className="bx bx-plus me-1" />
|
||||
Crear plantilla
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<small className="text-muted">
|
||||
Key <code>[a-z0-9_]</code> 2-64. Se guarda en{" "}
|
||||
<code>mail.templates</code> y queda disponible para{" "}
|
||||
<code>POST /api/email/send</code> como{" "}
|
||||
<code>templateKey</code>.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</SamShell>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,14 @@ export interface CorreoModule {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface CorreoTemplate {
|
||||
key: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
bodyHtml: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface CorreoSnap {
|
||||
data: CorreoModule[] | null;
|
||||
error: string | null;
|
||||
@@ -70,3 +78,67 @@ export function useCorreoModulos(): CorreoSnap {
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Plantillas del banco único mail.templates (GET/POST/PUT vía
|
||||
// /api/correo/plantillas). Mismo patrón de store que módulos.
|
||||
// ============================================================
|
||||
|
||||
interface PlantillasSnap {
|
||||
data: CorreoTemplate[] | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
async function fetchPlantillas(): Promise<PlantillasSnap> {
|
||||
try {
|
||||
const res = await fetch("/api/correo/plantillas", { cache: "no-store" });
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.message || j.error || `HTTP ${res.status}`);
|
||||
return { data: Array.isArray(j) ? j : [], error: null };
|
||||
} catch (e) {
|
||||
return {
|
||||
data: null,
|
||||
error: e instanceof Error ? e.message : "Error al cargar plantillas",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let psnap: PlantillasSnap = { data: null, error: null };
|
||||
let pstarted = false;
|
||||
const plisteners = new Set<() => void>();
|
||||
|
||||
function pnotify() {
|
||||
plisteners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function refreshPlantillas() {
|
||||
fetchPlantillas()
|
||||
.then((next) => {
|
||||
psnap = next;
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => pnotify());
|
||||
}
|
||||
|
||||
function psubscribe(cb: () => void) {
|
||||
if (!pstarted) {
|
||||
pstarted = true;
|
||||
refreshPlantillas();
|
||||
}
|
||||
plisteners.add(cb);
|
||||
return () => {
|
||||
plisteners.delete(cb);
|
||||
if (plisteners.size === 0) pstarted = false;
|
||||
};
|
||||
}
|
||||
|
||||
function psnapshot(): PlantillasSnap {
|
||||
return psnap;
|
||||
}
|
||||
|
||||
export function useCorreoPlantillas(): PlantillasSnap {
|
||||
return useSyncExternalStore(psubscribe, psnapshot, () => ({
|
||||
data: null,
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
@@ -452,5 +452,6 @@ export function itMenu(modules: ModuleState[]): MenuItem[] {
|
||||
{ label: "Actualizar", icon: "bx-refresh", href: "/actualizar" },
|
||||
{ label: "Eliminar", icon: "bx-trash", href: "/eliminar" },
|
||||
{ label: "Correo", icon: "bx-envelope", href: "/correo" },
|
||||
{ label: "Plantillas", icon: "bx-file-blank", href: "/correo/plantillas" },
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user