From 5565eef554411925fd0eed06bbf346f57deaa44b Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:09:05 -0500 Subject: [PATCH] feat: payments module, notification templates, WA float, route fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PaymentsModule: POST /payments/intent, POST /:id/confirm, GET /payments, GET /payments/package/:id, GET /payments/track/:trackingId - Add Payment model to Prisma schema (PaymentStatus enum, Payment table) - Add NotificationTemplate model + NotificationsController (GET/PUT /notification-templates, POST /notification-templates/seed) - Update NotificationsService: DB-backed templates with variable interpolation {{trackingId}} {{firstName}} {{status}} {{suiteCode}} - Fix /bodega/paquetes: replace 11 wrong status strings with correct §08 enum values - Fix /admin/reportes: replace EN_CAMINO_A_ECUADOR with correct §08 statuses, rewrite report page with proper KPIs and bar charts - Fix /portal/mis-paquetes: correct §08 statuses, add 'Pagar envío' button for VERIFICADO/DECLARACION_ADUANERA packages - Add WhatsApp float component (_components/whatsapp-float.tsx, 2 contacts: NJ ops + Cuenca aduana) - Add /casillero/calculadora and /casillero/registro redirects (§20) - Add /portal/pago payment page with cost breakdown (§09/§14/§15) - Add /admin/notificaciones page: view/edit/toggle templates per event×channel - Admin nav: add Notificaciones link - api.ts: add notificationTemplates.* and payments.* client methods - schema.prisma v0.4: PaymentStatus enum, Payment model, NotificationTemplate model - db push applied to remote DB (46.202.93.92) - All builds pass (API nest build + Next.js build) --- apps/api/src/app.module.ts | 2 + .../notifications/notifications.controller.ts | 46 +++ .../src/notifications/notifications.module.ts | 2 + .../notifications/notifications.service.ts | 148 +++++++-- apps/api/src/payments/payments.controller.ts | 48 +++ apps/api/src/payments/payments.module.ts | 12 + apps/api/src/payments/payments.service.ts | 119 +++++++ .../src/app/_components/whatsapp-float.tsx | 93 ++++++ apps/web/src/app/admin/layout.tsx | 15 +- .../web/src/app/admin/notificaciones/page.tsx | 307 ++++++++++++++++++ apps/web/src/app/admin/reportes/page.tsx | 120 +++++-- apps/web/src/app/bodega/paquetes/page.tsx | 15 +- .../src/app/casillero/calculadora/page.tsx | 6 + apps/web/src/app/casillero/registro/page.tsx | 6 + apps/web/src/app/globals.css | 93 ++++++ apps/web/src/app/page.tsx | 2 + apps/web/src/app/portal/mis-paquetes/page.tsx | 115 +++++-- apps/web/src/app/portal/pago/page.tsx | 199 ++++++++++++ apps/web/src/lib/api.ts | 15 + packages/database/prisma/schema.prisma | 60 ++++ 20 files changed, 1326 insertions(+), 97 deletions(-) create mode 100644 apps/api/src/notifications/notifications.controller.ts create mode 100644 apps/api/src/payments/payments.controller.ts create mode 100644 apps/api/src/payments/payments.module.ts create mode 100644 apps/api/src/payments/payments.service.ts create mode 100644 apps/web/src/app/_components/whatsapp-float.tsx create mode 100644 apps/web/src/app/admin/notificaciones/page.tsx create mode 100644 apps/web/src/app/casillero/calculadora/page.tsx create mode 100644 apps/web/src/app/casillero/registro/page.tsx create mode 100644 apps/web/src/app/portal/pago/page.tsx diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ec45f6b..3279d3b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -16,6 +16,7 @@ import { TariffsModule } from "./tariffs/tariffs.module"; import { ProductsModule } from "./products/products.module"; import { WarehousesModule } from "./warehouses/warehouses.module"; import { IntegrationsModule } from "./integrations/integrations.module"; +import { PaymentsModule } from "./payments/payments.module"; @Module({ imports: [ @@ -39,6 +40,7 @@ import { IntegrationsModule } from "./integrations/integrations.module"; ProductsModule, WarehousesModule, IntegrationsModule, + PaymentsModule, ], }) export class AppModule {} diff --git a/apps/api/src/notifications/notifications.controller.ts b/apps/api/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..adb26cc --- /dev/null +++ b/apps/api/src/notifications/notifications.controller.ts @@ -0,0 +1,46 @@ +import { + Controller, + Get, + Put, + Post, + Body, + Param, + UseGuards, + Request, +} from "@nestjs/common"; +import { JwtAuthGuard } from "../auth/guards/auth.guard"; +import { NotificationsService } from "./notifications.service"; + +class UpdateTemplateDto { + body!: string; + subject?: string; + isActive?: boolean; +} + +@Controller("notification-templates") +@UseGuards(JwtAuthGuard) +export class NotificationsController { + constructor(private readonly svc: NotificationsService) {} + + /** GET /notification-templates — lista plantillas del tenant */ + @Get() + list(@Request() req: any) { + return this.svc.getTemplates(req.user.tenantId); + } + + /** PUT /notification-templates/:id — actualiza asunto/cuerpo/estado */ + @Put(":id") + update( + @Param("id") id: string, + @Body() dto: UpdateTemplateDto, + @Request() req: any, + ) { + return this.svc.updateTemplate(id, req.user.tenantId, dto.body, dto.subject, dto.isActive); + } + + /** POST /notification-templates/seed — crea plantillas por defecto (idempotente) */ + @Post("seed") + seed(@Request() req: any) { + return this.svc.seedDefaultTemplates(req.user.tenantId); + } +} diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts index f8b2bcf..7c7ab03 100644 --- a/apps/api/src/notifications/notifications.module.ts +++ b/apps/api/src/notifications/notifications.module.ts @@ -1,9 +1,11 @@ import { Module } from "@nestjs/common"; import { NotificationsService } from "./notifications.service"; +import { NotificationsController } from "./notifications.controller"; import { PrismaModule } from "../prisma/prisma.module"; @Module({ imports: [PrismaModule], + controllers: [NotificationsController], providers: [NotificationsService], exports: [NotificationsService], }) diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts index eea1f3e..8d6e447 100644 --- a/apps/api/src/notifications/notifications.service.ts +++ b/apps/api/src/notifications/notifications.service.ts @@ -1,50 +1,138 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; +// ─── Plantillas por defecto (fallback cuando no hay en DB) ──── +const DEFAULT_SUBJECTS: Record = { + REGISTRADO: "Tu paquete fue registrado — {{trackingId}}", + EN_TRANSITO_BODEGA: "Tu paquete está en camino a NJ — {{trackingId}}", + RECIBIDO_BODEGA: "Tu paquete llegó a bodega NJ — {{trackingId}}", + EN_VERIFICACION: "Tu paquete está siendo verificado — {{trackingId}}", + VERIFICADO: "Tu paquete fue verificado — {{trackingId}}", + DECLARACION_ADUANERA: "Declaración aduanera aprobada — {{trackingId}}", + EN_TRANSITO_ECUADOR: "Tu paquete viaja hacia Ecuador — {{trackingId}}", + EN_ADUANA_ECUADOR: "Tu paquete está en aduana Ecuador — {{trackingId}}", + LISTO_ENTREGA: "Tu paquete está listo para entrega — {{trackingId}}", + ENTREGADO: "Tu paquete fue entregado — {{trackingId}}", + INCIDENCIA: "Incidencia en tu paquete — {{trackingId}}", +}; + +const DEFAULT_BODIES: Record = { + REGISTRADO: "Hola {{firstName}}, tu paquete {{trackingId}} fue registrado en el sistema. Te notificaremos cada avance.", + EN_TRANSITO_BODEGA: "Hola {{firstName}}, tu paquete {{trackingId}} está en tránsito hacia nuestra bodega en New Jersey.", + RECIBIDO_BODEGA: "Hola {{firstName}}, tu paquete {{trackingId}} llegó a nuestra bodega en NJ. Estamos procesándolo.", + EN_VERIFICACION: "Hola {{firstName}}, tu paquete {{trackingId}} está siendo verificado por nuestro equipo.", + VERIFICADO: "Hola {{firstName}}, tu paquete {{trackingId}} fue verificado. El cobro final fue aplicado.", + DECLARACION_ADUANERA: "Hola {{firstName}}, la declaración aduanera SENAE de tu paquete {{trackingId}} fue aprobada.", + EN_TRANSITO_ECUADOR: "Hola {{firstName}}, tu paquete {{trackingId}} está en tránsito hacia Ecuador. ¡Ya viene en camino!", + EN_ADUANA_ECUADOR: "Hola {{firstName}}, tu paquete {{trackingId}} está en inspección aduanera en Ecuador.", + LISTO_ENTREGA: "Hola {{firstName}}, tu paquete {{trackingId}} está listo para ser retirado o entregado.", + ENTREGADO: "Hola {{firstName}}, tu paquete {{trackingId}} fue entregado exitosamente. ¡Gracias por confiar en Moraworld Imports!", + INCIDENCIA: "Hola {{firstName}}, hay una incidencia con tu paquete {{trackingId}}. Nuestro equipo te contactará pronto.", +}; + +/** Sustituye variables {{key}} en una plantilla */ +function interpolate(tpl: string, vars: Record): string { + return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`); +} + @Injectable() export class NotificationsService { private readonly logger = new Logger(NotificationsService.name); constructor(private prisma: PrismaService) {} - /** Called whenever a package status changes. Creates Notification records and stubs dispatch. */ + // ─── Gestión de plantillas ──────────────────────────────── + + async getTemplates(tenantId: string) { + return this.prisma.client.notificationTemplate.findMany({ + where: { tenantId }, + orderBy: [{ event: "asc" }, { channel: "asc" }], + }); + } + + async updateTemplate(id: string, tenantId: string, body: string, subject?: string, isActive?: boolean) { + const tpl = await this.prisma.client.notificationTemplate.findUnique({ where: { id } }); + if (!tpl || tpl.tenantId !== tenantId) throw new NotFoundException("Plantilla no encontrada"); + return this.prisma.client.notificationTemplate.update({ + where: { id }, + data: { body, subject: subject ?? tpl.subject, isActive: isActive ?? tpl.isActive, updatedAt: new Date() }, + }); + } + + /** Crea las plantillas por defecto para un tenant (upsert — idempotente). */ + async seedDefaultTemplates(tenantId: string) { + const events = Object.keys(DEFAULT_BODIES); + const channels: Array<"EMAIL" | "WHATSAPP" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"]; + const ops = []; + for (const event of events) { + for (const channel of channels) { + ops.push( + this.prisma.client.notificationTemplate.upsert({ + where: { tenantId_event_channel: { tenantId, event, channel } }, + create: { + tenantId, + event, + channel, + subject: channel === "EMAIL" ? DEFAULT_SUBJECTS[event] : undefined, + body: DEFAULT_BODIES[event], + isActive: true, + }, + update: {}, // no sobreescribir si ya existe + }) + ); + } + } + await Promise.all(ops); + return { seeded: ops.length }; + } + + // ─── Envío de notificaciones ───────────────────────────── + + /** Called whenever a package status changes. */ async notifyStatusChange(pkg: any, user: any): Promise { - const statusLabels: Record = { - REGISTRADO: "fue registrado en el sistema", - EN_TRANSITO_BODEGA: "está en tránsito hacia la bodega NJ", - RECIBIDO_BODEGA: "fue recibido en la bodega de NJ", - EN_VERIFICACION: "está siendo verificado en bodega", - VERIFICADO: "fue verificado. El cobro final fue aplicado.", - DECLARACION_ADUANERA: "tiene su declaración aduanera aprobada (SENAE)", - EN_TRANSITO_ECUADOR: "está en tránsito hacia Ecuador", - EN_ADUANA_ECUADOR: "está en inspección aduanera en Ecuador", - LISTO_ENTREGA: "está listo para entrega", - ENTREGADO: "fue entregado exitosamente", - INCIDENCIA: "tiene una incidencia reportada", + const vars: Record = { + trackingId: pkg.trackingId ?? "", + firstName: user?.firstName ?? "Cliente", + status: pkg.status ?? "", + suiteCode: user?.suite?.code ?? "", }; - const label = statusLabels[pkg.status] ?? `cambió a estado ${pkg.status}`; - const body = `Tu paquete ${pkg.trackingId} ${label}.`; - const subject = `Estado de tu paquete: ${pkg.trackingId}`; - - const channels: Array<"EMAIL" | "WHATSAPP" | "SMS" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"]; + const channels: Array<"EMAIL" | "WHATSAPP" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"]; for (const channel of channels) { try { - await this.prisma.client.notification.create({ + // 1. Buscar plantilla en DB + const tpl = await this.prisma.client.notificationTemplate.findUnique({ + where: { tenantId_event_channel: { tenantId: pkg.tenantId, event: pkg.status, channel } }, + }); + + const active = tpl ? tpl.isActive : true; + if (!active) continue; + + const subject = interpolate( + tpl?.subject ?? DEFAULT_SUBJECTS[pkg.status] ?? `Estado de tu paquete: ${pkg.trackingId}`, + vars + ); + const bodyText = interpolate( + tpl?.body ?? DEFAULT_BODIES[pkg.status] ?? `Tu paquete ${pkg.trackingId} cambió a ${pkg.status}.`, + vars + ); + + const record = await this.prisma.client.notification.create({ data: { packageId: pkg.id, - userId: pkg.userId, + userId: pkg.userId, channel, - status: "PENDIENTE", + status: "PENDIENTE", subject, - body, + body: bodyText, }, }); - // STUB: In production, dispatch via SendGrid (EMAIL), WhatsApp Business API (WHATSAPP), etc. - this.logger.log(`[NOTIF STUB] ${channel} → userId=${pkg.userId} | ${body}`); - // Mark as sent (stub — in prod this would be async) - await this.prisma.client.notification.updateMany({ - where: { packageId: pkg.id, userId: pkg.userId, channel, status: "PENDIENTE" }, + + // STUB: En producción → SendGrid (EMAIL), WhatsApp Business API, etc. + this.logger.log(`[NOTIF STUB] ${channel} → userId=${pkg.userId} | ${bodyText}`); + + await this.prisma.client.notification.update({ + where: { id: record.id }, data: { status: "ENVIADO", sentAt: new Date() }, }); } catch (e: unknown) { @@ -53,7 +141,7 @@ export class NotificationsService { } } - async findByUser(userId: string, limit = 20): Promise { + async findByUser(userId: string, limit = 20) { return this.prisma.client.notification.findMany({ where: { userId }, orderBy: { createdAt: "desc" }, @@ -61,7 +149,7 @@ export class NotificationsService { }); } - async findByPackage(packageId: string): Promise { + async findByPackage(packageId: string) { return this.prisma.client.notification.findMany({ where: { packageId }, orderBy: { createdAt: "desc" }, diff --git a/apps/api/src/payments/payments.controller.ts b/apps/api/src/payments/payments.controller.ts new file mode 100644 index 0000000..950e56a --- /dev/null +++ b/apps/api/src/payments/payments.controller.ts @@ -0,0 +1,48 @@ +import { + Controller, Get, Post, Body, Param, Query, + UseGuards, Request, BadRequestException, +} from "@nestjs/common"; +import { JwtAuthGuard } from "../auth/guards/auth.guard"; +import { PaymentsService } from "./payments.service"; + +class CreateIntentDto { + packageId!: string; + provider?: string; +} + +@Controller("payments") +@UseGuards(JwtAuthGuard) +export class PaymentsController { + constructor(private readonly svc: PaymentsService) {} + + /** GET /payments — lista todos los pagos del tenant (admin) */ + @Get() + list(@Request() req: any, @Query("status") status?: string): Promise { + return this.svc.list(req.user.tenantId, status); + } + + /** GET /payments/package/:packageId — detalle + desglose para el cliente */ + @Get("package/:packageId") + detail(@Param("packageId") packageId: string, @Request() req: any): Promise { + return this.svc.findByPackageForUser(packageId, req.user.id, req.user.tenantId); + } + + /** GET /payments/track/:trackingId — por tracking ID (cliente o admin) */ + @Get("track/:trackingId") + byTracking(@Param("trackingId") trackingId: string, @Request() req: any): Promise { + return this.svc.findByTracking(trackingId, req.user.tenantId); + } + + /** POST /payments/intent — crea o recupera un PaymentIntent */ + @Post("intent") + createIntent(@Body() dto: CreateIntentDto, @Request() req: any): Promise { + if (!dto.packageId) throw new BadRequestException("packageId es requerido"); + return this.svc.createIntent(dto.packageId, req.user.id, req.user.tenantId, dto.provider); + } + + /** POST /payments/:id/confirm — confirma pago (dev/stub) */ + @Post(":id/confirm") + confirm(@Param("id") id: string, @Request() req: any): Promise { + return this.svc.confirm(id, req.user.tenantId); + } +} diff --git a/apps/api/src/payments/payments.module.ts b/apps/api/src/payments/payments.module.ts new file mode 100644 index 0000000..b4e8992 --- /dev/null +++ b/apps/api/src/payments/payments.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { PaymentsService } from "./payments.service"; +import { PaymentsController } from "./payments.controller"; +import { PrismaModule } from "../prisma/prisma.module"; + +@Module({ + imports: [PrismaModule], + controllers: [PaymentsController], + providers: [PaymentsService], + exports: [PaymentsService], +}) +export class PaymentsModule {} diff --git a/apps/api/src/payments/payments.service.ts b/apps/api/src/payments/payments.service.ts new file mode 100644 index 0000000..de5487b --- /dev/null +++ b/apps/api/src/payments/payments.service.ts @@ -0,0 +1,119 @@ +import { Injectable, NotFoundException, BadRequestException, Logger } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; + +@Injectable() +export class PaymentsService { + private readonly logger = new Logger(PaymentsService.name); + + constructor(private prisma: PrismaService) {} + + /** Calcula el monto a cobrar desde el Package (peso real × tarifa) */ + private async calcAmount(pkg: any, tenantId: string): Promise { + const tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } }); + const pricePerLb = Number(tariff?.pricePerLb ?? 3.5); + const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0); + const freight = weight * pricePerLb; + const insurance = Number(pkg.declaredValue) * Number(tariff?.insurancePct ?? 0.02); + return Math.round((freight + insurance) * 100) / 100; + } + + /** Obtiene el pago vinculado a un paquete (por trackingId) */ + async findByTracking(trackingId: string, tenantId: string): Promise { + const pkg = await this.prisma.client.package.findFirst({ + where: { trackingId, tenantId }, + include: { payment: true }, + }); + if (!pkg) throw new NotFoundException("Paquete no encontrado"); + return { package: pkg, payment: pkg.payment }; + } + + /** Crea o recupera un intento de pago para el paquete */ + async createIntent(packageId: string, userId: string, tenantId: string, provider = "stripe"): Promise { + const pkg = await this.prisma.client.package.findFirst({ where: { id: packageId, tenantId } }); + if (!pkg) throw new NotFoundException("Paquete no encontrado"); + if (pkg.paidAt) throw new BadRequestException("El paquete ya fue pagado"); + + // Reusar intent existente si está PENDIENTE o PROCESANDO + const existing = await this.prisma.client.payment.findUnique({ where: { packageId } }); + if (existing && ["PENDIENTE", "PROCESANDO"].includes(existing.status)) { + return existing; + } + + const amount = await this.calcAmount(pkg, tenantId); + + // STUB: En producción → Stripe.paymentIntents.create(...) + const providerRef = `pi_stub_${Date.now()}`; + this.logger.log(`[PAYMENT STUB] Creating ${provider} intent for ${pkg.trackingId} — $${amount}`); + + return this.prisma.client.payment.create({ + data: { + tenantId, + packageId, + userId, + amount, + currency: "USD", + provider, + providerRef, + status: "PENDIENTE", + }, + }); + } + + /** Confirma un pago (webhook de Stripe o confirmación manual en dev) */ + async confirm(paymentId: string, tenantId: string): Promise { + const payment = await this.prisma.client.payment.findUnique({ where: { id: paymentId } }); + if (!payment || payment.tenantId !== tenantId) throw new NotFoundException("Pago no encontrado"); + if (payment.status === "COMPLETADO") throw new BadRequestException("El pago ya fue completado"); + + const [updatedPayment] = await this.prisma.client.$transaction([ + this.prisma.client.payment.update({ + where: { id: paymentId }, + data: { status: "COMPLETADO", paidAt: new Date() }, + }), + this.prisma.client.package.update({ + where: { id: payment.packageId }, + data: { paidAt: new Date() }, + }), + ]); + + this.logger.log(`[PAYMENT] Confirmed ${paymentId} for package ${payment.packageId}`); + return updatedPayment; + } + + /** Lista pagos del tenant con filtros opcionales */ + async list(tenantId: string, status?: string): Promise { + return this.prisma.client.payment.findMany({ + where: { tenantId, ...(status ? { status: status as any } : {}) }, + include: { package: { select: { trackingId: true, description: true } } }, + orderBy: { createdAt: "desc" }, + }); + } + + /** Obtiene el pago de un package para el usuario autenticado */ + async findByPackageForUser(packageId: string, userId: string, tenantId: string): Promise { + const pkg = await this.prisma.client.package.findFirst({ + where: { id: packageId, userId, tenantId }, + include: { payment: true }, + }); + if (!pkg) throw new NotFoundException("Paquete no encontrado"); + const tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } }); + const pricePerLb = Number(tariff?.pricePerLb ?? 3.5); + const weight = Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0); + const freight = weight * pricePerLb; + const insurance = Number(pkg.declaredValue) * Number(tariff?.insurancePct ?? 0.02); + const fodinfa = Number(pkg.declaredValue) * Number(tariff?.fodinfaPct ?? 0.005); + const total = freight + insurance + fodinfa; + return { + package: pkg, + payment: pkg.payment, + breakdown: { + weightLb: weight, + pricePerLb, + freight: Math.round(freight * 100) / 100, + insurance: Math.round(insurance * 100) / 100, + fodinfa: Math.round(fodinfa * 100) / 100, + total: Math.round(total * 100) / 100, + }, + }; + } +} diff --git a/apps/web/src/app/_components/whatsapp-float.tsx b/apps/web/src/app/_components/whatsapp-float.tsx new file mode 100644 index 0000000..f05e4b7 --- /dev/null +++ b/apps/web/src/app/_components/whatsapp-float.tsx @@ -0,0 +1,93 @@ +"use client"; +import { useState } from "react"; + +// §07 + §21 — Botón flotante WhatsApp con dos contactos +// Reemplaza los números de teléfono con los reales antes de producción. +const CONTACTS = [ + { + label: "Operaciones NJ", + sub: "150 N Day St · New Jersey", + phone: "12015550100", // ← reemplazar con número real + msg: "Hola, tengo una consulta sobre mi paquete en New Jersey.", + }, + { + label: "Aduana · Cuenca", + sub: "Moraworld Imports S.A.S.", + phone: "593987654321", // ← reemplazar con número real + msg: "Hola, necesito información sobre trámites aduaneros en Ecuador.", + }, +]; + +export function WhatsAppFloat() { + const [open, setOpen] = useState(false); + + return ( +
+ {/* Opciones desplegables */} + {open && ( + + )} + + {/* Botón principal */} + +
+ ); +} diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx index c292e6f..16aeb8b 100644 --- a/apps/web/src/app/admin/layout.tsx +++ b/apps/web/src/app/admin/layout.tsx @@ -6,13 +6,14 @@ import { getUser, clearAuth } from "@/lib/api"; import { api } from "@/lib/api"; const NAV = [ - { href: "/admin", icon: "◈", label: "Dashboard" }, - { href: "/admin/usuarios", icon: "👥", label: "Usuarios" }, - { href: "/admin/tarifas", icon: "💰", label: "Tarifas" }, - { href: "/admin/configuracion", icon: "⚙️", label: "Configuración" }, - { href: "/admin/reportes", icon: "📊", label: "Reportes" }, - { href: "/admin/auditoria", icon: "🔍", label: "Auditoría" }, - { href: "/bodega", icon: "📦", label: "→ Bodega" }, + { href: "/admin", icon: "◈", label: "Dashboard" }, + { href: "/admin/usuarios", icon: "👥", label: "Usuarios" }, + { href: "/admin/tarifas", icon: "💰", label: "Tarifas" }, + { href: "/admin/notificaciones", icon: "📋", label: "Notificaciones" }, + { href: "/admin/configuracion", icon: "⚙️", label: "Configuración" }, + { href: "/admin/reportes", icon: "📊", label: "Reportes" }, + { href: "/admin/auditoria", icon: "🔍", label: "Auditoría" }, + { href: "/bodega", icon: "📦", label: "→ Bodega" }, ]; export default function AdminLayout({ children }: { children: React.ReactNode }) { diff --git a/apps/web/src/app/admin/notificaciones/page.tsx b/apps/web/src/app/admin/notificaciones/page.tsx new file mode 100644 index 0000000..5fed092 --- /dev/null +++ b/apps/web/src/app/admin/notificaciones/page.tsx @@ -0,0 +1,307 @@ +"use client"; +import { useEffect, useState, useCallback } from "react"; +import { api } from "@/lib/api"; + +// §12 — Plantillas de notificación por evento × canal +// Variables disponibles: {{trackingId}}, {{firstName}}, {{status}}, {{suiteCode}} + +const EVENT_LABELS: Record = { + REGISTRADO: "Paquete registrado", + EN_TRANSITO_BODEGA: "En tránsito a bodega NJ", + RECIBIDO_BODEGA: "Recibido en bodega NJ", + EN_VERIFICACION: "En verificación", + VERIFICADO: "Verificado", + DECLARACION_ADUANERA: "Declaración aduanera", + EN_TRANSITO_ECUADOR: "En tránsito a Ecuador", + EN_ADUANA_ECUADOR: "En aduana Ecuador", + LISTO_ENTREGA: "Listo para entrega", + ENTREGADO: "Entregado", + INCIDENCIA: "Incidencia reportada", +}; + +const CHANNEL_ICON: Record = { + EMAIL: "📧", + WHATSAPP: "💬", + PUSH: "🔔", +}; + +const CHANNEL_COLOR: Record = { + EMAIL: "#3B82F6", + WHATSAPP: "#25D366", + PUSH: "#8B5CF6", +}; + +const EVENTS_ORDER = Object.keys(EVENT_LABELS); +const CHANNELS = ["EMAIL", "WHATSAPP", "PUSH"]; + +type Template = { + id: string; + event: string; + channel: string; + subject: string | null; + body: string; + isActive: boolean; +}; + +export default function NotificacionesPage() { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [seeding, setSeeding] = useState(false); + const [editing, setEditing] = useState