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
@@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import { useCan } from "@/lib/abilities";
import { NotificacionesServicios } from "@/components/NotificacionesServicios";
import { NotificacionesPolizas } from "@/components/NotificacionesPolizas";
/**
* Notificaciones — one screen, two subsections:
*
* - **servicios** — the four mass-email jobs against customer ledgers
* (pagos pendientes, confirmación de pago, estado de cuenta, fideicomiso).
* - **polizas** — renewal notices, 30/15 days before and 7 days after a
* policy expires.
*
* Both are "tell a customer something by email", so they are modes of one
* screen rather than two menu entries. `/renovaciones` still resolves here on
* the pólizas tab so old bookmarks keep working (same pattern as Captura).
*/
export type NotificacionesTab = "servicios" | "polizas";
const TAB_HINT: Record<NotificacionesTab, string> = {
servicios:
"Envíos masivos de cobranza y estado de cuenta a los clientes de servicios.",
polizas: "Avisos de renovación de pólizas: 30 y 15 días antes, 7 días después.",
};
export function Notificaciones({
initialTab = "servicios",
}: {
initialTab?: NotificacionesTab;
}) {
const canNotify = useCan("notification:send");
const canRenew = useCan("renewal:send");
// Gating is cosmetic (the API enforces every send), but a user who only has
// one of the two abilities should land on the tab they can actually use.
// Servicios stays visible read-only for STAFF, who can browse the log.
const tabs: { key: NotificacionesTab; label: string }[] = [
{ key: "servicios", label: "Servicios" },
...(canRenew ? [{ key: "polizas" as const, label: "Pólizas" }] : []),
];
const [tab, setTab] = useState<NotificacionesTab>(
tabs.some((t) => t.key === initialTab) ? initialTab : "servicios",
);
if (!canNotify && !canRenew) {
return (
<div className="state-box state-error">
No tienes permiso para enviar notificaciones.
</div>
);
}
return (
<>
<div className="page-head">
<p className="eyebrow">Notificaciones</p>
<h1 className="page-title">Notificaciones</h1>
<p className="muted" style={{ marginTop: 6, maxWidth: 720 }}>
{TAB_HINT[tab]}
</p>
</div>
{tabs.length > 1 && (
<div className="seg" role="tablist" style={{ marginBottom: 20 }}>
{tabs.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
className={`seg-btn ${tab === t.key ? "active" : ""}`}
onClick={() => setTab(t.key)}
>
{t.label}
</button>
))}
</div>
)}
{tab === "servicios" ? <NotificacionesServicios /> : <NotificacionesPolizas />}
</>
);
}