"use client"; import { useCallback, useEffect, useState } from "react"; import { useCan } from "@/lib/abilities"; import { formatDate, formatMoney } from "@/lib/labels"; import { apiFetch } 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`. */ 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 RenewalSendResult { policyId: string; generation: number; to: string; 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() { const allowed = useCan("renewal:send"); 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); 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() { setActionError(null); setNotice(null); setSweeping(true); try { const result = await apiFetch("/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); } } /** * 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, }), }); setNotice(`Aviso enviado a ${result.to}.`); await refresh(); } catch (e) { setActionError((e as Error)?.message ?? "No se pudo enviar el aviso."); } 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 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 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)}
))}
); }