fix(notificaciones): merge renewals into one screen, fix MailModule DI
MailModule's provider used a `useFactory` with no `inject`, so the factory received `undefined` and `new MailService(config)` threw on `config.get`, taking the whole API down at boot. The module also wasn't actually `@Global()` even though both NotificationsModule and RenewalsModule inject MailService without importing it — that would have failed next. Replaced the factory with a plain provider (ConfigModule is already `isGlobal`) and marked the module global. On the web side, mass email and renewal notices were two menu entries doing the same job — telling a customer something by email. They are now two tabs of `/notificaciones` (Servicios and Pólizas), following the Captura pattern: `/renovaciones` still resolves, opening the same screen on its Pólizas tab so existing bookmarks keep working. The notifications page was also the last screen written in raw inline styles, with blue buttons and filter pills that appear nowhere else in the app. It now uses the shared design system: btn-primary/btn-outline, the seg segmented control, card, tx-table, pager, and the servicios/fideicomiso badges. Two supporting fixes found on the way: NOTIFICATION_STATUS_COLORS hardcoded hex instead of the theme's positive/negative/muted vars, and `.small` was referenced in 19 places across the app but never defined in globals.css. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Global, Module } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { MailService } from "./mail.service";
|
import { MailService } from "./mail.service";
|
||||||
|
|
||||||
/** Global so any feature module can inject MailService without re-importing.
|
/** Global so any feature module can inject MailService without re-importing.
|
||||||
* Matches the StorageService pattern: env-driven, null when unconfigured,
|
* Matches the StorageService pattern: env-driven, null when unconfigured,
|
||||||
* and never blocks API boot. Notifications use it; renewals reuse it. */
|
* and never blocks API boot. Notifications use it; renewals reuse it.
|
||||||
|
* ConfigService comes from the global ConfigModule in AppModule. */
|
||||||
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [{ provide: MailService, useFactory: (c: ConfigService) => new MailService(c) }],
|
providers: [MailService],
|
||||||
exports: [MailService],
|
exports: [MailService],
|
||||||
})
|
})
|
||||||
export class MailModule {}
|
export class MailModule {}
|
||||||
|
|||||||
@@ -212,6 +212,11 @@ button {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Secondary line inside a row or card — used alongside .muted throughout. */
|
||||||
|
.small {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
App shell / top nav
|
App shell / top nav
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|||||||
@@ -1,444 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { useCan } from "@/lib/abilities";
|
import { Notificaciones } from "@/components/Notificaciones";
|
||||||
import { formatDateTime } from "@/lib/labels";
|
|
||||||
import {
|
|
||||||
NOTIFICATION_STATUS_COLORS,
|
|
||||||
NOTIFICATION_STATUS_LABELS,
|
|
||||||
NOTIFICATION_SERVICIO_LABELS,
|
|
||||||
NOTIFICATION_TYPE_LABELS,
|
|
||||||
} from "@/lib/labels";
|
|
||||||
import {
|
|
||||||
getNotificationStats,
|
|
||||||
listNotificationLog,
|
|
||||||
runAccountStatus,
|
|
||||||
runOutstandingPayments,
|
|
||||||
runPaymentConfirmation,
|
|
||||||
runTrustConfirmation,
|
|
||||||
} from "@/lib/api";
|
|
||||||
import type {
|
|
||||||
NotificationFlags,
|
|
||||||
NotificationJobResponse,
|
|
||||||
NotificationLogPage,
|
|
||||||
NotificationStats,
|
|
||||||
NotificationStatus,
|
|
||||||
} from "@/lib/api";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mass email notifications UI. Manual triggers for the four jobs plus a
|
|
||||||
* paged log browser. The page is gated on `notification:send`; a STAFF
|
|
||||||
* viewer sees the read-only log table but not the trigger buttons.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default function NotificacionesPage() {
|
export default function NotificacionesPage() {
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Notificaciones />
|
<Notificaciones initialTab="servicios" />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobKind = "outstanding" | "payment" | "account" | "trust";
|
|
||||||
|
|
||||||
interface JobDef {
|
|
||||||
kind: JobKind;
|
|
||||||
title: string;
|
|
||||||
endpoint: string;
|
|
||||||
description: string;
|
|
||||||
servicio: "Clientes" | "Fideicomiso";
|
|
||||||
flagsHint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const JOBS: JobDef[] = [
|
|
||||||
{
|
|
||||||
kind: "outstanding",
|
|
||||||
title: "Pagos pendientes",
|
|
||||||
endpoint: "sendOutstandingPaymentAlerts",
|
|
||||||
servicio: "Clientes",
|
|
||||||
description:
|
|
||||||
"Clientes con al menos un movimiento marcado como pendiente (outstanding). Equivale a la columna NOPAGO=1 del antiguo datosfreak.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "payment",
|
|
||||||
title: "Confirmación de pago",
|
|
||||||
endpoint: "sendPaymentConfirmation",
|
|
||||||
servicio: "Clientes",
|
|
||||||
description:
|
|
||||||
"Clientes con un crédito (abono) en las últimas 24 horas. Un correo por cliente con el pago más reciente.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "account",
|
|
||||||
title: "Estado de cuenta",
|
|
||||||
endpoint: "sendAccountStatus",
|
|
||||||
servicio: "Clientes",
|
|
||||||
description:
|
|
||||||
"Alerta amarilla (DEBAJO DEL TIPO) los miércoles y roja (EN ROJO) lunes/miércoles/viernes. El flag ignoreDayRestriction salta los gates.",
|
|
||||||
flagsHint: "Solo este job respeta ignoreDayRestriction y useEmailLimit.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "trust",
|
|
||||||
title: "Confirmación fideicomiso",
|
|
||||||
endpoint: "sendConfirmTrustPayment",
|
|
||||||
servicio: "Fideicomiso",
|
|
||||||
description:
|
|
||||||
"Clientes con TrustAccount que recibieron un crédito en el dominio TRUST en las últimas 24 horas.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function Notificaciones() {
|
|
||||||
const allowed = useCan("notification:send");
|
|
||||||
|
|
||||||
const [flags, setFlags] = useState<NotificationFlags>({ debug: true });
|
|
||||||
const [stats, setStats] = useState<NotificationStats | null>(null);
|
|
||||||
const [log, setLog] = useState<NotificationLogPage | null>(null);
|
|
||||||
const [logFilter, setLogFilter] = useState<{
|
|
||||||
status?: NotificationStatus;
|
|
||||||
view: "all" | "sent" | "failed" | "skipped";
|
|
||||||
}>({ view: "all" });
|
|
||||||
const [logPage, setLogPage] = useState(1);
|
|
||||||
const [busy, setBusy] = useState<JobKind | null>(null);
|
|
||||||
const [lastResult, setLastResult] = useState<NotificationJobResponse | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const [s, l] = await Promise.all([
|
|
||||||
getNotificationStats(),
|
|
||||||
listNotificationLog({
|
|
||||||
page: logPage,
|
|
||||||
pageSize: 50,
|
|
||||||
status: logFilter.status,
|
|
||||||
view: logFilter.view === "all" ? undefined : logFilter.view,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
setStats(s);
|
|
||||||
setLog(l);
|
|
||||||
setError(null);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
}, [logPage, logFilter]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const run = useCallback(
|
|
||||||
async (job: JobDef) => {
|
|
||||||
if (!allowed) return;
|
|
||||||
setBusy(job.kind);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
let res: NotificationJobResponse;
|
|
||||||
if (job.kind === "outstanding") res = await runOutstandingPayments(flags);
|
|
||||||
else if (job.kind === "payment") res = await runPaymentConfirmation(flags);
|
|
||||||
else if (job.kind === "account") res = await runAccountStatus(flags);
|
|
||||||
else res = await runTrustConfirmation(flags);
|
|
||||||
setLastResult(res);
|
|
||||||
await refresh();
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
} finally {
|
|
||||||
setBusy(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[allowed, flags, refresh],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: "grid", gap: 24, padding: 24 }}>
|
|
||||||
<header>
|
|
||||||
<h1 style={{ margin: 0 }}>Notificaciones masivas</h1>
|
|
||||||
<p style={{ color: "#666", marginTop: 4 }}>
|
|
||||||
Disparo manual de los cuatro envíos equivalentes a los scripts PHP
|
|
||||||
de <code>email.notifications/</code>. Cada ejecución registra todas
|
|
||||||
las filas (enviado, fallido, omitido) en <code>email_notification_log</code>.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 16,
|
|
||||||
display: "grid",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>Flags del envío</strong>
|
|
||||||
<label style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!flags.debug}
|
|
||||||
disabled={!allowed}
|
|
||||||
onChange={(e) => setFlags((f) => ({ ...f, debug: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<strong>debug</strong> — reescribe todos los destinatarios a{" "}
|
|
||||||
<code>rmancinas@freakma.net</code>. Ningún cliente real recibe el
|
|
||||||
correo mientras esté activo.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!flags.ignoreDayRestriction}
|
|
||||||
disabled={!allowed}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFlags((f) => ({ ...f, ignoreDayRestriction: e.target.checked }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<strong>ignoreDayRestriction</strong> — salta los gates de
|
|
||||||
Mon/Wed/Fri del estado de cuenta (job 3). Útil para disparar en
|
|
||||||
cualquier día sin esperar a la próxima corrida.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!flags.useEmailLimit}
|
|
||||||
disabled={!allowed}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFlags((f) => ({ ...f, useEmailLimit: e.target.checked }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<strong>useEmailLimit</strong> — pausa el job 3 cada 100 correos
|
|
||||||
durante 1 hora. Vestigio de la era SMTP; SES no lo necesita.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{JOBS.map((j) => (
|
|
||||||
<article
|
|
||||||
key={j.kind}
|
|
||||||
style={{
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 16,
|
|
||||||
display: "grid",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<header style={{ display: "flex", justifyContent: "space-between" }}>
|
|
||||||
<strong>{j.title}</strong>
|
|
||||||
<span style={{ fontSize: 12, color: "#666" }}>{j.servicio}</span>
|
|
||||||
</header>
|
|
||||||
<p style={{ margin: 0, color: "#444", fontSize: 13 }}>{j.description}</p>
|
|
||||||
{j.flagsHint && (
|
|
||||||
<p style={{ margin: 0, color: "#666", fontSize: 12, fontStyle: "italic" }}>
|
|
||||||
{j.flagsHint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!allowed || busy !== null}
|
|
||||||
onClick={() => void run(j)}
|
|
||||||
style={{
|
|
||||||
padding: "8px 12px",
|
|
||||||
background: !allowed || busy !== null ? "#bbb" : "#1f4eaf",
|
|
||||||
color: "#fff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: 6,
|
|
||||||
cursor: !allowed || busy !== null ? "not-allowed" : "pointer",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{busy === j.kind ? "Ejecutando…" : "Ejecutar"}
|
|
||||||
</button>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{!allowed && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#fff8e1",
|
|
||||||
border: "1px solid #f1c40f",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Tu rol no incluye <code>notification:send</code>. Solo puedes ver el
|
|
||||||
registro. Para disparar envíos pide a un MANAGER/ADMIN.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stats && (
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>Estado del transporte</strong>
|
|
||||||
<ul style={{ marginTop: 8, marginBottom: 0 }}>
|
|
||||||
<li>
|
|
||||||
SES configurado:{" "}
|
|
||||||
<strong style={{ color: stats.transport.available ? "#1f7a3a" : "#b3261e" }}>
|
|
||||||
{stats.transport.available ? "sí" : "no"}
|
|
||||||
</strong>
|
|
||||||
{stats.transport.devFallback && " (fallback dev: stdout)"}
|
|
||||||
</li>
|
|
||||||
<li>Último envío registrado: {stats.lastRun ? `${NOTIFICATION_TYPE_LABELS[stats.lastRun.notificationType]} — ${formatDateTime(stats.lastRun.sendDate)}` : "—"}</li>
|
|
||||||
<li>
|
|
||||||
Totales:{" "}
|
|
||||||
{stats.byStatus.map((s) => (
|
|
||||||
<span key={s.status} style={{ marginRight: 12 }}>
|
|
||||||
{NOTIFICATION_STATUS_LABELS[s.status]}: {s._count._all}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{lastResult && (
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
background: "#eef6ff",
|
|
||||||
border: "1px solid #b3d4fc",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>Última respuesta</strong>
|
|
||||||
<pre style={{ margin: 0, fontSize: 12, overflow: "auto" }}>
|
|
||||||
{JSON.stringify(lastResult, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#fdecea",
|
|
||||||
border: "1px solid #b3261e",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
color: "#b3261e",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
||||||
<strong>Registro de envíos</strong>
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
|
||||||
{(["all", "sent", "failed", "skipped"] as const).map((v) => (
|
|
||||||
<button
|
|
||||||
key={v}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setLogFilter({ view: v });
|
|
||||||
setLogPage(1);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: "4px 10px",
|
|
||||||
background: logFilter.view === v ? "#1f4eaf" : "#eee",
|
|
||||||
color: logFilter.view === v ? "#fff" : "#333",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: 4,
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v === "all" ? "Todos" : v === "sent" ? "Enviados" : v === "failed" ? "Fallidos" : "Omitidos"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table style={{ width: "100%", borderCollapse: "collapse", marginTop: 12 }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: "2px solid #ddd" }}>
|
|
||||||
<th style={{ textAlign: "left", padding: 6 }}>Fecha</th>
|
|
||||||
<th style={{ textAlign: "left", padding: 6 }}>Tipo</th>
|
|
||||||
<th style={{ textAlign: "left", padding: 6 }}>Servicio</th>
|
|
||||||
<th style={{ textAlign: "left", padding: 6 }}>Cliente</th>
|
|
||||||
<th style={{ textAlign: "left", padding: 6 }}>Email</th>
|
|
||||||
<th style={{ textAlign: "left", padding: 6 }}>Estado</th>
|
|
||||||
<th style={{ textAlign: "left", padding: 6 }}>Asunto</th>
|
|
||||||
<th style={{ textAlign: "left", padding: 6 }}>Provider</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{log?.items.map((row) => (
|
|
||||||
<tr key={row.id} style={{ borderBottom: "1px solid #eee" }}>
|
|
||||||
<td style={{ padding: 6, fontSize: 12 }}>{formatDateTime(row.sendDate)}</td>
|
|
||||||
<td style={{ padding: 6, fontSize: 12 }}>
|
|
||||||
{NOTIFICATION_TYPE_LABELS[row.notificationType]}
|
|
||||||
{row.level !== null && (row.level === 0 ? " (amarilla)" : " (roja)")}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: 6, fontSize: 12 }}>{NOTIFICATION_SERVICIO_LABELS[row.servicio]}</td>
|
|
||||||
<td style={{ padding: 6, fontSize: 12 }}>{row.customerName}{row.debug ? " · debug" : ""}</td>
|
|
||||||
<td style={{ padding: 6, fontSize: 12 }}>{row.customerEmail}</td>
|
|
||||||
<td style={{ padding: 6, fontSize: 12, color: NOTIFICATION_STATUS_COLORS[row.status] }}>
|
|
||||||
{NOTIFICATION_STATUS_LABELS[row.status]}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: 6, fontSize: 12 }}>{row.subject}</td>
|
|
||||||
<td style={{ padding: 6, fontSize: 11, color: "#666" }}>
|
|
||||||
{row.providerMessageId ?? row.error ?? "—"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{log && log.items.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} style={{ padding: 12, color: "#666", textAlign: "center" }}>
|
|
||||||
Sin envíos con el filtro actual.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{log && log.pageCount > 1 && (
|
|
||||||
<div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
||||||
<span style={{ fontSize: 12, color: "#666" }}>
|
|
||||||
{log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de {log.pageCount}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: 4 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={log.page <= 1}
|
|
||||||
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={log.page >= log.pageCount}
|
|
||||||
onClick={() => setLogPage((p) => Math.min(log.pageCount, p + 1))}
|
|
||||||
>
|
|
||||||
→
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,266 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { useCan } from "@/lib/abilities";
|
import { Notificaciones } from "@/components/Notificaciones";
|
||||||
import { formatDate, formatMoney } from "@/lib/labels";
|
|
||||||
import { apiFetch } from "@/lib/api";
|
|
||||||
|
|
||||||
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 RenewalMarkInput {
|
|
||||||
generation: number;
|
|
||||||
channel: "MAIL" | "EMAIL";
|
|
||||||
sentAt?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** Renewal notices moved into /notificaciones as its "Pólizas" tab. This route
|
||||||
|
* stays so old bookmarks and links land on that tab instead of a 404. */
|
||||||
export default function RenovacionesPage() {
|
export default function RenovacionesPage() {
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Renovaciones />
|
<Notificaciones initialTab="polizas" />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CHANNEL_LABEL: Record<"MAIL" | "EMAIL", string> = {
|
|
||||||
MAIL: "Impreso",
|
|
||||||
EMAIL: "Correo electrónico",
|
|
||||||
};
|
|
||||||
|
|
||||||
function Renovaciones() {
|
|
||||||
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);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMark(letter: RenewalLetter, channel: "MAIL" | "EMAIL") {
|
|
||||||
setActionError(null);
|
|
||||||
setNotice(null);
|
|
||||||
try {
|
|
||||||
await apiFetch(`/policies/${letter.policyId}/renewal-notices`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
generation: letter.generation,
|
|
||||||
channel,
|
|
||||||
} satisfies RenewalMarkInput),
|
|
||||||
});
|
|
||||||
setNotice(`Aviso marcado como enviado (${CHANNEL_LABEL[channel]}).`);
|
|
||||||
await refresh();
|
|
||||||
} catch (e) {
|
|
||||||
setActionError(
|
|
||||||
(e as Error)?.message ?? "No se pudo registrar el aviso.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowed) {
|
|
||||||
return (
|
|
||||||
<div className="page-head">
|
|
||||||
<h1 className="page-title">Renovaciones</h1>
|
|
||||||
<div className="state-box state-error">
|
|
||||||
No tiene permisos para enviar avisos de renovación.
|
|
||||||
</div>
|
|
||||||
</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 className="page-head">
|
|
||||||
<p className="eyebrow">Renovaciones</p>
|
|
||||||
<h1 className="page-title">Avisos de renovación</h1>
|
|
||||||
<p className="muted" style={{ marginTop: 6, maxWidth: 720 }}>
|
|
||||||
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 pantalla muestra qué avisos están
|
|
||||||
pendientes y permite ejecutarlo manualmente.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{actionError && <div className="state-box state-error">{actionError}</div>}
|
|
||||||
{notice && <div className="state-box">{notice}</div>}
|
|
||||||
|
|
||||||
<div className="card" style={{ padding: 20, marginBottom: 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 }}>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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">
|
|
||||||
<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={() => handleMark(item, "EMAIL")}
|
|
||||||
disabled={!item.customerEmail}
|
|
||||||
>
|
|
||||||
Marcar EMAIL
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
onClick={() => handleMark(item, "MAIL")}
|
|
||||||
>
|
|
||||||
Marcar impreso
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ type NavLink = {
|
|||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
ability?: Ability;
|
ability?: Ability;
|
||||||
|
/** Shown when the user holds *any* of these — for a screen that merges two
|
||||||
|
* separately-gated jobs (Notificaciones: servicios + pólizas). */
|
||||||
|
anyAbility?: Ability[];
|
||||||
exact?: boolean;
|
exact?: boolean;
|
||||||
/** Extra path prefixes that belong to this entry (e.g. a second route into
|
/** Extra path prefixes that belong to this entry (e.g. a second route into
|
||||||
* the same screen), so they highlight it instead of nothing. */
|
* the same screen), so they highlight it instead of nothing. */
|
||||||
@@ -78,13 +81,15 @@ const NAV: NavEntry[] = [
|
|||||||
label: "Cuentas de chequera",
|
label: "Cuentas de chequera",
|
||||||
ability: "bank:manage-accounts",
|
ability: "bank:manage-accounts",
|
||||||
},
|
},
|
||||||
|
// Mass email (servicios) and renewal notices (pólizas) are two tabs of
|
||||||
|
// one screen; `/renovaciones` opens the same page on its pólizas tab.
|
||||||
{
|
{
|
||||||
href: "/notificaciones",
|
href: "/notificaciones",
|
||||||
label: "Notificaciones masivas",
|
label: "Notificaciones",
|
||||||
ability: "notification:send",
|
anyAbility: ["notification:send", "renewal:send"],
|
||||||
|
aliases: ["/renovaciones"],
|
||||||
},
|
},
|
||||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||||
{ href: "/renovaciones", label: "Renovaciones", ability: "renewal:send" },
|
|
||||||
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -97,7 +102,9 @@ const NAV_LINKS: NavLink[] = NAV.flatMap((entry) =>
|
|||||||
|
|
||||||
/** The nav the given user may see, with empty groups dropped. */
|
/** The nav the given user may see, with empty groups dropped. */
|
||||||
function visibleNav(user: AuthUser | null): NavEntry[] {
|
function visibleNav(user: AuthUser | null): NavEntry[] {
|
||||||
const allowed = (item: NavLink) => !item.ability || can(user, item.ability);
|
const allowed = (item: NavLink) =>
|
||||||
|
(!item.ability || can(user, item.ability)) &&
|
||||||
|
(!item.anyAbility || item.anyAbility.some((a) => can(user, a)));
|
||||||
const out: NavEntry[] = [];
|
const out: NavEntry[] = [];
|
||||||
for (const entry of NAV) {
|
for (const entry of NAV) {
|
||||||
if (entry.kind === "link") {
|
if (entry.kind === "link") {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { NotificacionesServicios } from "@/components/NotificacionesServicios";
|
||||||
|
import { NotificacionesPolizas } from "@/components/NotificacionesPolizas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notificaciones — one screen, two subsections:
|
||||||
|
*
|
||||||
|
* - **servicios** — the four mass-email jobs against customer ledgers
|
||||||
|
* (pagos pendientes, confirmación de pago, estado de cuenta, fideicomiso).
|
||||||
|
* - **polizas** — renewal notices, 30/15 days before and 7 days after a
|
||||||
|
* policy expires.
|
||||||
|
*
|
||||||
|
* Both are "tell a customer something by email", so they are modes of one
|
||||||
|
* screen rather than two menu entries. `/renovaciones` still resolves here on
|
||||||
|
* the pólizas tab so old bookmarks keep working (same pattern as Captura).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type NotificacionesTab = "servicios" | "polizas";
|
||||||
|
|
||||||
|
const TAB_HINT: Record<NotificacionesTab, string> = {
|
||||||
|
servicios:
|
||||||
|
"Envíos masivos de cobranza y estado de cuenta a los clientes de servicios.",
|
||||||
|
polizas: "Avisos de renovación de pólizas: 30 y 15 días antes, 7 días después.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Notificaciones({
|
||||||
|
initialTab = "servicios",
|
||||||
|
}: {
|
||||||
|
initialTab?: NotificacionesTab;
|
||||||
|
}) {
|
||||||
|
const canNotify = useCan("notification:send");
|
||||||
|
const canRenew = useCan("renewal:send");
|
||||||
|
|
||||||
|
// Gating is cosmetic (the API enforces every send), but a user who only has
|
||||||
|
// one of the two abilities should land on the tab they can actually use.
|
||||||
|
// Servicios stays visible read-only for STAFF, who can browse the log.
|
||||||
|
const tabs: { key: NotificacionesTab; label: string }[] = [
|
||||||
|
{ key: "servicios", label: "Servicios" },
|
||||||
|
...(canRenew ? [{ key: "polizas" as const, label: "Pólizas" }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<NotificacionesTab>(
|
||||||
|
tabs.some((t) => t.key === initialTab) ? initialTab : "servicios",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canNotify && !canRenew) {
|
||||||
|
return (
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tienes permiso para enviar notificaciones.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<p className="eyebrow">Notificaciones</p>
|
||||||
|
<h1 className="page-title">Notificaciones</h1>
|
||||||
|
<p className="muted" style={{ marginTop: 6, maxWidth: 720 }}>
|
||||||
|
{TAB_HINT[tab]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tabs.length > 1 && (
|
||||||
|
<div className="seg" role="tablist" style={{ marginBottom: 20 }}>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === t.key}
|
||||||
|
className={`seg-btn ${tab === t.key ? "active" : ""}`}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "servicios" ? <NotificacionesServicios /> : <NotificacionesPolizas />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"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 run the sweep by
|
||||||
|
* hand or mark a notice as delivered. 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 RenewalMarkInput {
|
||||||
|
generation: number;
|
||||||
|
channel: "MAIL" | "EMAIL";
|
||||||
|
sentAt?: string;
|
||||||
|
notes?: 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)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNEL_LABEL: Record<"MAIL" | "EMAIL", string> = {
|
||||||
|
MAIL: "Impreso",
|
||||||
|
EMAIL: "Correo electrónico",
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMark(letter: RenewalLetter, channel: "MAIL" | "EMAIL") {
|
||||||
|
setActionError(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
await apiFetch(`/policies/${letter.policyId}/renewal-notices`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
generation: letter.generation,
|
||||||
|
channel,
|
||||||
|
} satisfies RenewalMarkInput),
|
||||||
|
});
|
||||||
|
setNotice(`Aviso marcado como enviado (${CHANNEL_LABEL[channel]}).`);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setActionError((e as Error)?.message ?? "No se pudo registrar el aviso.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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={() => handleMark(item, "EMAIL")}
|
||||||
|
disabled={!item.customerEmail}
|
||||||
|
>
|
||||||
|
Marcar EMAIL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
onClick={() => handleMark(item, "MAIL")}
|
||||||
|
>
|
||||||
|
Marcar impreso
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { formatDateTime } from "@/lib/labels";
|
||||||
|
import {
|
||||||
|
NOTIFICATION_STATUS_COLORS,
|
||||||
|
NOTIFICATION_STATUS_LABELS,
|
||||||
|
NOTIFICATION_SERVICIO_LABELS,
|
||||||
|
NOTIFICATION_TYPE_LABELS,
|
||||||
|
} from "@/lib/labels";
|
||||||
|
import {
|
||||||
|
getNotificationStats,
|
||||||
|
listNotificationLog,
|
||||||
|
runAccountStatus,
|
||||||
|
runOutstandingPayments,
|
||||||
|
runPaymentConfirmation,
|
||||||
|
runTrustConfirmation,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
NotificationFlags,
|
||||||
|
NotificationJobResponse,
|
||||||
|
NotificationLogPage,
|
||||||
|
NotificationStats,
|
||||||
|
NotificationStatus,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mass email notifications — the "Servicios" half of /notificaciones. Manual
|
||||||
|
* triggers for the four jobs plus a paged log browser. Gated on
|
||||||
|
* `notification:send`; a STAFF viewer sees the read-only log table but not the
|
||||||
|
* trigger buttons.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type JobKind = "outstanding" | "payment" | "account" | "trust";
|
||||||
|
|
||||||
|
interface JobDef {
|
||||||
|
kind: JobKind;
|
||||||
|
title: string;
|
||||||
|
endpoint: string;
|
||||||
|
description: string;
|
||||||
|
servicio: "Clientes" | "Fideicomiso";
|
||||||
|
flagsHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JOBS: JobDef[] = [
|
||||||
|
{
|
||||||
|
kind: "outstanding",
|
||||||
|
title: "Pagos pendientes",
|
||||||
|
endpoint: "sendOutstandingPaymentAlerts",
|
||||||
|
servicio: "Clientes",
|
||||||
|
description:
|
||||||
|
"Clientes con al menos un movimiento marcado como pendiente (outstanding). Equivale a la columna NOPAGO=1 del antiguo datosfreak.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "payment",
|
||||||
|
title: "Confirmación de pago",
|
||||||
|
endpoint: "sendPaymentConfirmation",
|
||||||
|
servicio: "Clientes",
|
||||||
|
description:
|
||||||
|
"Clientes con un crédito (abono) en las últimas 24 horas. Un correo por cliente con el pago más reciente.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "account",
|
||||||
|
title: "Estado de cuenta",
|
||||||
|
endpoint: "sendAccountStatus",
|
||||||
|
servicio: "Clientes",
|
||||||
|
description:
|
||||||
|
"Alerta amarilla (DEBAJO DEL TIPO) los miércoles y roja (EN ROJO) lunes/miércoles/viernes. El flag ignoreDayRestriction salta los gates.",
|
||||||
|
flagsHint: "Solo este job respeta ignoreDayRestriction y useEmailLimit.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "trust",
|
||||||
|
title: "Confirmación fideicomiso",
|
||||||
|
endpoint: "sendConfirmTrustPayment",
|
||||||
|
servicio: "Fideicomiso",
|
||||||
|
description:
|
||||||
|
"Clientes con TrustAccount que recibieron un crédito en el dominio TRUST en las últimas 24 horas.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOG_VIEWS = [
|
||||||
|
{ key: "all", label: "Todos" },
|
||||||
|
{ key: "sent", label: "Enviados" },
|
||||||
|
{ key: "failed", label: "Fallidos" },
|
||||||
|
{ key: "skipped", label: "Omitidos" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function NotificacionesServicios() {
|
||||||
|
const allowed = useCan("notification:send");
|
||||||
|
|
||||||
|
const [flags, setFlags] = useState<NotificationFlags>({ debug: true });
|
||||||
|
const [stats, setStats] = useState<NotificationStats | null>(null);
|
||||||
|
const [log, setLog] = useState<NotificationLogPage | null>(null);
|
||||||
|
const [logFilter, setLogFilter] = useState<{
|
||||||
|
status?: NotificationStatus;
|
||||||
|
view: "all" | "sent" | "failed" | "skipped";
|
||||||
|
}>({ view: "all" });
|
||||||
|
const [logPage, setLogPage] = useState(1);
|
||||||
|
const [busy, setBusy] = useState<JobKind | null>(null);
|
||||||
|
const [lastResult, setLastResult] = useState<NotificationJobResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [s, l] = await Promise.all([
|
||||||
|
getNotificationStats(),
|
||||||
|
listNotificationLog({
|
||||||
|
page: logPage,
|
||||||
|
pageSize: 50,
|
||||||
|
status: logFilter.status,
|
||||||
|
view: logFilter.view === "all" ? undefined : logFilter.view,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
setStats(s);
|
||||||
|
setLog(l);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}, [logPage, logFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const run = useCallback(
|
||||||
|
async (job: JobDef) => {
|
||||||
|
if (!allowed) return;
|
||||||
|
setBusy(job.kind);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
let res: NotificationJobResponse;
|
||||||
|
if (job.kind === "outstanding") res = await runOutstandingPayments(flags);
|
||||||
|
else if (job.kind === "payment") res = await runPaymentConfirmation(flags);
|
||||||
|
else if (job.kind === "account") res = await runAccountStatus(flags);
|
||||||
|
else res = await runTrustConfirmation(flags);
|
||||||
|
setLastResult(res);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[allowed, flags, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "grid", gap: 20 }}>
|
||||||
|
<p className="muted" style={{ maxWidth: 760, margin: 0 }}>
|
||||||
|
Disparo manual de los cuatro envíos equivalentes a los scripts PHP de{" "}
|
||||||
|
<code>email.notifications/</code>. Cada ejecución registra todas las filas
|
||||||
|
(enviado, fallido, omitido) en <code>email_notification_log</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!allowed && (
|
||||||
|
<div className="empty-inline">
|
||||||
|
Tu rol no incluye <code>notification:send</code>. Solo puedes ver el
|
||||||
|
registro. Para disparar envíos pide a un MANAGER/ADMIN.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
|
||||||
|
<section className="card" style={{ padding: 20 }}>
|
||||||
|
<h2 className="section-title">Flags del envío</h2>
|
||||||
|
<div style={{ display: "grid", gap: 4, marginTop: 12 }}>
|
||||||
|
<label
|
||||||
|
className="field"
|
||||||
|
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!flags.debug}
|
||||||
|
disabled={!allowed}
|
||||||
|
onChange={(e) => setFlags((f) => ({ ...f, debug: e.target.checked }))}
|
||||||
|
style={{ marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<span className="small">
|
||||||
|
<strong>debug</strong> — reescribe todos los destinatarios a{" "}
|
||||||
|
<code>rmancinas@freakma.net</code>. Ningún cliente real recibe el
|
||||||
|
correo mientras esté activo.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className="field"
|
||||||
|
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!flags.ignoreDayRestriction}
|
||||||
|
disabled={!allowed}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFlags((f) => ({ ...f, ignoreDayRestriction: e.target.checked }))
|
||||||
|
}
|
||||||
|
style={{ marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<span className="small">
|
||||||
|
<strong>ignoreDayRestriction</strong> — salta los gates de
|
||||||
|
Mon/Wed/Fri del estado de cuenta. Útil para disparar en cualquier
|
||||||
|
día sin esperar a la próxima corrida.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className="field"
|
||||||
|
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!flags.useEmailLimit}
|
||||||
|
disabled={!allowed}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFlags((f) => ({ ...f, useEmailLimit: e.target.checked }))
|
||||||
|
}
|
||||||
|
style={{ marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<span className="small">
|
||||||
|
<strong>useEmailLimit</strong> — pausa el estado de cuenta cada 100
|
||||||
|
correos durante 1 hora. Vestigio de la era SMTP; SES no lo necesita.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{JOBS.map((j) => (
|
||||||
|
<article
|
||||||
|
key={j.kind}
|
||||||
|
className="card"
|
||||||
|
style={{ padding: 18, display: "grid", gap: 8, alignContent: "start" }}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{j.title}</strong>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
j.servicio === "Fideicomiso"
|
||||||
|
? "badge badge-fideicomiso"
|
||||||
|
: "badge badge-servicios"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="dot" />
|
||||||
|
{j.servicio}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<p className="muted small" style={{ margin: 0 }}>
|
||||||
|
{j.description}
|
||||||
|
</p>
|
||||||
|
{j.flagsHint && (
|
||||||
|
<p className="section-note" style={{ margin: 0, fontStyle: "italic" }}>
|
||||||
|
{j.flagsHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="row-actions" style={{ marginTop: 4 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={!allowed || busy !== null}
|
||||||
|
onClick={() => void run(j)}
|
||||||
|
>
|
||||||
|
{busy === j.kind ? "Ejecutando…" : "Ejecutar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<section className="card" style={{ padding: 20 }}>
|
||||||
|
<h2 className="section-title">Estado del transporte</h2>
|
||||||
|
<ul className="small" style={{ marginTop: 10, marginBottom: 0, paddingLeft: 18 }}>
|
||||||
|
<li>
|
||||||
|
SES configurado:{" "}
|
||||||
|
<strong
|
||||||
|
style={{
|
||||||
|
color: stats.transport.available
|
||||||
|
? "var(--positive)"
|
||||||
|
: "var(--negative)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stats.transport.available ? "sí" : "no"}
|
||||||
|
</strong>
|
||||||
|
{stats.transport.devFallback && " (fallback dev: stdout)"}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Último envío registrado:{" "}
|
||||||
|
{stats.lastRun
|
||||||
|
? `${NOTIFICATION_TYPE_LABELS[stats.lastRun.notificationType]} — ${formatDateTime(stats.lastRun.sendDate)}`
|
||||||
|
: "—"}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Totales:{" "}
|
||||||
|
{stats.byStatus.map((s) => (
|
||||||
|
<span key={s.status} style={{ marginRight: 12 }}>
|
||||||
|
{NOTIFICATION_STATUS_LABELS[s.status]}: {s._count._all}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lastResult && (
|
||||||
|
<section className="card" style={{ padding: 20 }}>
|
||||||
|
<h2 className="section-title">Última respuesta</h2>
|
||||||
|
<pre
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
margin: "10px 0 0",
|
||||||
|
fontSize: 12,
|
||||||
|
overflow: "auto",
|
||||||
|
background: "var(--surface-2)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
padding: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{JSON.stringify(lastResult, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="card" style={{ padding: 20 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 className="section-title">Registro de envíos</h2>
|
||||||
|
<div className="seg" role="tablist">
|
||||||
|
{LOG_VIEWS.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={logFilter.view === v.key}
|
||||||
|
className={`seg-btn ${logFilter.view === v.key ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setLogFilter({ view: v.key });
|
||||||
|
setLogPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tx-scroll" style={{ marginTop: 12 }}>
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Servicio</th>
|
||||||
|
<th>Cliente</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Asunto</th>
|
||||||
|
<th>Provider</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{log?.items.map((row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td>{formatDateTime(row.sendDate)}</td>
|
||||||
|
<td>
|
||||||
|
{NOTIFICATION_TYPE_LABELS[row.notificationType]}
|
||||||
|
{row.level !== null && (row.level === 0 ? " (amarilla)" : " (roja)")}
|
||||||
|
</td>
|
||||||
|
<td>{NOTIFICATION_SERVICIO_LABELS[row.servicio]}</td>
|
||||||
|
<td>
|
||||||
|
{row.customerName}
|
||||||
|
{row.debug ? " · debug" : ""}
|
||||||
|
</td>
|
||||||
|
<td>{row.customerEmail}</td>
|
||||||
|
<td style={{ color: NOTIFICATION_STATUS_COLORS[row.status] }}>
|
||||||
|
{NOTIFICATION_STATUS_LABELS[row.status]}
|
||||||
|
</td>
|
||||||
|
<td>{row.subject}</td>
|
||||||
|
<td className="muted small">
|
||||||
|
{row.providerMessageId ?? row.error ?? "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{log && log.items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8}>
|
||||||
|
<span className="empty-inline">
|
||||||
|
Sin envíos con el filtro actual.
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{log && log.pageCount > 1 && (
|
||||||
|
<div className="pager" style={{ marginTop: 14 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
disabled={log.page <= 1}
|
||||||
|
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
← Anterior
|
||||||
|
</button>
|
||||||
|
<span className="pager-info">
|
||||||
|
{log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de{" "}
|
||||||
|
{log.pageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
disabled={log.page >= log.pageCount}
|
||||||
|
onClick={() => setLogPage((p) => Math.min(log.pageCount, p + 1))}
|
||||||
|
>
|
||||||
|
Siguiente →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -408,8 +408,8 @@ export const NOTIFICATION_STATUS_LABELS: Record<NotificationStatus, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const NOTIFICATION_STATUS_COLORS: Record<NotificationStatus, string> = {
|
export const NOTIFICATION_STATUS_COLORS: Record<NotificationStatus, string> = {
|
||||||
SENT: "#1f7a3a",
|
SENT: "var(--positive)",
|
||||||
FAILED: "#b3261e",
|
FAILED: "var(--negative)",
|
||||||
SKIPPED_NO_EMAIL: "#666",
|
SKIPPED_NO_EMAIL: "var(--muted)",
|
||||||
SKIPPED_GATE: "#888",
|
SKIPPED_GATE: "var(--muted-2)",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user