The "Flags del envío" panel lived inside the Servicios tab and only
governed the four bulk jobs. The pólizas half had no debug at all, so
there was no way to test a renewal notice without mailing a real
customer. The panel now lives in the /notificaciones shell above the
tabs and both halves read it.
`debug` on the renewal path diverts to the same override inbox as the
servicios jobs and deliberately does NOT write the `RenewalNotice` row
or advance the sweep's `lastSuccessfulAt` — the customer was not
notified, so nothing may gate the letter they are still owed.
`ignoreDayRestriction` and `useEmailLimit` stay estado-de-cuenta-only
and are labelled as such.
Both automatic sweeps are now operator-editable. The renewal cadence
was a `@Cron("0 6 * * *")` literal and servicios had no automatic run
at all; both now resolve through `NotificationScheduleService`, which
stores the cadence in `app_settings` and reinstalls the cron job on
save — no redeploy, no restart. Defaults preserve current behaviour:
pólizas 06:00 daily, servicios off. A scheduled run never inherits the
UI flags; it always sends for real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
306 lines
10 KiB
TypeScript
306 lines
10 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, 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<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);
|
|
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 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() {
|
|
// 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<RenewalSweepResult>("/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<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.
|
|
</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>
|
|
))}
|
|
|
|
<NotificationLogPanel
|
|
servicio={POLIZAS_LOG_SCOPE}
|
|
reloadToken={logToken}
|
|
emptyHint="Todavía no se ha enviado ningún aviso de renovación con este filtro."
|
|
/>
|
|
</div>
|
|
);
|
|
}
|