"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, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api"; /** * 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. */ 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; } 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 = { 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); const [pending, setPending] = useState(null); const [pendingError, setPendingError] = useState(null); const [actionError, setActionError] = useState(null); const [notice, setNotice] = useState(null); const [sweeping, setSweeping] = useState(false); /** `policyId-generation` of the row currently being sent, if any. */ const [sendingKey, setSendingKey] = useState(null); /** Raised after every send so the log panel reloads. */ const [logToken, setLogToken] = useState(0); const refresh = useCallback(async () => { setPendingError(null); try { const data = await apiFetch( `/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() { // Only worth confirming when debug is off — that is the case where real // customers receive mail. Mirrors "Ejecutar todos" on the servicios tab. if (!debug) { const ok = window.confirm( "debug está desactivado: los avisos irán a los correos reales de los clientes. ¿Ejecutar el barrido?", ); if (!ok) return; } setActionError(null); setNotice(null); setSweeping(true); try { const result = await apiFetch("/renewals/sweep", { method: "POST", body: JSON.stringify({ debug }), }); setNotice( `Enviados ${result.sent} avisos (${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("/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 (
No tiene permisos para enviar avisos de renovación.
); } const counts = (pending ?? []).reduce>( (acc, item) => ({ ...acc, [item.generation]: (acc[item.generation] ?? 0) + 1, }), {}, ); const grouped = [1, 2, 3].filter((gen) => (counts[gen] ?? 0) > 0); return (

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.

{actionError &&
{actionError}
} {notice &&
{notice}
}

Barrido manual

Usa la fecha actual del servidor como referencia para seleccionar avisos vencidos a 30 y 15 días, y vencidos hace 7 días.

Ventana (días) setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30))) } />
{pendingError &&
{pendingError}
} {!pendingError && grouped.length === 0 && (
No hay avisos pendientes en esta ventana.
)} {grouped.map((generation) => (

{GENERATION_LABEL[generation]}

{(pending ?? []) .filter((item) => item.generation === generation) .map((item) => ( ))}
Cliente Póliza Tipo Aseguradora Vence Prima Acciones
{item.customerName}
{item.customerEmail ?? "Sin correo"}
{item.policyNumber} {item.policyType} {item.provider} {formatDate(item.policyTo)} {formatMoney(item.total ?? item.netPremium, item.currency)}
))}
); }