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 { 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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }>) =>
|
||||
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")
|
||||
}
|
||||
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user