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
+4 -256
View File
@@ -1,266 +1,14 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney } from "@/lib/labels";
import { apiFetch } from "@/lib/api";
export interface RenewalLetter {
policyId: string;
policyNumber: string;
policyType: string;
customerName: string;
customerEmail: string | null;
provider: string;
policyTo: string;
netPremium: string | null;
total: string | null;
currency: string;
generation: number;
sentAt: string | null;
}
export interface RenewalSweepResult {
eligible: number;
sent: number;
skipped: number;
failed: number;
failures: { policyId: string; generation: number; error: string }[];
}
export interface RenewalMarkInput {
generation: number;
channel: "MAIL" | "EMAIL";
sentAt?: string;
notes?: string;
}
import { Notificaciones } from "@/components/Notificaciones";
/** Renewal notices moved into /notificaciones as its "Pólizas" tab. This route
* stays so old bookmarks and links land on that tab instead of a 404. */
export default function RenovacionesPage() {
return (
<AppShell>
<Renovaciones />
<Notificaciones initialTab="polizas" />
</AppShell>
);
}
const GENERATION_LABEL: Record<number, string> = {
1: "Primer aviso (30 días antes)",
2: "Segundo aviso (15 días antes)",
3: "Tercer aviso (7 días después)",
};
const CHANNEL_LABEL: Record<"MAIL" | "EMAIL", string> = {
MAIL: "Impreso",
EMAIL: "Correo electrónico",
};
function Renovaciones() {
const allowed = useCan("renewal:send");
const [days, setDays] = useState(30);
const [pending, setPending] = useState<RenewalLetter[] | null>(null);
const [pendingError, setPendingError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [sweeping, setSweeping] = useState(false);
const refresh = useCallback(async () => {
setPendingError(null);
try {
const data = await apiFetch<RenewalLetter[]>(
`/renewals/pending?days=${days}`,
);
setPending(data);
} catch (e) {
setPendingError(
(e as Error)?.message ?? "No se pudo cargar la lista de avisos.",
);
setPending([]);
}
}, [days]);
useEffect(() => {
if (allowed) refresh();
}, [allowed, refresh]);
async function handleSweep() {
setActionError(null);
setNotice(null);
setSweeping(true);
try {
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
method: "POST",
});
setNotice(
`Enviados ${result.sent} avisos (${result.failed} con error).`,
);
await refresh();
} catch (e) {
setActionError((e as Error)?.message ?? "No se pudo ejecutar el barrido.");
} finally {
setSweeping(false);
}
}
async function handleMark(letter: RenewalLetter, channel: "MAIL" | "EMAIL") {
setActionError(null);
setNotice(null);
try {
await apiFetch(`/policies/${letter.policyId}/renewal-notices`, {
method: "POST",
body: JSON.stringify({
generation: letter.generation,
channel,
} satisfies RenewalMarkInput),
});
setNotice(`Aviso marcado como enviado (${CHANNEL_LABEL[channel]}).`);
await refresh();
} catch (e) {
setActionError(
(e as Error)?.message ?? "No se pudo registrar el aviso.",
);
}
}
if (!allowed) {
return (
<div className="page-head">
<h1 className="page-title">Renovaciones</h1>
<div className="state-box state-error">
No tiene permisos para enviar avisos de renovación.
</div>
</div>
);
}
const counts = (pending ?? []).reduce<Record<number, number>>(
(acc, item) => ({
...acc,
[item.generation]: (acc[item.generation] ?? 0) + 1,
}),
{},
);
const grouped = [1, 2, 3].filter((gen) => (counts[gen] ?? 0) > 0);
return (
<>
<div className="page-head">
<p className="eyebrow">Renovaciones</p>
<h1 className="page-title">Avisos de renovación</h1>
<p className="muted" style={{ marginTop: 6, maxWidth: 720 }}>
El sistema ejecuta un barrido diario a las 06:00 hora local que
notifica a los clientes a 30, 15 y 7 días antes o después del
vencimiento de su póliza. Esta pantalla muestra qué avisos están
pendientes y permite ejecutarlo manualmente.
</p>
</div>
{actionError && <div className="state-box state-error">{actionError}</div>}
{notice && <div className="state-box">{notice}</div>}
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<div className="row-actions" style={{ justifyContent: "space-between" }}>
<div>
<h2 className="section-title">Barrido manual</h2>
<p className="muted small" style={{ marginTop: 4 }}>
Usa la fecha actual del servidor como referencia para seleccionar
avisos vencidos a 30 y 15 días, y vencidos hace 7 días.
</p>
</div>
<button
type="button"
className="btn btn-primary"
disabled={sweeping}
onClick={handleSweep}
>
{sweeping ? "Enviando…" : "Ejecutar barrido"}
</button>
</div>
<div className="field" style={{ maxWidth: 180, marginTop: 12 }}>
<span className="field-label">Ventana (días)</span>
<input
className="input"
type="number"
min={1}
max={365}
value={days}
onChange={(e) =>
setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30)))
}
/>
</div>
</div>
{pendingError && (
<div className="state-box state-error">{pendingError}</div>
)}
{!pendingError && grouped.length === 0 && (
<div className="empty-inline">
No hay avisos pendientes en esta ventana.
</div>
)}
{grouped.map((generation) => (
<section className="card" key={generation} style={{ padding: 20 }}>
<h2 className="section-title">{GENERATION_LABEL[generation]}</h2>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Cliente</th>
<th>Póliza</th>
<th>Tipo</th>
<th>Aseguradora</th>
<th>Vence</th>
<th className="num">Prima</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{(pending ?? [])
.filter((item) => item.generation === generation)
.map((item) => (
<tr key={`${item.policyId}-${item.generation}`}>
<td>
<div>{item.customerName}</div>
<div className="muted small">
{item.customerEmail ?? "Sin correo"}
</div>
</td>
<td className="mono">{item.policyNumber}</td>
<td>{item.policyType}</td>
<td>{item.provider}</td>
<td>{formatDate(item.policyTo)}</td>
<td className="num">
{formatMoney(item.total ?? item.netPremium, item.currency)}
</td>
<td>
<div className="row-actions">
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => handleMark(item, "EMAIL")}
disabled={!item.customerEmail}
>
Marcar EMAIL
</button>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => handleMark(item, "MAIL")}
>
Marcar impreso
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
</>
);
}