fix(notificaciones): merge renewals into one screen, fix MailModule DI
MailModule's provider used a `useFactory` with no `inject`, so the factory received `undefined` and `new MailService(config)` threw on `config.get`, taking the whole API down at boot. The module also wasn't actually `@Global()` even though both NotificationsModule and RenewalsModule inject MailService without importing it — that would have failed next. Replaced the factory with a plain provider (ConfigModule is already `isGlobal`) and marked the module global. On the web side, mass email and renewal notices were two menu entries doing the same job — telling a customer something by email. They are now two tabs of `/notificaciones` (Servicios and Pólizas), following the Captura pattern: `/renovaciones` still resolves, opening the same screen on its Pólizas tab so existing bookmarks keep working. The notifications page was also the last screen written in raw inline styles, with blue buttons and filter pills that appear nowhere else in the app. It now uses the shared design system: btn-primary/btn-outline, the seg segmented control, card, tx-table, pager, and the servicios/fideicomiso badges. Two supporting fixes found on the way: NOTIFICATION_STATUS_COLORS hardcoded hex instead of the theme's positive/negative/muted vars, and `.small` was referenced in 19 places across the app but never defined in globals.css. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDateTime } from "@/lib/labels";
|
||||
import {
|
||||
NOTIFICATION_STATUS_COLORS,
|
||||
NOTIFICATION_STATUS_LABELS,
|
||||
NOTIFICATION_SERVICIO_LABELS,
|
||||
NOTIFICATION_TYPE_LABELS,
|
||||
} from "@/lib/labels";
|
||||
import {
|
||||
getNotificationStats,
|
||||
listNotificationLog,
|
||||
runAccountStatus,
|
||||
runOutstandingPayments,
|
||||
runPaymentConfirmation,
|
||||
runTrustConfirmation,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
NotificationFlags,
|
||||
NotificationJobResponse,
|
||||
NotificationLogPage,
|
||||
NotificationStats,
|
||||
NotificationStatus,
|
||||
} from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Mass email notifications — the "Servicios" half of /notificaciones. Manual
|
||||
* triggers for the four jobs plus a paged log browser. Gated on
|
||||
* `notification:send`; a STAFF viewer sees the read-only log table but not the
|
||||
* trigger buttons.
|
||||
*/
|
||||
|
||||
type JobKind = "outstanding" | "payment" | "account" | "trust";
|
||||
|
||||
interface JobDef {
|
||||
kind: JobKind;
|
||||
title: string;
|
||||
endpoint: string;
|
||||
description: string;
|
||||
servicio: "Clientes" | "Fideicomiso";
|
||||
flagsHint?: string;
|
||||
}
|
||||
|
||||
const JOBS: JobDef[] = [
|
||||
{
|
||||
kind: "outstanding",
|
||||
title: "Pagos pendientes",
|
||||
endpoint: "sendOutstandingPaymentAlerts",
|
||||
servicio: "Clientes",
|
||||
description:
|
||||
"Clientes con al menos un movimiento marcado como pendiente (outstanding). Equivale a la columna NOPAGO=1 del antiguo datosfreak.",
|
||||
},
|
||||
{
|
||||
kind: "payment",
|
||||
title: "Confirmación de pago",
|
||||
endpoint: "sendPaymentConfirmation",
|
||||
servicio: "Clientes",
|
||||
description:
|
||||
"Clientes con un crédito (abono) en las últimas 24 horas. Un correo por cliente con el pago más reciente.",
|
||||
},
|
||||
{
|
||||
kind: "account",
|
||||
title: "Estado de cuenta",
|
||||
endpoint: "sendAccountStatus",
|
||||
servicio: "Clientes",
|
||||
description:
|
||||
"Alerta amarilla (DEBAJO DEL TIPO) los miércoles y roja (EN ROJO) lunes/miércoles/viernes. El flag ignoreDayRestriction salta los gates.",
|
||||
flagsHint: "Solo este job respeta ignoreDayRestriction y useEmailLimit.",
|
||||
},
|
||||
{
|
||||
kind: "trust",
|
||||
title: "Confirmación fideicomiso",
|
||||
endpoint: "sendConfirmTrustPayment",
|
||||
servicio: "Fideicomiso",
|
||||
description:
|
||||
"Clientes con TrustAccount que recibieron un crédito en el dominio TRUST en las últimas 24 horas.",
|
||||
},
|
||||
];
|
||||
|
||||
const LOG_VIEWS = [
|
||||
{ key: "all", label: "Todos" },
|
||||
{ key: "sent", label: "Enviados" },
|
||||
{ key: "failed", label: "Fallidos" },
|
||||
{ key: "skipped", label: "Omitidos" },
|
||||
] as const;
|
||||
|
||||
export function NotificacionesServicios() {
|
||||
const allowed = useCan("notification:send");
|
||||
|
||||
const [flags, setFlags] = useState<NotificationFlags>({ debug: true });
|
||||
const [stats, setStats] = useState<NotificationStats | null>(null);
|
||||
const [log, setLog] = useState<NotificationLogPage | null>(null);
|
||||
const [logFilter, setLogFilter] = useState<{
|
||||
status?: NotificationStatus;
|
||||
view: "all" | "sent" | "failed" | "skipped";
|
||||
}>({ view: "all" });
|
||||
const [logPage, setLogPage] = useState(1);
|
||||
const [busy, setBusy] = useState<JobKind | null>(null);
|
||||
const [lastResult, setLastResult] = useState<NotificationJobResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [s, l] = await Promise.all([
|
||||
getNotificationStats(),
|
||||
listNotificationLog({
|
||||
page: logPage,
|
||||
pageSize: 50,
|
||||
status: logFilter.status,
|
||||
view: logFilter.view === "all" ? undefined : logFilter.view,
|
||||
}),
|
||||
]);
|
||||
setStats(s);
|
||||
setLog(l);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [logPage, logFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const run = useCallback(
|
||||
async (job: JobDef) => {
|
||||
if (!allowed) return;
|
||||
setBusy(job.kind);
|
||||
setError(null);
|
||||
try {
|
||||
let res: NotificationJobResponse;
|
||||
if (job.kind === "outstanding") res = await runOutstandingPayments(flags);
|
||||
else if (job.kind === "payment") res = await runPaymentConfirmation(flags);
|
||||
else if (job.kind === "account") res = await runAccountStatus(flags);
|
||||
else res = await runTrustConfirmation(flags);
|
||||
setLastResult(res);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[allowed, flags, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 20 }}>
|
||||
<p className="muted" style={{ maxWidth: 760, margin: 0 }}>
|
||||
Disparo manual de los cuatro envíos equivalentes a los scripts PHP de{" "}
|
||||
<code>email.notifications/</code>. Cada ejecución registra todas las filas
|
||||
(enviado, fallido, omitido) en <code>email_notification_log</code>.
|
||||
</p>
|
||||
|
||||
{!allowed && (
|
||||
<div className="empty-inline">
|
||||
Tu rol no incluye <code>notification:send</code>. Solo puedes ver el
|
||||
registro. Para disparar envíos pide a un MANAGER/ADMIN.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Flags del envío</h2>
|
||||
<div style={{ display: "grid", gap: 4, marginTop: 12 }}>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!flags.debug}
|
||||
disabled={!allowed}
|
||||
onChange={(e) => setFlags((f) => ({ ...f, debug: e.target.checked }))}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>debug</strong> — reescribe todos los destinatarios a{" "}
|
||||
<code>rmancinas@freakma.net</code>. Ningún cliente real recibe el
|
||||
correo mientras esté activo.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!flags.ignoreDayRestriction}
|
||||
disabled={!allowed}
|
||||
onChange={(e) =>
|
||||
setFlags((f) => ({ ...f, ignoreDayRestriction: e.target.checked }))
|
||||
}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>ignoreDayRestriction</strong> — salta los gates de
|
||||
Mon/Wed/Fri del estado de cuenta. Útil para disparar en cualquier
|
||||
día sin esperar a la próxima corrida.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 0 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!flags.useEmailLimit}
|
||||
disabled={!allowed}
|
||||
onChange={(e) =>
|
||||
setFlags((f) => ({ ...f, useEmailLimit: e.target.checked }))
|
||||
}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>useEmailLimit</strong> — pausa el estado de cuenta cada 100
|
||||
correos durante 1 hora. Vestigio de la era SMTP; SES no lo necesita.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{JOBS.map((j) => (
|
||||
<article
|
||||
key={j.kind}
|
||||
className="card"
|
||||
style={{ padding: 18, display: "grid", gap: 8, alignContent: "start" }}
|
||||
>
|
||||
<header
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<strong>{j.title}</strong>
|
||||
<span
|
||||
className={
|
||||
j.servicio === "Fideicomiso"
|
||||
? "badge badge-fideicomiso"
|
||||
: "badge badge-servicios"
|
||||
}
|
||||
>
|
||||
<span className="dot" />
|
||||
{j.servicio}
|
||||
</span>
|
||||
</header>
|
||||
<p className="muted small" style={{ margin: 0 }}>
|
||||
{j.description}
|
||||
</p>
|
||||
{j.flagsHint && (
|
||||
<p className="section-note" style={{ margin: 0, fontStyle: "italic" }}>
|
||||
{j.flagsHint}
|
||||
</p>
|
||||
)}
|
||||
<div className="row-actions" style={{ marginTop: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!allowed || busy !== null}
|
||||
onClick={() => void run(j)}
|
||||
>
|
||||
{busy === j.kind ? "Ejecutando…" : "Ejecutar"}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{stats && (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Estado del transporte</h2>
|
||||
<ul className="small" style={{ marginTop: 10, marginBottom: 0, paddingLeft: 18 }}>
|
||||
<li>
|
||||
SES configurado:{" "}
|
||||
<strong
|
||||
style={{
|
||||
color: stats.transport.available
|
||||
? "var(--positive)"
|
||||
: "var(--negative)",
|
||||
}}
|
||||
>
|
||||
{stats.transport.available ? "sí" : "no"}
|
||||
</strong>
|
||||
{stats.transport.devFallback && " (fallback dev: stdout)"}
|
||||
</li>
|
||||
<li>
|
||||
Último envío registrado:{" "}
|
||||
{stats.lastRun
|
||||
? `${NOTIFICATION_TYPE_LABELS[stats.lastRun.notificationType]} — ${formatDateTime(stats.lastRun.sendDate)}`
|
||||
: "—"}
|
||||
</li>
|
||||
<li>
|
||||
Totales:{" "}
|
||||
{stats.byStatus.map((s) => (
|
||||
<span key={s.status} style={{ marginRight: 12 }}>
|
||||
{NOTIFICATION_STATUS_LABELS[s.status]}: {s._count._all}
|
||||
</span>
|
||||
))}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{lastResult && (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Última respuesta</h2>
|
||||
<pre
|
||||
className="mono"
|
||||
style={{
|
||||
margin: "10px 0 0",
|
||||
fontSize: 12,
|
||||
overflow: "auto",
|
||||
background: "var(--surface-2)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(lastResult, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<h2 className="section-title">Registro de envíos</h2>
|
||||
<div className="seg" role="tablist">
|
||||
{LOG_VIEWS.map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={logFilter.view === v.key}
|
||||
className={`seg-btn ${logFilter.view === v.key ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setLogFilter({ view: v.key });
|
||||
setLogPage(1);
|
||||
}}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tx-scroll" style={{ marginTop: 12 }}>
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Tipo</th>
|
||||
<th>Servicio</th>
|
||||
<th>Cliente</th>
|
||||
<th>Email</th>
|
||||
<th>Estado</th>
|
||||
<th>Asunto</th>
|
||||
<th>Provider</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{log?.items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{formatDateTime(row.sendDate)}</td>
|
||||
<td>
|
||||
{NOTIFICATION_TYPE_LABELS[row.notificationType]}
|
||||
{row.level !== null && (row.level === 0 ? " (amarilla)" : " (roja)")}
|
||||
</td>
|
||||
<td>{NOTIFICATION_SERVICIO_LABELS[row.servicio]}</td>
|
||||
<td>
|
||||
{row.customerName}
|
||||
{row.debug ? " · debug" : ""}
|
||||
</td>
|
||||
<td>{row.customerEmail}</td>
|
||||
<td style={{ color: NOTIFICATION_STATUS_COLORS[row.status] }}>
|
||||
{NOTIFICATION_STATUS_LABELS[row.status]}
|
||||
</td>
|
||||
<td>{row.subject}</td>
|
||||
<td className="muted small">
|
||||
{row.providerMessageId ?? row.error ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{log && log.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8}>
|
||||
<span className="empty-inline">
|
||||
Sin envíos con el filtro actual.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{log && log.pageCount > 1 && (
|
||||
<div className="pager" style={{ marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={log.page <= 1}
|
||||
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<span className="pager-info">
|
||||
{log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de{" "}
|
||||
{log.pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={log.page >= log.pageCount}
|
||||
onClick={() => setLogPage((p) => Math.min(log.pageCount, p + 1))}
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user