Files
jorgecuadros-platform/apps/web/src/components/NotificacionesPolizas.tsx
T
rmancinasandClaude Opus 5 36158ae761
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m59s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m27s
feat(notificaciones): sweep one aseguradora at a time, and stop the robot quoting a premium
The office works GMX and ANA as separate batches, so the manual barrido now
takes a "Compañía" selection. It filters the pending list as well as the
sweep, so what is on screen is exactly what "Ejecutar barrido" will mail, and
the confirmation names the carrier — running GMX when ANA was meant is the
mistake the filter exists to prevent, and it is not reversible once the mail
is out.

The carrier is chosen by InsuranceProvider id, from the same /lookups the
policy form reads, so a renamed or newly added aseguradora needs no code
change here.

A carrier-scoped run deliberately does NOT advance `lastSuccessfulAt`. The
sweep's catch-up window is computed from it, so advancing after a run that
looked at every day but mailed only one carrier would push every OTHER
carrier's letters out of tomorrow's window and they would never be sent.
Same reasoning that already keeps a debug run from advancing it.

Separately, the letter's "Prima" row is now dropped from the unattended
scheduled sweep only. A premium can still be re-rated at renewal, and an
amount a robot mailed out is one the office has to walk back; every send a
person triggers — the manual barrido and the per-row "Enviar aviso" — still
quotes it. `recordLog` renders with the same flag, so `bodySnapshot` cannot
show the office a letter the customer never received.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:24:28 -07:00

