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:
@@ -16,6 +16,7 @@ import { TariffsModule } from "./tariffs/tariffs.module";
|
|||||||
import { ProductsModule } from "./products/products.module";
|
import { ProductsModule } from "./products/products.module";
|
||||||
import { WarehousesModule } from "./warehouses/warehouses.module";
|
import { WarehousesModule } from "./warehouses/warehouses.module";
|
||||||
import { IntegrationsModule } from "./integrations/integrations.module";
|
import { IntegrationsModule } from "./integrations/integrations.module";
|
||||||
|
import { PaymentsModule } from "./payments/payments.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -39,6 +40,7 @@ import { IntegrationsModule } from "./integrations/integrations.module";
|
|||||||
ProductsModule,
|
ProductsModule,
|
||||||
WarehousesModule,
|
WarehousesModule,
|
||||||
IntegrationsModule,
|
IntegrationsModule,
|
||||||
|
PaymentsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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 { Module } from "@nestjs/common";
|
||||||
import { NotificationsService } from "./notifications.service";
|
import { NotificationsService } from "./notifications.service";
|
||||||
|
import { NotificationsController } from "./notifications.controller";
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
|
controllers: [NotificationsController],
|
||||||
providers: [NotificationsService],
|
providers: [NotificationsService],
|
||||||
exports: [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";
|
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()
|
@Injectable()
|
||||||
export class NotificationsService {
|
export class NotificationsService {
|
||||||
private readonly logger = new Logger(NotificationsService.name);
|
private readonly logger = new Logger(NotificationsService.name);
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {}
|
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> {
|
async notifyStatusChange(pkg: any, user: any): Promise<void> {
|
||||||
const statusLabels: Record<string, string> = {
|
const vars: Record<string, string> = {
|
||||||
REGISTRADO: "fue registrado en el sistema",
|
trackingId: pkg.trackingId ?? "",
|
||||||
EN_TRANSITO_BODEGA: "está en tránsito hacia la bodega NJ",
|
firstName: user?.firstName ?? "Cliente",
|
||||||
RECIBIDO_BODEGA: "fue recibido en la bodega de NJ",
|
status: pkg.status ?? "",
|
||||||
EN_VERIFICACION: "está siendo verificado en bodega",
|
suiteCode: user?.suite?.code ?? "",
|
||||||
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 label = statusLabels[pkg.status] ?? `cambió a estado ${pkg.status}`;
|
const channels: Array<"EMAIL" | "WHATSAPP" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
|
||||||
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"];
|
|
||||||
for (const channel of channels) {
|
for (const channel of channels) {
|
||||||
try {
|
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: {
|
data: {
|
||||||
packageId: pkg.id,
|
packageId: pkg.id,
|
||||||
userId: pkg.userId,
|
userId: pkg.userId,
|
||||||
channel,
|
channel,
|
||||||
status: "PENDIENTE",
|
status: "PENDIENTE",
|
||||||
subject,
|
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}`);
|
// STUB: En producción → SendGrid (EMAIL), WhatsApp Business API, etc.
|
||||||
// Mark as sent (stub — in prod this would be async)
|
this.logger.log(`[NOTIF STUB] ${channel} → userId=${pkg.userId} | ${bodyText}`);
|
||||||
await this.prisma.client.notification.updateMany({
|
|
||||||
where: { packageId: pkg.id, userId: pkg.userId, channel, status: "PENDIENTE" },
|
await this.prisma.client.notification.update({
|
||||||
|
where: { id: record.id },
|
||||||
data: { status: "ENVIADO", sentAt: new Date() },
|
data: { status: "ENVIADO", sentAt: new Date() },
|
||||||
});
|
});
|
||||||
} catch (e: unknown) {
|
} 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({
|
return this.prisma.client.notification.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: { createdAt: "desc" },
|
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({
|
return this.prisma.client.notification.findMany({
|
||||||
where: { packageId },
|
where: { packageId },
|
||||||
orderBy: { createdAt: "desc" },
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,13 +6,14 @@ import { getUser, clearAuth } from "@/lib/api";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ href: "/admin", icon: "◈", label: "Dashboard" },
|
{ href: "/admin", icon: "◈", label: "Dashboard" },
|
||||||
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
|
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
|
||||||
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
|
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
|
||||||
{ href: "/admin/configuracion", icon: "⚙️", label: "Configuración" },
|
{ href: "/admin/notificaciones", icon: "📋", label: "Notificaciones" },
|
||||||
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
|
{ href: "/admin/configuracion", icon: "⚙️", label: "Configuración" },
|
||||||
{ href: "/admin/auditoria", icon: "🔍", label: "Auditoría" },
|
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
|
||||||
{ href: "/bodega", icon: "📦", label: "→ Bodega" },
|
{ href: "/admin/auditoria", icon: "🔍", label: "Auditoría" },
|
||||||
|
{ href: "/bodega", icon: "📦", label: "→ Bodega" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,66 +2,130 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "@/lib/api";
|
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() {
|
export default function ReportesPage() {
|
||||||
const [users, setUsers] = useState<any[]>([]);
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
const [packages, setPackages] = useState<any[]>([]);
|
const [packages, setPackages] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([api.users.list(), api.packages.list()])
|
Promise.all([api.users.list(), api.packages.list()])
|
||||||
.then(([u, p]) => { setUsers(u); setPackages(p); })
|
.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> = {};
|
const byStatus: Record<string, number> = {};
|
||||||
packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; });
|
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> = {};
|
const byRole: Record<string, number> = {};
|
||||||
users.forEach(u => { byRole[u.role] = (byRole[u.role] ?? 0) + 1; });
|
users.forEach(u => { byRole[u.role] = (byRole[u.role] ?? 0) + 1; });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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" }}>
|
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
|
||||||
{[
|
{[
|
||||||
{ label: "Total paquetes", value: packages.length, color: "var(--primary)" },
|
{ label: "Total paquetes", value: packages.length, color: "var(--primary)" },
|
||||||
{ label: "Entregados", value: byStatus["ENTREGADO"] ?? 0, color: "var(--green)" },
|
{ label: "Entregados", value: delivered, color: "var(--green)" },
|
||||||
{ label: "En tránsito", value: byStatus["EN_CAMINO_A_ECUADOR"] ?? 0, color: "var(--yellow)" },
|
{ label: "En tránsito", value: inTransit, color: "var(--yellow)" },
|
||||||
{ label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC")}`, color: "var(--accent)" },
|
{ 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 => (
|
].map(s => (
|
||||||
<div key={s.label} className="stat-card">
|
<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 className="stat-label">{s.label}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid-2" style={{ gap: "1.5rem" }}>
|
<div className="grid-2" style={{ gap: "1.5rem" }}>
|
||||||
|
{/* Paquetes por estado */}
|
||||||
<div className="card">
|
<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">
|
<div className="card-body">
|
||||||
{Object.entries(byStatus).map(([status, count]) => (
|
{Object.keys(STATUS_COLORS).map(status => {
|
||||||
<div key={status} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
|
const count = byStatus[status] ?? 0;
|
||||||
<span style={{ fontSize: ".9rem" }}>{status.replace(/_/g," ")}</span>
|
const pct = packages.length ? Math.round((count / packages.length) * 100) : 0;
|
||||||
<span style={{ fontWeight: 700 }}>{count}</span>
|
return (
|
||||||
</div>
|
<div key={status} style={{ marginBottom: ".75rem" }}>
|
||||||
))}
|
<div style={{ display:"flex", justifyContent:"space-between", marginBottom:".2rem" }}>
|
||||||
{Object.keys(byStatus).length === 0 && <p style={{ color: "var(--gray-500)" }}>Sin datos.</p>}
|
<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>
|
</div>
|
||||||
<div className="card">
|
|
||||||
<div className="card-header"><span className="font-semibold">Usuarios por rol</span></div>
|
<div style={{ display:"flex", flexDirection:"column", gap:"1.5rem" }}>
|
||||||
<div className="card-body">
|
{/* Usuarios por rol */}
|
||||||
{Object.entries(byRole).map(([role, count]) => (
|
<div className="card">
|
||||||
<div key={role} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
|
<div className="card-header"><span className="font-semibold">Usuarios por rol (§06)</span></div>
|
||||||
<span style={{ fontSize: ".9rem" }}>{role}</span>
|
<div className="card-body">
|
||||||
<span style={{ fontWeight: 700 }}>{count}</span>
|
{Object.entries(byRole).map(([role, count]) => (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,20 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "@/lib/api";
|
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() {
|
export default function BodegaPaquetesPage() {
|
||||||
const [packages, setPackages] = useState<any[]>([]);
|
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");
|
||||||
|
}
|
||||||
@@ -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-key { font-weight: 500; color: var(--gray-700); min-width: 110px; }
|
||||||
.address-box .address-val { font-family: monospace; color: var(--gray-900); }
|
.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 ─────────────────────────────────────── */
|
/* ─── Responsive ─────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
|
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { WhatsAppFloat } from "./_components/whatsapp-float";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Moraworld Imports — Tu casillero en New Jersey para Ecuador",
|
title: "Moraworld Imports — Tu casillero en New Jersey para Ecuador",
|
||||||
@@ -445,6 +446,7 @@ export default function HomePage() {
|
|||||||
<RolesSection />
|
<RolesSection />
|
||||||
<CompaniesSection />
|
<CompaniesSection />
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<WhatsAppFloat />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,96 +3,149 @@ import { useEffect, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
// §08 — 11 estados oficiales
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
RECIBIDO_EN_NJ: "Recibido en NJ", EN_PROCESO: "En proceso",
|
REGISTRADO: "Registrado",
|
||||||
EN_CAMINO_A_ECUADOR: "En camino a Ecuador", EN_ADUANA: "En aduana",
|
EN_TRANSITO_BODEGA: "En tránsito a NJ",
|
||||||
EN_BODEGA_EC: "En bodega EC", LISTO_PARA_RETIRO: "Listo para retiro",
|
RECIBIDO_BODEGA: "Recibido en NJ",
|
||||||
ENTREGADO: "Entregado", RETENIDO_ADUANA: "Retenido", DEVUELTO: "Devuelto",
|
EN_VERIFICACION: "En verificación",
|
||||||
PERDIDO: "Perdido", CANCELADO: "Cancelado",
|
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> = {
|
const STATUS_BADGE: Record<string, string> = {
|
||||||
RECIBIDO_EN_NJ: "badge-blue", EN_PROCESO: "badge-yellow",
|
REGISTRADO: "badge-gray",
|
||||||
EN_CAMINO_A_ECUADOR: "badge-orange", EN_ADUANA: "badge-yellow",
|
EN_TRANSITO_BODEGA: "badge-yellow",
|
||||||
EN_BODEGA_EC: "badge-blue", LISTO_PARA_RETIRO: "badge-green",
|
RECIBIDO_BODEGA: "badge-blue",
|
||||||
ENTREGADO: "badge-green", RETENIDO_ADUANA: "badge-red",
|
EN_VERIFICACION: "badge-yellow",
|
||||||
DEVUELTO: "badge-red", PERDIDO: "badge-red", CANCELADO: "badge-gray",
|
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() {
|
export default function MisPaquetesPage() {
|
||||||
const [packages, setPackages] = useState<any[]>([]);
|
const [packages, setPackages] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [selected, setSelected] = useState<any | null>(null);
|
const [selected, setSelected] = useState<any | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.packages.list().then(setPackages).catch(() => {}).finally(() => setLoading(false));
|
api.packages.list()
|
||||||
|
.then(setPackages)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filtered = packages.filter(p =>
|
const filtered = packages.filter(p =>
|
||||||
!filter ||
|
!filter ||
|
||||||
p.trackingId?.toLowerCase().includes(filter.toLowerCase()) ||
|
p.trackingId?.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
p.trackingNumber?.toLowerCase().includes(filter.toLowerCase()) ||
|
|
||||||
p.description?.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 (
|
return (
|
||||||
<div>
|
<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>
|
<div>
|
||||||
<h1 className="dash-page-title">Mis Paquetes</h1>
|
<h1 className="dash-page-title">Mis Paquetes</h1>
|
||||||
<p className="dash-page-subtitle">Historial completo de tus envíos.</p>
|
<p className="dash-page-subtitle">Historial completo de tus envíos.</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
className="input" style={{ maxWidth: 280 }}
|
className="form-input"
|
||||||
|
style={{ maxWidth: 280 }}
|
||||||
placeholder="Buscar por código o descripción..."
|
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>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
|
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
|
||||||
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}>📦</div>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
{filtered.map(p => (
|
{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 className="card-body" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: "1rem" }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 700, fontSize: "1rem", color: "var(--primary)" }}>{p.trackingId}</div>
|
<div style={{ fontWeight: 700, fontSize: "1rem", color: "var(--primary)" }}>{p.trackingId}</div>
|
||||||
<div style={{ fontSize: ".875rem", color: "var(--gray-500)", marginTop: ".2rem" }}>
|
<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>
|
||||||
<div style={{ fontSize: ".8rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
|
<div style={{ fontSize: ".8rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
|
||||||
{new Date(p.createdAt).toLocaleDateString("es-EC")}
|
{new Date(p.createdAt).toLocaleDateString("es-EC")}
|
||||||
|
{p.actualWeight ? ` · ${p.actualWeight} lb` : p.declaredWeight ? ` · ~${p.declaredWeight} lb (declarado)` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: "right" }}>
|
<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>
|
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`}>
|
||||||
{p.weightLb && <div style={{ fontSize: ".8rem", color: "var(--gray-500)", marginTop: ".4rem" }}>{p.weightLb} lb</div>}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
{/* Historial expandido */}
|
|
||||||
|
{/* Detalle expandido */}
|
||||||
{selected?.id === p.id && (
|
{selected?.id === p.id && (
|
||||||
<div className="card-footer">
|
<div className="card-footer" style={{ borderTop: "1px solid var(--gray-100)", paddingTop: "1rem" }}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: ".75rem" }}>Historial de estados</div>
|
<div style={{ fontWeight: 600, marginBottom: ".75rem", fontSize: ".9rem" }}>Historial de estados</div>
|
||||||
{p.statusHistory?.length ? (
|
{p.statusHistory?.length ? (
|
||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
{p.statusHistory.map((h: any, i: number) => (
|
{p.statusHistory.map((h: any, i: number) => (
|
||||||
<div key={h.id} className="timeline-item">
|
<div key={h.id} className="timeline-item">
|
||||||
<div className={`timeline-dot ${i === 0 ? "current" : "active"}`} />
|
<div className={`timeline-dot ${i === 0 ? "current" : "active"}`} />
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600, fontSize: ".875rem" }}>{STATUS_LABEL[h.status] ?? h.status}</div>
|
<div style={{ fontWeight: 600, fontSize: ".875rem" }}>
|
||||||
{h.notes && <div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{h.notes}</div>}
|
{STATUS_LABEL[h.status] ?? h.status}
|
||||||
<div style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".2rem" }}>{new Date(h.createdAt).toLocaleString("es-EC")}</div>
|
</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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -154,4 +154,19 @@ export const api = {
|
|||||||
batchUpsert: (items: Array<{ key: string; value: string | null; isActive: boolean }>) =>
|
batchUpsert: (items: Array<{ key: string; value: string | null; isActive: boolean }>) =>
|
||||||
request<any>("/integrations/batch", { method: "PUT", body: JSON.stringify({ items }) }),
|
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" }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PaymentStatus {
|
||||||
|
PENDIENTE
|
||||||
|
PROCESANDO
|
||||||
|
COMPLETADO
|
||||||
|
FALLIDO
|
||||||
|
REEMBOLSADO
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Enums ───────────────────────────────────────────────────
|
// ─── Enums ───────────────────────────────────────────────────
|
||||||
|
|
||||||
enum UserRole {
|
enum UserRole {
|
||||||
@@ -92,6 +100,8 @@ model Tenant {
|
|||||||
b2bRequests B2BRequest[]
|
b2bRequests B2BRequest[]
|
||||||
warehouses Warehouse[]
|
warehouses Warehouse[]
|
||||||
integrations Integration[]
|
integrations Integration[]
|
||||||
|
notificationTemplates NotificationTemplate[]
|
||||||
|
payments Payment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Usuarios ────────────────────────────────────────────────
|
// ─── Usuarios ────────────────────────────────────────────────
|
||||||
@@ -196,6 +206,7 @@ model Package {
|
|||||||
statusHistory PackageStatusHistory[]
|
statusHistory PackageStatusHistory[]
|
||||||
preAlert PreAlert?
|
preAlert PreAlert?
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
|
payment Payment?
|
||||||
|
|
||||||
@@index([tenantId, status])
|
@@index([tenantId, status])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -376,6 +387,55 @@ model Warehouse {
|
|||||||
@@index([tenantId])
|
@@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) ─────────────────────
|
// ─── Integraciones (API keys por tenant) ─────────────────────
|
||||||
|
|
||||||
/// Configuración de integraciones externas (API keys, endpoints, webhooks).
|
/// Configuración de integraciones externas (API keys, endpoints, webhooks).
|
||||||
|
|||||||
Reference in New Issue
Block a user