Renewal avisos left behind only a `RenewalNotice` row, whose sole job is gating: a row with `sentAt` drops the policy off the pending list. It cannot represent a failed send or a customer with no address, so the Pólizas tab had no "Registro de envíos" to show and a sent notice simply vanished from the list. Renewals now write `email_notification_log` — the same table the four bulk jobs write — as `RENEWAL_NOTICE` / `POLICIES`, with rows for failures and no-email skips too. `RenewalNotice` keeps its gating role unchanged; the two are complementary, not redundant. - extend `EmailNotificationType` (+RENEWAL_NOTICE) and `EmailNotificationServicio` (+POLICIES); `level` now carries the aviso generation on renewal rows, so every reader must branch on the type first (`notificationLevelLabel()` is the one place that lives) - backfill emailed notices (`channel = 'EMAIL'`) into the log; MAIL-channel rows are legacy printed letters and are deliberately left out - extract `NotificationLogService`/`NotificationLogModule` as the single writer, so a feature that sends mail records it without pulling the bulk-job pipelines into its module - `GET /notifications/log` and `/stats` take a comma-separated `servicio` list; each tab reads its own slice. This also fixes the "Omitidos" view, which mapped to no filter at all and showed every row - share one `NotificationLogPanel` between both tabs - pass SES_* / NOTIFICATION_ADMIN_EMAILS through the galactus compose, which was missing them entirely — mail is runtime config, not a CI secret, and the prod image sets NODE_ENV=production so a blank config fails loudly instead of falling back to stdout Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
5.6 KiB
TypeScript
187 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import {
|
|
listNotificationLog,
|
|
type NotificationLogPage,
|
|
type NotificationServicio,
|
|
} from "@/lib/api";
|
|
import {
|
|
formatDateTime,
|
|
NOTIFICATION_SERVICIO_LABELS,
|
|
NOTIFICATION_STATUS_COLORS,
|
|
NOTIFICATION_STATUS_LABELS,
|
|
NOTIFICATION_TYPE_LABELS,
|
|
notificationLevelLabel,
|
|
} from "@/lib/labels";
|
|
|
|
/**
|
|
* "Registro de envíos" — the send history over `email_notification_log`.
|
|
*
|
|
* Every outbound email the platform sends writes to that one table (the four
|
|
* bulk jobs and the renewal avisos alike), so this component is shared by
|
|
* both /notificaciones tabs; each passes the `servicio` slice it owns. Rows
|
|
* cover failures and skips too, which is the whole point: a notice that never
|
|
* left is invisible everywhere else.
|
|
*/
|
|
|
|
const LOG_VIEWS = [
|
|
{ key: "all", label: "Todos" },
|
|
{ key: "sent", label: "Enviados" },
|
|
{ key: "failed", label: "Fallidos" },
|
|
{ key: "skipped", label: "Omitidos" },
|
|
] as const;
|
|
|
|
export type LogView = (typeof LOG_VIEWS)[number]["key"];
|
|
|
|
export function NotificationLogPanel({
|
|
servicio,
|
|
emptyHint = "Sin envíos con el filtro actual.",
|
|
/** Bump to force a reload — the parent raises it after a send. */
|
|
reloadToken = 0,
|
|
}: {
|
|
servicio: NotificationServicio[];
|
|
emptyHint?: string;
|
|
reloadToken?: number;
|
|
}) {
|
|
const [log, setLog] = useState<NotificationLogPage | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [view, setView] = useState<LogView>("all");
|
|
const [page, setPage] = useState(1);
|
|
|
|
// `servicio` is a literal array at every call site, so a new identity each
|
|
// render would re-fetch forever. Key the effect on its contents instead.
|
|
const servicioKey = servicio.join(",");
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
const data = await listNotificationLog({
|
|
page,
|
|
pageSize: 50,
|
|
servicio: servicioKey.split(",") as NotificationServicio[],
|
|
view: view === "all" ? undefined : view,
|
|
});
|
|
setLog(data);
|
|
setError(null);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
}
|
|
}, [page, view, servicioKey]);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
}, [refresh, reloadToken]);
|
|
|
|
return (
|
|
<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={view === v.key}
|
|
className={`seg-btn ${view === v.key ? "active" : ""}`}
|
|
onClick={() => {
|
|
setView(v.key);
|
|
setPage(1);
|
|
}}
|
|
>
|
|
{v.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
|
{error}
|
|
</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]}
|
|
{notificationLevelLabel(row.notificationType, row.level)}
|
|
</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">{emptyHint}</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={() => setPage((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={() => setPage((p) => Math.min(log.pageCount, p + 1))}
|
|
>
|
|
Siguiente →
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|