"use client"; import { useCallback, useEffect, useState } from "react"; import { AppShell } from "@/components/AppShell"; 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 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() { return ( ); } 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({ debug: true }); const [stats, setStats] = useState(null); const [log, setLog] = useState(null); const [logFilter, setLogFilter] = useState<{ status?: NotificationStatus; view: "all" | "sent" | "failed" | "skipped"; }>({ view: "all" }); const [logPage, setLogPage] = useState(1); const [busy, setBusy] = useState(null); const [lastResult, setLastResult] = useState(null); const [error, setError] = useState(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 (

Notificaciones masivas

Disparo manual de los cuatro envíos equivalentes a los scripts PHP de email.notifications/. Cada ejecución registra todas las filas (enviado, fallido, omitido) en email_notification_log.

Flags del envío
{JOBS.map((j) => (
{j.title} {j.servicio}

{j.description}

{j.flagsHint && (

{j.flagsHint}

)}
))}
{!allowed && (
Tu rol no incluye notification:send. Solo puedes ver el registro. Para disparar envíos pide a un MANAGER/ADMIN.
)} {stats && (
Estado del transporte
  • SES configurado:{" "} {stats.transport.available ? "sí" : "no"} {stats.transport.devFallback && " (fallback dev: stdout)"}
  • Último envío registrado: {stats.lastRun ? `${NOTIFICATION_TYPE_LABELS[stats.lastRun.notificationType]} — ${formatDateTime(stats.lastRun.sendDate)}` : "—"}
  • Totales:{" "} {stats.byStatus.map((s) => ( {NOTIFICATION_STATUS_LABELS[s.status]}: {s._count._all} ))}
)} {lastResult && (
Última respuesta
            {JSON.stringify(lastResult, null, 2)}
          
)} {error && (
{error}
)}
Registro de envíos
{(["all", "sent", "failed", "skipped"] as const).map((v) => ( ))}
{log?.items.map((row) => ( ))} {log && log.items.length === 0 && ( )}
Fecha Tipo Servicio Cliente Email Estado Asunto Provider
{formatDateTime(row.sendDate)} {NOTIFICATION_TYPE_LABELS[row.notificationType]} {row.level !== null && (row.level === 0 ? " (amarilla)" : " (roja)")} {NOTIFICATION_SERVICIO_LABELS[row.servicio]} {row.customerName}{row.debug ? " · debug" : ""} {row.customerEmail} {NOTIFICATION_STATUS_LABELS[row.status]} {row.subject} {row.providerMessageId ?? row.error ?? "—"}
Sin envíos con el filtro actual.
{log && log.pageCount > 1 && (
{log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de {log.pageCount}
)}
); }