375 lines
13 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney } from "@/lib/labels";
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
import {
apiFetch,
getLookups,
POLIZAS_LOG_SCOPE,
type NotificationFlags,
} from "@/lib/api";
import type { ProviderRow } from "@/lib/types";
/**
* Renewal notices — the "Pólizas" half of /notificaciones. Shows which
* renewal letters are pending in a window and lets staff send them, either
* one row at a time or as a whole sweep. Sending is what marks a notice as
* delivered — there is no manual "mark as sent", so the list can never claim
* a letter went out when no mail was ever sent. Gated on `renewal:send`.
*
* Sends are recorded in the same `email_notification_log` the Servicios tab
* reads, so "Registro de envíos" below is the same component with the
* POLICIES slice — failures and no-email skips included, which the pending
* list alone cannot show.
*
* `debug` comes from the shared flags card above the tabs and means the same
* thing here as it does for servicios: the mail is diverted to the override
* inbox. It additionally does NOT mark the notice as sent, so a test send
* leaves the row exactly where it was — pending.
*
* The barrido manual is scoped by aseguradora because the office works GMX and
* ANA as separate batches. The selection filters the pending list too, so what
* is on screen is exactly what "Ejecutar barrido" will mail. A carrier-scoped
* run deliberately does not advance the sweep's catch-up window — it only
* covered one carrier — so the other carriers' letters stay pending.
*/
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 }[];
debug: boolean;
/** Echoed back so the confirmation says which carrier actually ran. */
providerId: string | null;
}
export interface RenewalSendResult {
policyId: string;
generation: number;
/** Where the mail actually went — the override inbox under debug. */
to: string;
debug: boolean;
sentAt: string;
providerMessageId?: string;
}
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)",
};
export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
const allowed = useCan("renewal:send");
const debug = !!flags.debug;
const [days, setDays] = useState(30);
/** "" = ambas/todas. Holds an InsuranceProvider id, never a name — carriers
* are renamed in the lookups screen and the filter must survive that. */
const [providerId, setProviderId] = useState("");
const [providers, setProviders] = useState<ProviderRow[]>([]);
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);
/** `policyId-generation` of the row currently being sent, if any. */
const [sendingKey, setSendingKey] = useState<string | null>(null);
/** Raised after every send so the log panel reloads. */
const [logToken, setLogToken] = useState(0);
const refresh = useCallback(async () => {
setPendingError(null);
try {
const params = new URLSearchParams({ days: String(days) });
if (providerId) params.set("providerId", providerId);
const data = await apiFetch<RenewalLetter[]>(
`/renewals/pending?${params.toString()}`,
);
setPending(data);
} catch (e) {
setPendingError(
(e as Error)?.message ?? "No se pudo cargar la lista de avisos.",
);
setPending([]);
}
}, [days, providerId]);
useEffect(() => {
if (allowed) refresh();
}, [allowed, refresh]);
// Carriers come from the same lookups the policy form uses, so a new
// aseguradora shows up here without a code change.
useEffect(() => {
if (!allowed) return;
let cancelled = false;
getLookups()
.then((data) => {
if (!cancelled) setProviders(data.providers);
})
.catch(() => {
// A failed lookup only costs the filter; the unfiltered sweep still
// works, so this must not blank the screen.
if (!cancelled) setProviders([]);
});
return () => {
cancelled = true;
};
}, [allowed]);
const providerLabel =
providers.find((item) => item.id === providerId)?.name ?? "todas las compañías";
async function handleSweep() {
// Only worth confirming when debug is off — that is the case where real
// customers receive mail. Mirrors "Ejecutar todos" on the servicios tab.
// The carrier is named in the prompt: running GMX when ANA was meant is
// exactly the mistake this filter exists to prevent, and it is not
// reversible once the mail is out.
if (!debug) {
const ok = window.confirm(
`debug está desactivado: los avisos irán a los correos reales de los clientes. ` +
`¿Ejecutar el barrido de ${providerLabel}?`,
);
if (!ok) return;
}
setActionError(null);
setNotice(null);
setSweeping(true);
try {
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
method: "POST",
body: JSON.stringify({ debug, providerId: providerId || undefined }),
});
setNotice(
`Enviados ${result.sent} avisos de ${providerLabel} ` +
`(${result.failed} con error).` +
(result.debug
? " Modo debug: fueron al buzón de pruebas y siguen pendientes."
: ""),
);
setLogToken((t) => t + 1);
await refresh();
} catch (e) {
setActionError((e as Error)?.message ?? "No se pudo ejecutar el barrido.");
setLogToken((t) => t + 1);
} finally {
setSweeping(false);
}
}
/**
* Send this one notice now. The API records it as sent on success, so the
* row leaves the pending list — that disappearance IS the "sent" signal,
* backed by the confirmation line above the table.
*/
async function handleSend(letter: RenewalLetter) {
setActionError(null);
setNotice(null);
setSendingKey(`${letter.policyId}-${letter.generation}`);
try {
const result = await apiFetch<RenewalSendResult>("/renewals/send", {
method: "POST",
body: JSON.stringify({
policyId: letter.policyId,
generation: letter.generation,
debug,
}),
});
setNotice(
result.debug
? `Prueba enviada a ${result.to}. El aviso sigue pendiente: el cliente no ha recibido nada.`
: `Aviso enviado a ${result.to}.`,
);
setLogToken((t) => t + 1);
await refresh();
} catch (e) {
setActionError((e as Error)?.message ?? "No se pudo enviar el aviso.");
// A rejected send may still have written a FAILED row; reload either way.
setLogToken((t) => t + 1);
} finally {
setSendingKey(null);
}
}
if (!allowed) {
return (
<div className="empty-inline">
No tiene permisos para enviar avisos de renovación.
</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 style={{ display: "grid", gap: 20 }}>
<p className="muted" style={{ maxWidth: 760, margin: 0 }}>
El sistema ejecuta un barrido automático (ver «Programación de envíos»
arriba) que notifica a los clientes a 30, 15 y 7 días antes o después
del vencimiento de su póliza. Esta sección muestra qué avisos están
pendientes y permite ejecutarlo manualmente.
</p>
{actionError && <div className="state-box state-error">{actionError}</div>}
{notice && <div className="empty-inline">{notice}</div>}
<section className="card" style={{ padding: 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. La
compañía elegida filtra también la lista de abajo: se envía
exactamente lo que está en pantalla.
</p>
</div>
<button
type="button"
className="btn btn-primary"
disabled={sweeping}
onClick={handleSweep}
>
{sweeping ? "Enviando…" : "Ejecutar barrido"}
</button>
</div>
<div
className="row-actions"
style={{ marginTop: 12, alignItems: "flex-end", gap: 16 }}
>
<div className="field" style={{ maxWidth: 180, marginBottom: 0 }}>
<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 className="field" style={{ maxWidth: 260, marginBottom: 0 }}>
<span className="field-label">Compañía</span>
<select
className="input"
value={providerId}
onChange={(e) => setProviderId(e.target.value)}
>
<option value="">Todas las compañías</option>
{providers.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
</div>
</section>
{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" style={{ marginTop: 12 }}>
<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={() => handleSend(item)}
disabled={
!item.customerEmail ||
sweeping ||
sendingKey !== null
}
>
{sendingKey ===
`${item.policyId}-${item.generation}`
? "Enviando…"
: "Enviar aviso"}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
<NotificationLogPanel
servicio={POLIZAS_LOG_SCOPE}
reloadToken={logToken}
emptyHint="Todavía no se ha enviado ningún aviso de renovación con este filtro."
/>
</div>
);
}