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.
259 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|