Files
jorgecuadros-platform/apps/web/src/components/NotificacionesPolizas.tsx
T
rmancinas c0cc0d2ac2
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m45s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m24s
feat(renovaciones): send renewal notices from the list, drop manual marking
The Pólizas tab now sends. Each pending row gets an "Enviar aviso" button
backed by POST /renewals/send, which renders, mails and records the notice
through the same path the daily sweep uses — so a hand-sent letter is
marked exactly like a swept one and drops off the pending list.

Sending is now the only way a notice gets marked as sent. Remove the
manual "Marcar impreso" / "Marcar EMAIL" buttons and the endpoint behind
them (POST /policies/:id/renewal-notices, PoliciesService.markRenewalNotice,
MarkRenewalNoticeDto): they wrote a sentAt with no mail behind it, which
let the list claim a customer was notified when nothing was sent.

sendOne refuses a generation that already has a sentAt (409) so a double
click cannot mail the customer twice, and 400s when the customer has no
email on file. Sweep and single send share the new deliver() helper.
2026-08-02 02:40:38 -07:00

259 lines
8.5 KiB
TypeScript

"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<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() {
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);
/** `policyId-generation` of the row currently being sent, if any. */
const [sendingKey, setSendingKey] = useState<string | null>(null);
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);
}
}
/**
* 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,
}),
});
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 (
<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 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.
</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.
</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, 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>
</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>
))}
</div>
);
}