fix(notificaciones): merge renewals into one screen, fix MailModule DI
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m42s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m22s

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:
2026-08-02 02:21:51 -07:00
co-authored by Claude Opus 5
parent ec0e9c2a5d
commit 0332292ae9
9 changed files with 811 additions and 702 deletions
+2 -434
View File
@@ -1,444 +1,12 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
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 UI. Manual triggers for the four jobs plus a
* paged log browser. The page is gated on `notification:send`; a STAFF
* viewer sees the read-only log table but not the trigger buttons.
*/
import { Notificaciones } from "@/components/Notificaciones";
export default function NotificacionesPage() {
return (
<AppShell>
<Notificaciones />
<Notificaciones initialTab="servicios" />
</AppShell>
);
}
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.",
},
];
function Notificaciones() {
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: 24, padding: 24 }}>
<header>
<h1 style={{ margin: 0 }}>Notificaciones masivas</h1>
<p style={{ color: "#666", marginTop: 4 }}>
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>
</header>
<section
style={{
background: "#fff",
border: "1px solid #ddd",
borderRadius: 8,
padding: 16,
display: "grid",
gap: 12,
}}
>
<strong>Flags del envío</strong>
<label style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
type="checkbox"
checked={!!flags.debug}
disabled={!allowed}
onChange={(e) => setFlags((f) => ({ ...f, debug: e.target.checked }))}
/>
<span>
<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 style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
type="checkbox"
checked={!!flags.ignoreDayRestriction}
disabled={!allowed}
onChange={(e) =>
setFlags((f) => ({ ...f, ignoreDayRestriction: e.target.checked }))
}
/>
<span>
<strong>ignoreDayRestriction</strong> salta los gates de
Mon/Wed/Fri del estado de cuenta (job 3). Útil para disparar en
cualquier día sin esperar a la próxima corrida.
</span>
</label>
<label style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
type="checkbox"
checked={!!flags.useEmailLimit}
disabled={!allowed}
onChange={(e) =>
setFlags((f) => ({ ...f, useEmailLimit: e.target.checked }))
}
/>
<span>
<strong>useEmailLimit</strong> pausa el job 3 cada 100 correos
durante 1 hora. Vestigio de la era SMTP; SES no lo necesita.
</span>
</label>
</section>
<section
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
gap: 12,
}}
>
{JOBS.map((j) => (
<article
key={j.kind}
style={{
background: "#fff",
border: "1px solid #ddd",
borderRadius: 8,
padding: 16,
display: "grid",
gap: 8,
}}
>
<header style={{ display: "flex", justifyContent: "space-between" }}>
<strong>{j.title}</strong>
<span style={{ fontSize: 12, color: "#666" }}>{j.servicio}</span>
</header>
<p style={{ margin: 0, color: "#444", fontSize: 13 }}>{j.description}</p>
{j.flagsHint && (
<p style={{ margin: 0, color: "#666", fontSize: 12, fontStyle: "italic" }}>
{j.flagsHint}
</p>
)}
<button
type="button"
disabled={!allowed || busy !== null}
onClick={() => void run(j)}
style={{
padding: "8px 12px",
background: !allowed || busy !== null ? "#bbb" : "#1f4eaf",
color: "#fff",
border: "none",
borderRadius: 6,
cursor: !allowed || busy !== null ? "not-allowed" : "pointer",
fontWeight: 600,
}}
>
{busy === j.kind ? "Ejecutando…" : "Ejecutar"}
</button>
</article>
))}
</section>
{!allowed && (
<div
style={{
background: "#fff8e1",
border: "1px solid #f1c40f",
borderRadius: 8,
padding: 12,
}}
>
Tu rol no incluye <code>notification:send</code>. Solo puedes ver el
registro. Para disparar envíos pide a un MANAGER/ADMIN.
</div>
)}
{stats && (
<section
style={{
background: "#fff",
border: "1px solid #ddd",
borderRadius: 8,
padding: 16,
}}
>
<strong>Estado del transporte</strong>
<ul style={{ marginTop: 8, marginBottom: 0 }}>
<li>
SES configurado:{" "}
<strong style={{ color: stats.transport.available ? "#1f7a3a" : "#b3261e" }}>
{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
style={{
background: "#eef6ff",
border: "1px solid #b3d4fc",
borderRadius: 8,
padding: 12,
}}
>
<strong>Última respuesta</strong>
<pre style={{ margin: 0, fontSize: 12, overflow: "auto" }}>
{JSON.stringify(lastResult, null, 2)}
</pre>
</section>
)}
{error && (
<div
style={{
background: "#fdecea",
border: "1px solid #b3261e",
borderRadius: 8,
padding: 12,
color: "#b3261e",
}}
>
{error}
</div>
)}
<section
style={{
background: "#fff",
border: "1px solid #ddd",
borderRadius: 8,
padding: 16,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<strong>Registro de envíos</strong>
<div style={{ display: "flex", gap: 8 }}>
{(["all", "sent", "failed", "skipped"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => {
setLogFilter({ view: v });
setLogPage(1);
}}
style={{
padding: "4px 10px",
background: logFilter.view === v ? "#1f4eaf" : "#eee",
color: logFilter.view === v ? "#fff" : "#333",
border: "none",
borderRadius: 4,
cursor: "pointer",
fontSize: 12,
}}
>
{v === "all" ? "Todos" : v === "sent" ? "Enviados" : v === "failed" ? "Fallidos" : "Omitidos"}
</button>
))}
</div>
</div>
<table style={{ width: "100%", borderCollapse: "collapse", marginTop: 12 }}>
<thead>
<tr style={{ borderBottom: "2px solid #ddd" }}>
<th style={{ textAlign: "left", padding: 6 }}>Fecha</th>
<th style={{ textAlign: "left", padding: 6 }}>Tipo</th>
<th style={{ textAlign: "left", padding: 6 }}>Servicio</th>
<th style={{ textAlign: "left", padding: 6 }}>Cliente</th>
<th style={{ textAlign: "left", padding: 6 }}>Email</th>
<th style={{ textAlign: "left", padding: 6 }}>Estado</th>
<th style={{ textAlign: "left", padding: 6 }}>Asunto</th>
<th style={{ textAlign: "left", padding: 6 }}>Provider</th>
</tr>
</thead>
<tbody>
{log?.items.map((row) => (
<tr key={row.id} style={{ borderBottom: "1px solid #eee" }}>
<td style={{ padding: 6, fontSize: 12 }}>{formatDateTime(row.sendDate)}</td>
<td style={{ padding: 6, fontSize: 12 }}>
{NOTIFICATION_TYPE_LABELS[row.notificationType]}
{row.level !== null && (row.level === 0 ? " (amarilla)" : " (roja)")}
</td>
<td style={{ padding: 6, fontSize: 12 }}>{NOTIFICATION_SERVICIO_LABELS[row.servicio]}</td>
<td style={{ padding: 6, fontSize: 12 }}>{row.customerName}{row.debug ? " · debug" : ""}</td>
<td style={{ padding: 6, fontSize: 12 }}>{row.customerEmail}</td>
<td style={{ padding: 6, fontSize: 12, color: NOTIFICATION_STATUS_COLORS[row.status] }}>
{NOTIFICATION_STATUS_LABELS[row.status]}
</td>
<td style={{ padding: 6, fontSize: 12 }}>{row.subject}</td>
<td style={{ padding: 6, fontSize: 11, color: "#666" }}>
{row.providerMessageId ?? row.error ?? "—"}
</td>
</tr>
))}
{log && log.items.length === 0 && (
<tr>
<td colSpan={8} style={{ padding: 12, color: "#666", textAlign: "center" }}>
Sin envíos con el filtro actual.
</td>
</tr>
)}
</tbody>
</table>
{log && log.pageCount > 1 && (
<div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: 12, color: "#666" }}>
{log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de {log.pageCount}
</span>
<div style={{ display: "flex", gap: 4 }}>
<button
type="button"
disabled={log.page <= 1}
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
>
</button>
<button
type="button"
disabled={log.page >= log.pageCount}
onClick={() => setLogPage((p) => Math.min(log.pageCount, p + 1))}
>
</button>
</div>
</div>
)}
</section>
</div>
);
}