diff --git a/apps/api/src/mail/mail.module.ts b/apps/api/src/mail/mail.module.ts index 6dcc162..f0320b5 100644 --- a/apps/api/src/mail/mail.module.ts +++ b/apps/api/src/mail/mail.module.ts @@ -1,12 +1,13 @@ -import { Module } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; +import { Global, Module } from "@nestjs/common"; import { MailService } from "./mail.service"; /** Global so any feature module can inject MailService without re-importing. * 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({ - providers: [{ provide: MailService, useFactory: (c: ConfigService) => new MailService(c) }], + providers: [MailService], exports: [MailService], }) export class MailModule {} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 17d5aa7..e16a94b 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -212,6 +212,11 @@ button { color: var(--muted); } +/* Secondary line inside a row or card — used alongside .muted throughout. */ +.small { + font-size: 0.8125rem; +} + /* ============================================================================ App shell / top nav ========================================================================== */ diff --git a/apps/web/src/app/notificaciones/page.tsx b/apps/web/src/app/notificaciones/page.tsx index 319a92d..2b9e047 100644 --- a/apps/web/src/app/notificaciones/page.tsx +++ b/apps/web/src/app/notificaciones/page.tsx @@ -1,444 +1,12 @@ "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. - */ +import { Notificaciones } from "@/components/Notificaciones"; 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 && ( - - - - )} - -
FechaTipoServicioClienteEmailEstadoAsuntoProvider
{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} - -
- - -
-
- )} -
-
- ); -} diff --git a/apps/web/src/app/renovaciones/page.tsx b/apps/web/src/app/renovaciones/page.tsx index 9c5d6f2..ccb9856 100644 --- a/apps/web/src/app/renovaciones/page.tsx +++ b/apps/web/src/app/renovaciones/page.tsx @@ -1,266 +1,14 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; import { AppShell } from "@/components/AppShell"; -import { useCan } from "@/lib/abilities"; -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; -} +import { Notificaciones } from "@/components/Notificaciones"; +/** 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() { return ( - + ); } - -const GENERATION_LABEL: Record = { - 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(null); - const [pendingError, setPendingError] = useState(null); - const [actionError, setActionError] = useState(null); - const [notice, setNotice] = useState(null); - const [sweeping, setSweeping] = useState(false); - - const refresh = useCallback(async () => { - setPendingError(null); - try { - const data = await apiFetch( - `/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("/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 ( -
-

Renovaciones

-
- No tiene permisos para enviar avisos de renovación. -
-
- ); - } - - const counts = (pending ?? []).reduce>( - (acc, item) => ({ - ...acc, - [item.generation]: (acc[item.generation] ?? 0) + 1, - }), - {}, - ); - const grouped = [1, 2, 3].filter((gen) => (counts[gen] ?? 0) > 0); - - return ( - <> -
-

Renovaciones

-

Avisos de renovación

-

- 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. -

-
- - {actionError &&
{actionError}
} - {notice &&
{notice}
} - -
-
-
-

Barrido manual

-

- Usa la fecha actual del servidor como referencia para seleccionar - avisos vencidos a 30 y 15 días, y vencidos hace 7 días. -

-
- -
-
- Ventana (días) - - setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30))) - } - /> -
-
- - {pendingError && ( -
{pendingError}
- )} - - {!pendingError && grouped.length === 0 && ( -
- No hay avisos pendientes en esta ventana. -
- )} - - {grouped.map((generation) => ( -
-

{GENERATION_LABEL[generation]}

-
- - - - - - - - - - - - - - {(pending ?? []) - .filter((item) => item.generation === generation) - .map((item) => ( - - - - - - - - - - ))} - -
ClientePólizaTipoAseguradoraVencePrimaAcciones
-
{item.customerName}
-
- {item.customerEmail ?? "Sin correo"} -
-
{item.policyNumber}{item.policyType}{item.provider}{formatDate(item.policyTo)} - {formatMoney(item.total ?? item.netPremium, item.currency)} - -
- - -
-
-
-
- ))} - - ); -} diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index a1c1941..589fb80 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -28,6 +28,9 @@ type NavLink = { href: string; label: string; 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; /** Extra path prefixes that belong to this entry (e.g. a second route into * the same screen), so they highlight it instead of nothing. */ @@ -78,13 +81,15 @@ const NAV: NavEntry[] = [ label: "Cuentas de chequera", 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", - label: "Notificaciones masivas", - ability: "notification:send", + label: "Notificaciones", + anyAbility: ["notification:send", "renewal:send"], + aliases: ["/renovaciones"], }, { href: "/usuarios", label: "Usuarios", ability: "user:manage" }, - { href: "/renovaciones", label: "Renovaciones", ability: "renewal:send" }, { 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. */ 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[] = []; for (const entry of NAV) { if (entry.kind === "link") { diff --git a/apps/web/src/components/Notificaciones.tsx b/apps/web/src/components/Notificaciones.tsx new file mode 100644 index 0000000..624d1f8 --- /dev/null +++ b/apps/web/src/components/Notificaciones.tsx @@ -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 = { + 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( + tabs.some((t) => t.key === initialTab) ? initialTab : "servicios", + ); + + if (!canNotify && !canRenew) { + return ( +
+ No tienes permiso para enviar notificaciones. +
+ ); + } + + return ( + <> +
+

Notificaciones

+

Notificaciones

+

+ {TAB_HINT[tab]} +

+
+ + {tabs.length > 1 && ( +
+ {tabs.map((t) => ( + + ))} +
+ )} + + {tab === "servicios" ? : } + + ); +} diff --git a/apps/web/src/components/NotificacionesPolizas.tsx b/apps/web/src/components/NotificacionesPolizas.tsx new file mode 100644 index 0000000..c3e4721 --- /dev/null +++ b/apps/web/src/components/NotificacionesPolizas.tsx @@ -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 = { + 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(null); + const [pendingError, setPendingError] = useState(null); + const [actionError, setActionError] = useState(null); + const [notice, setNotice] = useState(null); + const [sweeping, setSweeping] = useState(false); + + const refresh = useCallback(async () => { + setPendingError(null); + try { + const data = await apiFetch( + `/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("/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 ( +
+ No tiene permisos para enviar avisos de renovación. +
+ ); + } + + const counts = (pending ?? []).reduce>( + (acc, item) => ({ + ...acc, + [item.generation]: (acc[item.generation] ?? 0) + 1, + }), + {}, + ); + const grouped = [1, 2, 3].filter((gen) => (counts[gen] ?? 0) > 0); + + return ( +
+

+ 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. +

+ + {actionError &&
{actionError}
} + {notice &&
{notice}
} + +
+
+
+

Barrido manual

+

+ Usa la fecha actual del servidor como referencia para seleccionar + avisos vencidos a 30 y 15 días, y vencidos hace 7 días. +

+
+ +
+
+ Ventana (días) + + setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30))) + } + /> +
+
+ + {pendingError &&
{pendingError}
} + + {!pendingError && grouped.length === 0 && ( +
+ No hay avisos pendientes en esta ventana. +
+ )} + + {grouped.map((generation) => ( +
+

{GENERATION_LABEL[generation]}

+
+ + + + + + + + + + + + + + {(pending ?? []) + .filter((item) => item.generation === generation) + .map((item) => ( + + + + + + + + + + ))} + +
ClientePólizaTipoAseguradoraVencePrimaAcciones
+
{item.customerName}
+
+ {item.customerEmail ?? "Sin correo"} +
+
{item.policyNumber}{item.policyType}{item.provider}{formatDate(item.policyTo)} + {formatMoney(item.total ?? item.netPremium, item.currency)} + +
+ + +
+
+
+
+ ))} +
+ ); +} diff --git a/apps/web/src/components/NotificacionesServicios.tsx b/apps/web/src/components/NotificacionesServicios.tsx new file mode 100644 index 0000000..eb7e3c4 --- /dev/null +++ b/apps/web/src/components/NotificacionesServicios.tsx @@ -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({ 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 ( +
+

+ 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. +

+ + {!allowed && ( +
+ Tu rol no incluye notification:send. Solo puedes ver el + registro. Para disparar envíos pide a un MANAGER/ADMIN. +
+ )} + + {error &&
{error}
} + +
+

Flags del envío

+
+ + + +
+
+ +
+ {JOBS.map((j) => ( +
+
+ {j.title} + + + {j.servicio} + +
+

+ {j.description} +

+ {j.flagsHint && ( +

+ {j.flagsHint} +

+ )} +
+ +
+
+ ))} +
+ + {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)}
+          
+
+ )} + +
+
+

Registro de envíos

+
+ {LOG_VIEWS.map((v) => ( + + ))} +
+
+ +
+ + + + + + + + + + + + + + + {log?.items.map((row) => ( + + + + + + + + + + + ))} + {log && log.items.length === 0 && ( + + + + )} + +
FechaTipoServicioClienteEmailEstadoAsuntoProvider
{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} + + +
+ )} +
+
+ ); +} diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts index dbe212c..a5307a2 100644 --- a/apps/web/src/lib/labels.ts +++ b/apps/web/src/lib/labels.ts @@ -408,8 +408,8 @@ export const NOTIFICATION_STATUS_LABELS: Record = { }; export const NOTIFICATION_STATUS_COLORS: Record = { - SENT: "#1f7a3a", - FAILED: "#b3261e", - SKIPPED_NO_EMAIL: "#666", - SKIPPED_GATE: "#888", + SENT: "var(--positive)", + FAILED: "var(--negative)", + SKIPPED_NO_EMAIL: "var(--muted)", + SKIPPED_GATE: "var(--muted-2)", };