feat: payments module, notification templates, WA float, route fixes

- 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)
This commit is contained in:
Lizandro Guarnizo
2026-06-01 17:09:05 -05:00
parent b2b292c50a
commit 5565eef554
20 changed files with 1326 additions and 97 deletions
+2
View File
@@ -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 {}
@@ -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);
}
}
@@ -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],
})
@@ -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<string, string> = {
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<string, string> = {
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, string>): 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<void> {
const statusLabels: Record<string, string> = {
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<string, string> = {
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<any[]> {
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<any[]> {
async findByPackage(packageId: string) {
return this.prisma.client.notification.findMany({
where: { packageId },
orderBy: { createdAt: "desc" },
@@ -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<any[]> {
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<any> {
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<any> {
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<any> {
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<any> {
return this.svc.confirm(id, req.user.tenantId);
}
}
+12
View File
@@ -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 {}
+119
View File
@@ -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<number> {
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<any> {
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<any> {
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<any> {
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<any[]> {
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<any> {
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,
},
};
}
}
@@ -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 (
<div
style={{
position: "fixed", bottom: "1.5rem", right: "1.5rem",
zIndex: 9999, display: "flex", flexDirection: "column",
alignItems: "flex-end", gap: ".75rem",
}}
>
{/* Opciones desplegables */}
{open && (
<div style={{ display: "flex", flexDirection: "column", gap: ".5rem" }}>
{CONTACTS.map(c => (
<a
key={c.phone}
href={`https://wa.me/${c.phone}?text=${encodeURIComponent(c.msg)}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: "flex", alignItems: "center", gap: ".75rem",
background: "#fff", borderRadius: 12, padding: ".75rem 1rem",
boxShadow: "0 4px 20px rgba(0,0,0,.15)",
textDecoration: "none", color: "#111827",
minWidth: 220, transition: "transform .15s",
}}
onMouseEnter={e => { e.currentTarget.style.transform = "translateX(-4px)"; }}
onMouseLeave={e => { e.currentTarget.style.transform = "translateX(0)"; }}
>
<span style={{ fontSize: "1.4rem", lineHeight: 1 }}>💬</span>
<div>
<div style={{ fontWeight: 700, fontSize: ".88rem" }}>{c.label}</div>
<div style={{ fontSize: ".75rem", color: "#6B7280" }}>{c.sub}</div>
</div>
</a>
))}
</div>
)}
{/* Botón principal */}
<button
onClick={() => setOpen(o => !o)}
aria-label="Contactar por WhatsApp"
style={{
width: 56, height: 56, borderRadius: "50%",
background: "#25D366", border: "none", cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
boxShadow: "0 4px 16px rgba(37,211,102,.45)",
transition: "transform .2s, box-shadow .2s",
transform: open ? "rotate(45deg)" : "rotate(0deg)",
}}
onMouseEnter={e => {
e.currentTarget.style.transform = open ? "rotate(45deg) scale(1.1)" : "scale(1.1)";
}}
onMouseLeave={e => {
e.currentTarget.style.transform = open ? "rotate(45deg)" : "scale(1)";
}}
>
{open ? (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="white" strokeWidth="2.5" strokeLinecap="round" />
</svg>
) : (
<svg width="26" height="26" viewBox="0 0 24 24" fill="white">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347z" />
<path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2 22l4.948-1.42A9.956 9.956 0 0012 22c5.523 0 10-4.477 10-10S17.523 2 12 2z" />
</svg>
)}
</button>
</div>
);
}
+8 -7
View File
@@ -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 }) {
@@ -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<string, string> = {
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<string, string> = {
EMAIL: "📧",
WHATSAPP: "💬",
PUSH: "🔔",
};
const CHANNEL_COLOR: Record<string, string> = {
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<Template[]>([]);
const [loading, setLoading] = useState(true);
const [seeding, setSeeding] = useState(false);
const [editing, setEditing] = useState<Template | null>(null);
const [editBody, setEditBody] = useState("");
const [editSubject, setEditSubject] = useState("");
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await api.notificationTemplates.list();
setTemplates(data);
} catch {
setTemplates([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const showToast = (msg: string) => {
setToast(msg);
setTimeout(() => setToast(null), 3000);
};
const handleSeed = async () => {
setSeeding(true);
try {
await api.notificationTemplates.seed();
await load();
showToast("Plantillas por defecto creadas correctamente.");
} catch {
showToast("Error al crear plantillas.");
} finally {
setSeeding(false);
}
};
const openEdit = (tpl: Template) => {
setEditing(tpl);
setEditBody(tpl.body);
setEditSubject(tpl.subject ?? "");
};
const handleSave = async () => {
if (!editing) return;
setSaving(true);
try {
await api.notificationTemplates.update(editing.id, {
body: editBody,
subject: editSubject || undefined,
isActive: editing.isActive,
});
setEditing(null);
await load();
showToast("Plantilla guardada.");
} catch {
showToast("Error al guardar.");
} finally {
setSaving(false);
}
};
const toggleActive = async (tpl: Template) => {
try {
await api.notificationTemplates.update(tpl.id, {
body: tpl.body,
subject: tpl.subject ?? undefined,
isActive: !tpl.isActive,
});
setTemplates(prev => prev.map(t => t.id === tpl.id ? { ...t, isActive: !t.isActive } : t));
} catch {
showToast("Error al actualizar estado.");
}
};
// Agrupa plantillas: { event → { channel → Template } }
const byEvent: Record<string, Record<string, Template>> = {};
templates.forEach(t => {
if (!byEvent[t.event]) byEvent[t.event] = {};
byEvent[t.event][t.channel] = t;
});
const isEmpty = templates.length === 0;
return (
<div>
{/* Toast */}
{toast && (
<div style={{
position: "fixed", top: "1rem", right: "1rem", zIndex: 9999,
background: "var(--green)", color: "#fff", borderRadius: 8,
padding: ".75rem 1.25rem", fontWeight: 600, fontSize: ".9rem",
boxShadow: "0 4px 16px rgba(0,0,0,.15)",
}}>{toast}</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "1.5rem" }}>
<div>
<h1 className="dash-page-title">Plantillas de notificación</h1>
<p className="dash-page-subtitle">
Configura los mensajes enviados a clientes en cada estado del ciclo de vida (§12).
<br />
Variables disponibles: <code>{"{{trackingId}}"}</code> <code>{"{{firstName}}"}</code> <code>{"{{status}}"}</code> <code>{"{{suiteCode}}"}</code>
</p>
</div>
{isEmpty && !loading && (
<button
className="btn btn-primary"
onClick={handleSeed}
disabled={seeding}
style={{ whiteSpace: "nowrap" }}
>
{seeding ? "Creando..." : "Crear plantillas por defecto"}
</button>
)}
</div>
{loading ? (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
) : isEmpty ? (
<div className="card" style={{ textAlign: "center", padding: "3rem" }}>
<div style={{ fontSize: "2.5rem", marginBottom: ".75rem" }}>📋</div>
<p style={{ color: "var(--gray-500)", marginBottom: "1rem" }}>
No hay plantillas configuradas. Haz click en "Crear plantillas por defecto" para empezar.
</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{EVENTS_ORDER.map(event => {
const row = byEvent[event] ?? {};
const hasAny = Object.keys(row).length > 0;
if (!hasAny) return null;
return (
<div key={event} className="card">
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 700 }}>{EVENT_LABELS[event] ?? event}</span>
<span style={{ fontSize: ".75rem", color: "var(--gray-500)", fontFamily: "monospace" }}>{event}</span>
</div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: ".75rem" }}>
{CHANNELS.map(channel => {
const tpl = row[channel];
if (!tpl) return null;
return (
<div
key={channel}
style={{
border: `1px solid ${tpl.isActive ? "var(--gray-200)" : "var(--gray-100)"}`,
borderRadius: 8, padding: ".875rem 1rem",
opacity: tpl.isActive ? 1 : 0.55,
background: tpl.isActive ? "#fff" : "var(--gray-50)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: ".5rem" }}>
<span style={{ display: "flex", alignItems: "center", gap: ".5rem", fontWeight: 600, fontSize: ".875rem" }}>
<span style={{ fontSize: "1rem" }}>{CHANNEL_ICON[channel]}</span>
<span style={{ color: CHANNEL_COLOR[channel] }}>{channel}</span>
</span>
<div style={{ display: "flex", gap: ".5rem", alignItems: "center" }}>
<button
className="btn btn-ghost"
style={{ fontSize: ".75rem", padding: ".25rem .6rem" }}
onClick={() => openEdit(tpl)}
>
Editar
</button>
<label className="toggle-wrap" style={{ margin: 0 }}>
<input
type="checkbox"
checked={tpl.isActive}
onChange={() => toggleActive(tpl)}
style={{ display: "none" }}
/>
<span
className="toggle-track"
onClick={() => toggleActive(tpl)}
style={{ background: tpl.isActive ? "var(--primary)" : "var(--gray-300)", cursor: "pointer" }}
>
<span className="toggle-thumb" style={{ transform: tpl.isActive ? "translateX(20px)" : "translateX(0)" }} />
</span>
</label>
</div>
</div>
{channel === "EMAIL" && tpl.subject && (
<div style={{ fontSize: ".78rem", color: "var(--gray-500)", marginBottom: ".35rem" }}>
<strong>Asunto:</strong> {tpl.subject}
</div>
)}
<div style={{ fontSize: ".82rem", color: "var(--gray-700)", lineHeight: 1.5 }}>
{tpl.body}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
{/* Modal de edición */}
{editing && (
<div className="modal-overlay" onClick={() => setEditing(null)}>
<div className="modal-box" onClick={e => e.stopPropagation()} style={{ maxWidth: 580 }}>
<div className="modal-header">
<span>
{CHANNEL_ICON[editing.channel]} Editar plantilla {" "}
<strong>{EVENT_LABELS[editing.event] ?? editing.event}</strong>{" "}
/ <span style={{ color: CHANNEL_COLOR[editing.channel] }}>{editing.channel}</span>
</span>
<button className="btn btn-ghost" style={{ padding: ".25rem .5rem" }} onClick={() => setEditing(null)}></button>
</div>
<div className="modal-body" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{editing.channel === "EMAIL" && (
<div>
<label style={{ display: "block", fontSize: ".85rem", fontWeight: 600, marginBottom: ".35rem" }}>
Asunto del correo
</label>
<input
className="form-input"
value={editSubject}
onChange={e => setEditSubject(e.target.value)}
placeholder="Ej: Tu paquete {{trackingId}} fue entregado"
/>
</div>
)}
<div>
<label style={{ display: "block", fontSize: ".85rem", fontWeight: 600, marginBottom: ".35rem" }}>
Cuerpo del mensaje
</label>
<textarea
className="form-input"
rows={6}
value={editBody}
onChange={e => setEditBody(e.target.value)}
placeholder="Hola {{firstName}}, tu paquete {{trackingId}} ..."
style={{ resize: "vertical", fontFamily: "inherit" }}
/>
<p style={{ fontSize: ".75rem", color: "var(--gray-500)", marginTop: ".35rem" }}>
Variables: <code>{"{{trackingId}}"}</code> <code>{"{{firstName}}"}</code> <code>{"{{status}}"}</code> <code>{"{{suiteCode}}"}</code>
</p>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setEditing(null)}>Cancelar</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !editBody.trim()}>
{saving ? "Guardando..." : "Guardar cambios"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+92 -28
View File
@@ -2,66 +2,130 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
// §08 — estados oficiales del ciclo de vida
const STATUS_COLORS: Record<string, string> = {
REGISTRADO: "#6B7280",
EN_TRANSITO_BODEGA: "#F59E0B",
RECIBIDO_BODEGA: "#3B82F6",
EN_VERIFICACION: "#8B5CF6",
VERIFICADO: "#10B981",
DECLARACION_ADUANERA: "#0057FF",
EN_TRANSITO_ECUADOR: "#F97316",
EN_ADUANA_ECUADOR: "#EF4444",
LISTO_ENTREGA: "#84CC16",
ENTREGADO: "#10B981",
INCIDENCIA: "#EF4444",
};
export default function ReportesPage() {
const [users, setUsers] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([api.users.list(), api.packages.list()])
.then(([u, p]) => { setUsers(u); setPackages(p); })
.catch(() => {}).finally(() => setLoading(false));
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
if (loading) return <div style={{ display:"flex", justifyContent:"center", padding:"4rem" }}><div className="spinner" /></div>;
const byStatus: Record<string, number> = {};
packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; });
const totalDeclared = packages.reduce((a, p) => a + (p.declaredValueUsd ?? 0), 0);
const totalDeclared = packages.reduce((a, p) => a + parseFloat(p.declaredValue ?? "0"), 0);
const inTransit = (byStatus["EN_TRANSITO_BODEGA"] ?? 0) + (byStatus["EN_TRANSITO_ECUADOR"] ?? 0);
const delivered = byStatus["ENTREGADO"] ?? 0;
const incidents = byStatus["INCIDENCIA"] ?? 0;
const byRole: Record<string, number> = {};
users.forEach(u => { byRole[u.role] = (byRole[u.role] ?? 0) + 1; });
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Reportes</h1><p className="dash-page-subtitle">Resumen operativo del sistema.</p></div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Reportes</h1>
<p className="dash-page-subtitle">Resumen operativo del sistema ingresos, volumen y estado de envíos.</p>
</div>
{/* KPIs */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Total paquetes", value: packages.length, color: "var(--primary)" },
{ label: "Entregados", value: byStatus["ENTREGADO"] ?? 0, color: "var(--green)" },
{ label: "En tránsito", value: byStatus["EN_CAMINO_A_ECUADOR"] ?? 0, color: "var(--yellow)" },
{ label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC")}`, color: "var(--accent)" },
{ label: "Total paquetes", value: packages.length, color: "var(--primary)" },
{ label: "Entregados", value: delivered, color: "var(--green)" },
{ label: "En tránsito", value: inTransit, color: "var(--yellow)" },
{ label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC")}`, color: "var(--accent)" },
{ label: "Incidencias", value: incidents, color: "var(--red)" },
{ label: "Usuarios", value: users.length, color: "var(--primary)" },
{ label: "Clientes", value: byRole["CLIENTE"] ?? 0, color: "#8B5CF6" },
{ label: "Pendiente aduana", value: byStatus["DECLARACION_ADUANERA"] ?? 0, color: "#0057FF" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-value" style={{ color: s.color, fontSize: "1.75rem" }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
{/* Paquetes por estado */}
<div className="card">
<div className="card-header"><span className="font-semibold">Paquetes por estado</span></div>
<div className="card-header"><span className="font-semibold">Paquetes por estado (§08)</span></div>
<div className="card-body">
{Object.entries(byStatus).map(([status, count]) => (
<div key={status} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ fontSize: ".9rem" }}>{status.replace(/_/g," ")}</span>
<span style={{ fontWeight: 700 }}>{count}</span>
</div>
))}
{Object.keys(byStatus).length === 0 && <p style={{ color: "var(--gray-500)" }}>Sin datos.</p>}
{Object.keys(STATUS_COLORS).map(status => {
const count = byStatus[status] ?? 0;
const pct = packages.length ? Math.round((count / packages.length) * 100) : 0;
return (
<div key={status} style={{ marginBottom: ".75rem" }}>
<div style={{ display:"flex", justifyContent:"space-between", marginBottom:".2rem" }}>
<span style={{ fontSize:".8rem", display:"flex", alignItems:"center", gap:".4rem" }}>
<span style={{ width:8, height:8, borderRadius:"50%", background: STATUS_COLORS[status], display:"inline-block", flexShrink:0 }} />
{status.replace(/_/g," ")}
</span>
<span style={{ fontWeight:700, fontSize:".85rem" }}>{count}</span>
</div>
<div style={{ height:4, background:"var(--gray-100)", borderRadius:999 }}>
<div style={{ height:4, borderRadius:999, background: STATUS_COLORS[status], width:`${pct}%`, transition:"width .4s" }} />
</div>
</div>
);
})}
{packages.length === 0 && <p style={{ color:"var(--gray-500)", fontSize:".9rem" }}>Sin datos.</p>}
</div>
</div>
<div className="card">
<div className="card-header"><span className="font-semibold">Usuarios por rol</span></div>
<div className="card-body">
{Object.entries(byRole).map(([role, count]) => (
<div key={role} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ fontSize: ".9rem" }}>{role}</span>
<span style={{ fontWeight: 700 }}>{count}</span>
</div>
))}
<div style={{ display:"flex", flexDirection:"column", gap:"1.5rem" }}>
{/* Usuarios por rol */}
<div className="card">
<div className="card-header"><span className="font-semibold">Usuarios por rol (§06)</span></div>
<div className="card-body">
{Object.entries(byRole).map(([role, count]) => (
<div key={role} style={{ display:"flex", justifyContent:"space-between", padding:".4rem 0", borderBottom:"1px solid var(--gray-100)" }}>
<span style={{ fontSize:".875rem" }}>{role}</span>
<span style={{ fontWeight:700 }}>{count}</span>
</div>
))}
{Object.keys(byRole).length === 0 && <p style={{ color:"var(--gray-500)", fontSize:".9rem" }}>Sin datos.</p>}
</div>
</div>
{/* Métricas rápidas */}
<div className="card">
<div className="card-header"><span className="font-semibold">Rendimiento</span></div>
<div className="card-body" style={{ display:"flex", flexDirection:"column", gap:".75rem" }}>
{[
["Tasa de entrega", packages.length ? `${Math.round((delivered/packages.length)*100)}%` : "—"],
["Tasa de incidencias", packages.length ? `${Math.round((incidents/packages.length)*100)}%` : "—"],
["Pendiente declaración", byStatus["VERIFICADO"] ?? 0],
["En bodega NJ", (byStatus["RECIBIDO_BODEGA"] ?? 0) + (byStatus["EN_VERIFICACION"] ?? 0) + (byStatus["VERIFICADO"] ?? 0)],
].map(([k, v]) => (
<div key={k as string} style={{ display:"flex", justifyContent:"space-between", padding:".375rem 0", borderBottom:"1px solid var(--gray-100)" }}>
<span style={{ color:"var(--gray-600)", fontSize:".875rem" }}>{k}</span>
<span style={{ fontWeight:600 }}>{v}</span>
</div>
))}
</div>
</div>
</div>
</div>
+14 -1
View File
@@ -2,7 +2,20 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
const STATUSES = ["RECIBIDO_EN_NJ","EN_PROCESO","EN_CAMINO_A_ECUADOR","EN_ADUANA","EN_BODEGA_EC","LISTO_PARA_RETIRO","ENTREGADO","RETENIDO_ADUANA","DEVUELTO","PERDIDO","CANCELADO"];
// §08 — 11 estados oficiales del ciclo de vida del paquete
const STATUSES = [
"REGISTRADO",
"EN_TRANSITO_BODEGA",
"RECIBIDO_BODEGA",
"EN_VERIFICACION",
"VERIFICADO",
"DECLARACION_ADUANERA",
"EN_TRANSITO_ECUADOR",
"EN_ADUANA_ECUADOR",
"LISTO_ENTREGA",
"ENTREGADO",
"INCIDENCIA",
];
export default function BodegaPaquetesPage() {
const [packages, setPackages] = useState<any[]>([]);
@@ -0,0 +1,6 @@
// §20 — /casillero/calculadora → redirige a /calculadora
import { redirect } from "next/navigation";
export default function CasilleroCalculadoraPage() {
redirect("/calculadora");
}
@@ -0,0 +1,6 @@
// §20 — /casillero/registro → redirige a /registro
import { redirect } from "next/navigation";
export default function CasilleroRegistroPage() {
redirect("/registro");
}
+93
View File
@@ -367,6 +367,99 @@ tbody tr:hover { background: var(--gray-50); }
.address-box .address-key { font-weight: 500; color: var(--gray-700); min-width: 110px; }
.address-box .address-val { font-family: monospace; color: var(--gray-900); }
/* ─── Toggle Switch ──────────────────────────────────── */
.toggle-wrap { display: flex; align-items: center; gap: .5rem; cursor: pointer; user-select: none; }
.toggle-wrap input[type="checkbox"] { display: none; }
.toggle-track {
position: relative; width: 36px; height: 20px; border-radius: 9999px;
background: var(--gray-300); transition: background .2s; flex-shrink: 0;
}
.toggle-thumb {
position: absolute; top: 2px; left: 2px;
width: 16px; height: 16px; border-radius: 9999px;
background: var(--white); box-shadow: 0 1px 3px rgba(0,0,0,.3);
transition: transform .2s;
}
.toggle-wrap input:checked ~ .toggle-track { background: var(--primary); }
.toggle-wrap input:checked ~ .toggle-track .toggle-thumb { transform: translateX(16px); }
.toggle-label { font-size: .8rem; font-weight: 500; color: var(--gray-600); }
/* ─── Integration field row ──────────────────────────── */
.int-field {
display: grid; grid-template-columns: 20px 1fr auto auto;
align-items: center; gap: 1rem;
padding: .875rem 1rem; border-bottom: 1px solid var(--gray-100);
}
.int-field:last-child { border-bottom: none; }
.int-field:hover { background: var(--gray-50); border-radius: var(--radius); }
.int-dot { width: 10px; height: 10px; border-radius: 9999px; flex-shrink: 0; }
.int-dot.has-value { background: var(--green); box-shadow: 0 0 0 3px var(--green-light); }
.int-dot.no-value { background: var(--gray-300); }
.int-label-wrap { min-width: 0; }
.int-label { font-size: .875rem; font-weight: 500; color: var(--gray-800); display: flex; align-items: center; gap: .375rem; }
.int-hint { font-size: .75rem; color: var(--gray-500); margin-top: .1rem; }
.int-input-wrap { position: relative; width: 260px; flex-shrink: 0; }
.int-input {
width: 100%; padding: .5rem 2.5rem .5rem .75rem;
border: 1.5px solid var(--gray-300); border-radius: var(--radius);
font-size: .875rem; background: var(--white); color: var(--gray-900);
transition: border-color .2s; font-family: monospace;
}
.int-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(0,87,255,.08); }
.int-input.is-dirty { border-color: var(--accent); }
.int-eye {
position: absolute; right: .5rem; top: 50%; transform: translateY(-50%);
background: none; border: none; cursor: pointer; color: var(--gray-400);
font-size: .875rem; padding: .2rem; line-height: 1;
}
.int-eye:hover { color: var(--gray-700); }
/* ─── Page header (if not already defined) ───────────── */
.page-header { margin-bottom: 1.5rem; }
.page-title { font-size: 1.5rem; font-weight: 700; color: var(--gray-900); }
.page-subtitle { font-size: .9rem; color: var(--gray-500); margin-top: .25rem; }
/* ─── Form control alias ─────────────────────────────── */
.form-label { font-size: .8125rem; font-weight: 500; color: var(--gray-700); margin-bottom: .25rem; display: block; }
.form-control {
width: 100%; padding: .5rem .75rem;
border: 1.5px solid var(--gray-300); border-radius: var(--radius);
font-size: .9rem; background: var(--white); color: var(--gray-900); transition: border-color .2s;
}
.form-control:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(0,87,255,.08); }
/* ─── Modal ──────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 200;
display: flex; align-items: center; justify-content: center; padding: 1rem;
}
.modal {
background: var(--white); border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl); width: 100%; max-height: 90vh; overflow-y: auto;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--gray-200);
}
.modal-header h3 { font-size: 1.05rem; font-weight: 700; }
.modal-body { padding: 1.5rem; }
.modal-footer {
display: flex; justify-content: flex-end; gap: .75rem;
padding: 1rem 1.5rem; border-top: 1px solid var(--gray-200); background: var(--gray-50);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
.btn-icon { background: none; border: none; cursor: pointer; color: var(--gray-500); font-size: 1rem; padding: .25rem; line-height: 1; border-radius: var(--radius); }
.btn-icon:hover { background: var(--gray-100); color: var(--gray-800); }
/* ─── Empty state ────────────────────────────────────── */
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--gray-500); }
/* ─── Badge aliases ──────────────────────────────────── */
.badge-success { background: var(--green-light); color: #065f46; }
.badge-error { background: var(--red-light); color: #991b1b; }
.badge-warning { background: var(--yellow-light); color: #92400e; }
.badge-info { background: var(--blue-light); color: #1e40af; }
/* ─── Responsive ─────────────────────────────────────── */
@media (max-width: 768px) {
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
+2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { WhatsAppFloat } from "./_components/whatsapp-float";
export const metadata: Metadata = {
title: "Moraworld Imports — Tu casillero en New Jersey para Ecuador",
@@ -445,6 +446,7 @@ export default function HomePage() {
<RolesSection />
<CompaniesSection />
<Footer />
<WhatsAppFloat />
</>
);
}
+84 -31
View File
@@ -3,96 +3,149 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
// §08 — 11 estados oficiales
const STATUS_LABEL: Record<string, string> = {
RECIBIDO_EN_NJ: "Recibido en NJ", EN_PROCESO: "En proceso",
EN_CAMINO_A_ECUADOR: "En camino a Ecuador", EN_ADUANA: "En aduana",
EN_BODEGA_EC: "En bodega EC", LISTO_PARA_RETIRO: "Listo para retiro",
ENTREGADO: "Entregado", RETENIDO_ADUANA: "Retenido", DEVUELTO: "Devuelto",
PERDIDO: "Perdido", CANCELADO: "Cancelado",
REGISTRADO: "Registrado",
EN_TRANSITO_BODEGA: "En tránsito a NJ",
RECIBIDO_BODEGA: "Recibido en 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",
};
const STATUS_BADGE: Record<string, string> = {
RECIBIDO_EN_NJ: "badge-blue", EN_PROCESO: "badge-yellow",
EN_CAMINO_A_ECUADOR: "badge-orange", EN_ADUANA: "badge-yellow",
EN_BODEGA_EC: "badge-blue", LISTO_PARA_RETIRO: "badge-green",
ENTREGADO: "badge-green", RETENIDO_ADUANA: "badge-red",
DEVUELTO: "badge-red", PERDIDO: "badge-red", CANCELADO: "badge-gray",
REGISTRADO: "badge-gray",
EN_TRANSITO_BODEGA: "badge-yellow",
RECIBIDO_BODEGA: "badge-blue",
EN_VERIFICACION: "badge-yellow",
VERIFICADO: "badge-green",
DECLARACION_ADUANERA: "badge-blue",
EN_TRANSITO_ECUADOR: "badge-orange",
EN_ADUANA_ECUADOR: "badge-red",
LISTO_ENTREGA: "badge-green",
ENTREGADO: "badge-green",
INCIDENCIA: "badge-red",
};
// Estados donde el cliente puede pagar
const PAYABLE_STATUSES = ["VERIFICADO", "DECLARACION_ADUANERA"];
export default function MisPaquetesPage() {
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("");
const [selected, setSelected] = useState<any | null>(null);
useEffect(() => {
api.packages.list().then(setPackages).catch(() => {}).finally(() => setLoading(false));
api.packages.list()
.then(setPackages)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const filtered = packages.filter(p =>
!filter ||
p.trackingId?.toLowerCase().includes(filter.toLowerCase()) ||
p.trackingNumber?.toLowerCase().includes(filter.toLowerCase()) ||
p.description?.toLowerCase().includes(filter.toLowerCase())
);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
if (loading) return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
);
return (
<div>
<div className="mb-6 flex justify-between items-center flex-wrap gap-4">
<div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "flex-start", flexWrap: "wrap", gap: "1rem" }}>
<div>
<h1 className="dash-page-title">Mis Paquetes</h1>
<p className="dash-page-subtitle">Historial completo de tus envíos.</p>
</div>
<input
className="input" style={{ maxWidth: 280 }}
className="form-input"
style={{ maxWidth: 280 }}
placeholder="Buscar por código o descripción..."
value={filter} onChange={e => setFilter(e.target.value)}
value={filter}
onChange={e => setFilter(e.target.value)}
/>
</div>
{filtered.length === 0 ? (
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}>📦</div>
<p style={{ color: "var(--gray-500)" }}>{filter ? "No hay paquetes que coincidan." : "Aún no tienes paquetes registrados."}</p>
<p style={{ color: "var(--gray-500)" }}>
{filter ? "No hay paquetes que coincidan." : "Aún no tienes paquetes registrados."}
</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{filtered.map(p => (
<div key={p.id} className="card pointer" onClick={() => setSelected(p === selected ? null : p)}>
<div key={p.id} className="card" style={{ cursor: "pointer" }} onClick={() => setSelected(p === selected ? null : p)}>
<div className="card-body" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: "1rem" }}>
<div>
<div style={{ fontWeight: 700, fontSize: "1rem", color: "var(--primary)" }}>{p.trackingId}</div>
<div style={{ fontSize: ".875rem", color: "var(--gray-500)", marginTop: ".2rem" }}>
{p.description ?? "Sin descripción"} · {p.trackingNumber ?? ""}
{p.description ?? "Sin descripción"}
{p.vendorTracking ? ` · ${p.vendorTracking}` : ""}
</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
{new Date(p.createdAt).toLocaleDateString("es-EC")}
{p.actualWeight ? ` · ${p.actualWeight} lb` : p.declaredWeight ? ` · ~${p.declaredWeight} lb (declarado)` : ""}
</div>
</div>
<div style={{ textAlign: "right" }}>
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`}>{STATUS_LABEL[p.status] ?? p.status}</span>
{p.weightLb && <div style={{ fontSize: ".8rem", color: "var(--gray-500)", marginTop: ".4rem" }}>{p.weightLb} lb</div>}
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: ".5rem" }}>
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`}>
{STATUS_LABEL[p.status] ?? p.status}
</span>
{/* Botón de pago cuando corresponde */}
{PAYABLE_STATUSES.includes(p.status) && !p.paidAt && (
<Link
href={`/portal/pago?packageId=${p.id}`}
className="btn btn-primary"
style={{ fontSize: ".8rem", padding: ".35rem .8rem" }}
onClick={e => e.stopPropagation()}
>
💳 Pagar envío
</Link>
)}
{p.paidAt && (
<span style={{ fontSize: ".75rem", color: "var(--green)", fontWeight: 600 }}> Pagado</span>
)}
</div>
</div>
{/* Historial expandido */}
{/* Detalle expandido */}
{selected?.id === p.id && (
<div className="card-footer">
<div style={{ fontWeight: 600, marginBottom: ".75rem" }}>Historial de estados</div>
<div className="card-footer" style={{ borderTop: "1px solid var(--gray-100)", paddingTop: "1rem" }}>
<div style={{ fontWeight: 600, marginBottom: ".75rem", fontSize: ".9rem" }}>Historial de estados</div>
{p.statusHistory?.length ? (
<div className="timeline">
{p.statusHistory.map((h: any, i: number) => (
<div key={h.id} className="timeline-item">
<div className={`timeline-dot ${i === 0 ? "current" : "active"}`} />
<div>
<div style={{ fontWeight: 600, fontSize: ".875rem" }}>{STATUS_LABEL[h.status] ?? h.status}</div>
{h.notes && <div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{h.notes}</div>}
<div style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".2rem" }}>{new Date(h.createdAt).toLocaleString("es-EC")}</div>
<div style={{ fontWeight: 600, fontSize: ".875rem" }}>
{STATUS_LABEL[h.status] ?? h.status}
</div>
{h.note && (
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{h.note}</div>
)}
<div style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
{new Date(h.createdAt).toLocaleString("es-EC")}
</div>
</div>
</div>
))}
</div>
) : <p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>Sin historial.</p>}
) : (
<p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>Sin historial disponible.</p>
)}
</div>
)}
</div>
+199
View File
@@ -0,0 +1,199 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { api, getUser } from "@/lib/api";
// §09 paso 8 — Página de pago del envío
// URL: /portal/pago?packageId=xxx
const STATUS_LABEL: Record<string, { text: string; color: string }> = {
PENDIENTE: { text: "Pendiente de pago", color: "var(--yellow)" },
PROCESANDO: { text: "Procesando...", color: "var(--accent)" },
COMPLETADO: { text: "Pago completado", color: "var(--green)" },
FALLIDO: { text: "Pago fallido", color: "var(--red)" },
REEMBOLSADO: { text: "Reembolsado", color: "var(--gray-500)" },
};
function PagoContent() {
const router = useRouter();
const params = useSearchParams();
const packageId = params.get("packageId");
const user = getUser();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!user) { router.replace("/login"); return; }
if (!packageId) { router.replace("/portal/mis-paquetes"); return; }
api.payments.packageDetail(packageId)
.then(setData)
.catch(() => setError("No se pudo cargar el paquete."))
.finally(() => setLoading(false));
}, [packageId, router, user]);
const handlePay = async () => {
if (!data) return;
setPaying(true);
setError(null);
try {
// 1. Crear intent de pago
const intent = await api.payments.createIntent(data.package.id);
// 2. En producción aquí se abre Stripe Checkout/PayPhone; en dev confirmamos directamente
await api.payments.confirm(intent.id);
setSuccess(true);
// Recargar datos
const fresh = await api.payments.packageDetail(packageId!);
setData(fresh);
} catch (e: any) {
setError(e?.message ?? "Error al procesar el pago.");
} finally {
setPaying(false);
}
};
if (loading) {
return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "60vh" }}>
<div className="spinner" />
</div>
);
}
if (error && !data) {
return (
<div style={{ maxWidth: 480, margin: "4rem auto", textAlign: "center" }}>
<div style={{ fontSize: "2rem", marginBottom: ".75rem" }}></div>
<p style={{ color: "var(--gray-600)" }}>{error}</p>
<button className="btn btn-ghost" style={{ marginTop: "1rem" }} onClick={() => router.back()}>Volver</button>
</div>
);
}
const { package: pkg, payment, breakdown } = data ?? {};
const payStatus = payment?.status;
const alreadyPaid = payStatus === "COMPLETADO";
return (
<div style={{ maxWidth: 560, margin: "0 auto", padding: "2rem 1rem" }}>
<button
className="btn btn-ghost"
style={{ marginBottom: "1.5rem", fontSize: ".85rem" }}
onClick={() => router.back()}
>
Volver
</button>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: ".25rem" }}>Pago del envío</h1>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem", marginBottom: "1.5rem" }}>
Tracking: <code style={{ fontWeight: 700, color: "var(--primary)" }}>{pkg?.trackingId}</code>
</p>
{/* Estado actual del pago */}
{payment && (
<div style={{
display: "flex", alignItems: "center", gap: ".75rem",
padding: ".875rem 1rem", borderRadius: 8, marginBottom: "1.5rem",
background: "var(--gray-50)", border: "1px solid var(--gray-200)",
}}>
<span style={{
width: 10, height: 10, borderRadius: "50%", flexShrink: 0,
background: STATUS_LABEL[payStatus]?.color ?? "var(--gray-400)",
}} />
<span style={{ fontWeight: 600 }}>{STATUS_LABEL[payStatus]?.text ?? payStatus}</span>
{payment.paidAt && (
<span style={{ marginLeft: "auto", fontSize: ".8rem", color: "var(--gray-500)" }}>
{new Date(payment.paidAt).toLocaleString("es-EC")}
</span>
)}
</div>
)}
{/* Desglose de costos */}
<div className="card" style={{ marginBottom: "1.5rem" }}>
<div className="card-header"><span style={{ fontWeight: 700 }}>Desglose de costos (§15)</span></div>
<div className="card-body">
{[
["Peso", `${breakdown?.weightLb ?? 0} lb`],
["Precio por libra", `$${breakdown?.pricePerLb ?? 0}`],
["Flete", `$${breakdown?.freight ?? 0}`],
["Seguro (2%)", `$${breakdown?.insurance ?? 0}`],
["FODINFA (0.5%)", `$${breakdown?.fodinfa ?? 0}`],
].map(([k, v]) => (
<div key={k as string} style={{ display: "flex", justifyContent: "space-between", padding: ".4rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ color: "var(--gray-600)", fontSize: ".9rem" }}>{k}</span>
<span style={{ fontSize: ".9rem" }}>{v}</span>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", padding: ".75rem 0 0", marginTop: ".25rem" }}>
<span style={{ fontWeight: 700, fontSize: "1rem" }}>Total a pagar</span>
<span style={{ fontWeight: 700, fontSize: "1.15rem", color: "var(--primary)" }}>
${breakdown?.total ?? 0} USD
</span>
</div>
</div>
</div>
{/* Descripción del paquete */}
<div style={{ padding: ".875rem 1rem", background: "var(--gray-50)", borderRadius: 8, marginBottom: "1.5rem", fontSize: ".875rem", color: "var(--gray-600)" }}>
<strong>Descripción:</strong> {pkg?.description}<br />
<strong>Valor declarado:</strong> ${pkg?.declaredValue} USD
</div>
{/* Alerta de éxito */}
{(success || alreadyPaid) && (
<div style={{ background: "#F0FDF4", border: "1px solid #86EFAC", borderRadius: 8, padding: "1rem", marginBottom: "1.5rem", display: "flex", gap: ".75rem", alignItems: "flex-start" }}>
<span style={{ fontSize: "1.3rem" }}></span>
<div>
<div style={{ fontWeight: 700, color: "#166534" }}>¡Pago completado!</div>
<div style={{ fontSize: ".85rem", color: "#15803D" }}>Tu envío fue confirmado y está siendo procesado.</div>
</div>
</div>
)}
{/* Error */}
{error && (
<div style={{ background: "#FEF2F2", border: "1px solid #FCA5A5", borderRadius: 8, padding: ".875rem 1rem", marginBottom: "1rem", fontSize: ".875rem", color: "#991B1B" }}>
{error}
</div>
)}
{/* Botón de pago */}
{!alreadyPaid && (
<button
className="btn btn-primary"
style={{ width: "100%", padding: "1rem", fontSize: "1rem", fontWeight: 700 }}
onClick={handlePay}
disabled={paying}
>
{paying ? "Procesando pago..." : `Pagar $${breakdown?.total ?? 0} USD`}
</button>
)}
{alreadyPaid && (
<button
className="btn btn-primary"
style={{ width: "100%" }}
onClick={() => router.push("/portal/mis-paquetes")}
>
Ver mis paquetes
</button>
)}
<p style={{ marginTop: "1rem", fontSize: ".75rem", color: "var(--gray-400)", textAlign: "center" }}>
Pago seguro · Stripe / PayPhone · Los datos de tu tarjeta nunca se almacenan en nuestros servidores.
</p>
</div>
);
}
export default function PagoPage() {
return (
<Suspense fallback={<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}><div className="spinner" /></div>}>
<PagoContent />
</Suspense>
);
}
+15
View File
@@ -154,4 +154,19 @@ export const api = {
batchUpsert: (items: Array<{ key: string; value: string | null; isActive: boolean }>) =>
request<any>("/integrations/batch", { method: "PUT", body: JSON.stringify({ items }) }),
},
notificationTemplates: {
list: () => request<any[]>("/notification-templates"),
seed: () => request<any>("/notification-templates/seed", { method: "POST" }),
update: (id: string, body: { body: string; subject?: string; isActive?: boolean }) =>
request<any>(`/notification-templates/${id}`, { method: "PUT", body: JSON.stringify(body) }),
},
payments: {
list: (status?: string) => request<any[]>(`/payments${status ? `?status=${status}` : ""}`),
packageDetail: (packageId: string) => request<any>(`/payments/package/${packageId}`),
byTracking: (trackingId: string) => request<any>(`/payments/track/${trackingId}`),
createIntent: (packageId: string, provider = "stripe") =>
request<any>("/payments/intent", { method: "POST", body: JSON.stringify({ packageId, provider }) }),
confirm: (paymentId: string) =>
request<any>(`/payments/${paymentId}/confirm`, { method: "POST" }),
},
};
+60
View File
@@ -11,6 +11,14 @@ datasource db {
url = env("DATABASE_URL")
}
enum PaymentStatus {
PENDIENTE
PROCESANDO
COMPLETADO
FALLIDO
REEMBOLSADO
}
// ─── Enums ───────────────────────────────────────────────────
enum UserRole {
@@ -92,6 +100,8 @@ model Tenant {
b2bRequests B2BRequest[]
warehouses Warehouse[]
integrations Integration[]
notificationTemplates NotificationTemplate[]
payments Payment[]
}
// ─── Usuarios ────────────────────────────────────────────────
@@ -196,6 +206,7 @@ model Package {
statusHistory PackageStatusHistory[]
preAlert PreAlert?
notifications Notification[]
payment Payment?
@@index([tenantId, status])
@@index([userId])
@@ -376,6 +387,55 @@ model Warehouse {
@@index([tenantId])
}
// ─── Pagos (§09 paso 8 / §14 paso 5) ─────────────────────────
/// Registro de pagos vinculados a un paquete.
/// El `providerRef` es el PaymentIntent ID de Stripe (o equivalente).
model Payment {
id String @id @default(cuid())
tenantId String
packageId String @unique
userId String
amount Decimal @db.Decimal(10, 2)
currency String @default("USD")
/// "stripe" | "payphone" | "paypal"
provider String @default("stripe")
/// PaymentIntent ID de Stripe u equivalente
providerRef String?
status PaymentStatus @default(PENDIENTE)
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
package Package @relation(fields: [packageId], references: [id], onDelete: Cascade)
@@index([tenantId, status])
@@index([userId])
}
// ─── Plantillas de notificación (§12) ────────────────────────
/// Plantillas de mensajes para cada evento del ciclo de vida del paquete.
/// Soporta variables: {{trackingId}}, {{firstName}}, {{status}}, {{suiteCode}}
model NotificationTemplate {
id String @id @default(cuid())
tenantId String
/// Evento que dispara la notificación — coincide con PackageStatus
event String // ej: "REGISTRADO", "ENTREGADO"
channel NotificationChannel
subject String? // Solo relevante para EMAIL
body String
isActive Boolean @default(true)
updatedAt DateTime @updatedAt
updatedBy String?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, event, channel])
@@index([tenantId, event])
}
// ─── Integraciones (API keys por tenant) ─────────────────────
/// Configuración de integraciones externas (API keys, endpoints, webhooks).