feat: consolidations, aduanero portal, payments admin, bug fixes, tests

## Bugs corregidos
- bodega/page.tsx: estados correctos §08 (RECIBIDO_BODEGA, EN_VERIFICACION, EN_TRANSITO_ECUADOR)
- admin/page.tsx: carga paquetes reales, alertas de incidencias/B2B/cobros pendientes, barras de estado

## Nuevos portales y páginas
- /aduanero/ — portal propio para AGENTE_ADUANERO (layout + dashboard §11 + declaraciones DSI)
- /aduanero/declaraciones — cola de declaraciones con formulario DSI y detección DAI automática
- /admin/pagos — gestión de cobros: KPIs, filtros por estado, tabla con breakdown
- /portal/consolidacion — cliente crea/gestiona consolidaciones §21
- /bodega/consolidacion — operador cierra y despacha consolidaciones (→ EN_TRANSITO_ECUADOR)

## API nueva (ConsolidationsModule)
- GET/POST /consolidations
- GET /consolidations/:id
- POST /consolidations/:id/packages
- DELETE /consolidations/:id/packages/:packageId
- POST /consolidations/:id/close
- POST /consolidations/:id/dispatch (→ actualiza paquetes a EN_TRANSITO_ECUADOR)

## Prisma schema v0.5
- ConsolidationStatus enum (ABIERTA, CERRADA, DESPACHADA, ENTREGADA, CANCELADA)
- Consolidation model con totales calculados (totalWeightLb, totalValue)
- ConsolidationPackage (tabla intermedia, un paquete = una consolidación)
- db push aplicado a remote DB (46.202.93.92)

## Navegación
- Login: AGENTE_ADUANERO → /aduanero (ya no /bodega)
- Bodega nav: añadido Consolidaciones
- Portal nav: añadido Consolidar
- Admin nav: añadido Cobros

## Tests (104 total, 8 suites)
- payments.service.spec.ts: 16 tests (createIntent, confirm, list, findByPackageForUser)
- notifications.service.spec.ts: 12 tests (getTemplates, updateTemplate, seedDefaultTemplates, notifyStatusChange, findByUser)

## Legal §21
- Registro: aviso LOPDP Ecuador + normativa NJ en footer del formulario
- Landing footer: aviso detallado de protección de datos LOPDP/NJ

## api.ts: consolidations.* client methods
This commit is contained in:
Lizandro Guarnizo
2026-06-01 18:29:26 -05:00
parent 5565eef554
commit 7682b11dff
23 changed files with 2158 additions and 95 deletions
+1
View File
@@ -38,3 +38,4 @@ coverage/
# Coolify / Docker local overrides
docker-compose.override.yml
.tasker
+2
View File
@@ -17,6 +17,7 @@ import { ProductsModule } from "./products/products.module";
import { WarehousesModule } from "./warehouses/warehouses.module";
import { IntegrationsModule } from "./integrations/integrations.module";
import { PaymentsModule } from "./payments/payments.module";
import { ConsolidationsModule } from "./consolidations/consolidations.module";
@Module({
imports: [
@@ -41,6 +42,7 @@ import { PaymentsModule } from "./payments/payments.module";
WarehousesModule,
IntegrationsModule,
PaymentsModule,
ConsolidationsModule,
],
})
export class AppModule {}
@@ -0,0 +1,64 @@
import {
Controller, Get, Post, Delete, Body, Param, Request,
UseGuards, Query,
} from "@nestjs/common";
import { JwtAuthGuard } from "../auth/guards/auth.guard";
import { ConsolidationsService } from "./consolidations.service";
class CreateConsolidationDto { notes?: string; }
class AddPackageDto { packageId!: string; }
class CloseDto { courierTracking?: string; }
class DispatchDto { courierTracking!: string; }
@Controller("consolidations")
@UseGuards(JwtAuthGuard)
export class ConsolidationsController {
constructor(private readonly svc: ConsolidationsService) {}
/** GET /consolidations — lista. Cliente ve solo las suyas; operador/admin ve todas */
@Get()
list(@Request() req: any): Promise<any[]> {
const isClient = req.user.role === "CLIENTE";
return this.svc.list(req.user.tenantId, isClient ? req.user.id : undefined);
}
/** GET /consolidations/:id */
@Get(":id")
findOne(@Param("id") id: string, @Request() req: any): Promise<any> {
return this.svc.findOne(id, req.user.tenantId);
}
/** POST /consolidations — el cliente crea una consolidación */
@Post()
create(@Body() dto: CreateConsolidationDto, @Request() req: any): Promise<any> {
return this.svc.create(req.user.tenantId, req.user.id, dto.notes);
}
/** POST /consolidations/:id/packages — agrega paquete */
@Post(":id/packages")
addPackage(@Param("id") id: string, @Body() dto: AddPackageDto, @Request() req: any): Promise<any> {
return this.svc.addPackage(id, dto.packageId, req.user.tenantId);
}
/** DELETE /consolidations/:id/packages/:packageId — quita paquete */
@Delete(":id/packages/:packageId")
removePackage(
@Param("id") id: string,
@Param("packageId") packageId: string,
@Request() req: any,
): Promise<any> {
return this.svc.removePackage(id, packageId, req.user.tenantId);
}
/** POST /consolidations/:id/close — cierra la consolidación */
@Post(":id/close")
close(@Param("id") id: string, @Body() dto: CloseDto, @Request() req: any): Promise<any> {
return this.svc.close(id, req.user.tenantId, dto.courierTracking);
}
/** POST /consolidations/:id/dispatch — despacha y mueve paquetes a EN_TRANSITO_ECUADOR */
@Post(":id/dispatch")
dispatch(@Param("id") id: string, @Body() dto: DispatchDto, @Request() req: any): Promise<any> {
return this.svc.dispatch(id, req.user.tenantId, dto.courierTracking);
}
}
@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { ConsolidationsService } from "./consolidations.service";
import { ConsolidationsController } from "./consolidations.controller";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
imports: [PrismaModule],
controllers: [ConsolidationsController],
providers: [ConsolidationsService],
exports: [ConsolidationsService],
})
export class ConsolidationsModule {}
@@ -0,0 +1,162 @@
import {
Injectable,
NotFoundException,
BadRequestException,
Logger,
} from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
function genCode(): string {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
return `CON-${date}-${rand}`;
}
@Injectable()
export class ConsolidationsService {
private readonly logger = new Logger(ConsolidationsService.name);
constructor(private prisma: PrismaService) {}
/** Lista consolidaciones del tenant. Cliente solo ve las suyas. */
async list(tenantId: string, userId?: string): Promise<any[]> {
return this.prisma.client.consolidation.findMany({
where: { tenantId, ...(userId ? { userId } : {}) },
include: {
packages: {
include: {
package: { select: { id: true, trackingId: true, description: true, declaredValue: true, actualWeight: true, status: true } },
},
},
},
orderBy: { createdAt: "desc" },
});
}
/** Obtiene una consolidación por ID */
async findOne(id: string, tenantId: string): Promise<any> {
const c = await this.prisma.client.consolidation.findFirst({
where: { id, tenantId },
include: {
packages: {
include: {
package: { select: { id: true, trackingId: true, description: true, declaredValue: true, actualWeight: true, status: true } },
},
},
},
});
if (!c) throw new NotFoundException("Consolidación no encontrada");
return c;
}
/** Crea una consolidación vacía para un cliente */
async create(tenantId: string, userId: string, notes?: string): Promise<any> {
return this.prisma.client.consolidation.create({
data: { tenantId, userId, code: genCode(), notes, status: "ABIERTA" },
});
}
/** Agrega un paquete a la consolidación (§21 — solo paquetes VERIFICADOS) */
async addPackage(id: string, packageId: string, tenantId: string): Promise<any> {
const c = await this.prisma.client.consolidation.findFirst({ where: { id, tenantId } });
if (!c) throw new NotFoundException("Consolidación no encontrada");
if (c.status !== "ABIERTA") throw new BadRequestException("Solo se pueden agregar paquetes a consolidaciones ABIERTAS");
const pkg = await this.prisma.client.package.findFirst({ where: { id: packageId, tenantId } });
if (!pkg) throw new NotFoundException("Paquete no encontrado");
if (!["VERIFICADO", "RECIBIDO_BODEGA"].includes(pkg.status)) {
throw new BadRequestException(`El paquete debe estar en estado VERIFICADO o RECIBIDO_BODEGA. Estado actual: ${pkg.status}`);
}
// Verificar que no esté ya en otra consolidación
const existing = await this.prisma.client.consolidationPackage.findUnique({ where: { packageId } });
if (existing) throw new BadRequestException("El paquete ya está en una consolidación");
await this.prisma.client.consolidationPackage.create({
data: { consolidationId: id, packageId },
});
return this.recalcTotals(id, tenantId);
}
/** Quita un paquete de la consolidación */
async removePackage(id: string, packageId: string, tenantId: string): Promise<any> {
const c = await this.prisma.client.consolidation.findFirst({ where: { id, tenantId } });
if (!c) throw new NotFoundException("Consolidación no encontrada");
if (c.status !== "ABIERTA") throw new BadRequestException("No se pueden quitar paquetes de una consolidación cerrada");
await this.prisma.client.consolidationPackage.deleteMany({
where: { consolidationId: id, packageId },
});
return this.recalcTotals(id, tenantId);
}
/** Cierra la consolidación y la marca lista para despacho */
async close(id: string, tenantId: string, courierTracking?: string): Promise<any> {
const c = await this.prisma.client.consolidation.findFirst({
where: { id, tenantId },
include: { packages: true },
});
if (!c) throw new NotFoundException("Consolidación no encontrada");
if (c.status !== "ABIERTA") throw new BadRequestException("La consolidación ya está cerrada");
if (c.packages.length === 0) throw new BadRequestException("No se puede cerrar una consolidación vacía");
return this.prisma.client.consolidation.update({
where: { id },
data: { status: "CERRADA", courierTracking: courierTracking ?? null },
});
}
/** Marca como despachada (courier recogió el paquete) */
async dispatch(id: string, tenantId: string, courierTracking: string): Promise<any> {
const c = await this.prisma.client.consolidation.findFirst({ where: { id, tenantId } });
if (!c) throw new NotFoundException("Consolidación no encontrada");
if (c.status !== "CERRADA") throw new BadRequestException("La consolidación debe estar CERRADA para despachar");
// Actualizar todos los paquetes a EN_TRANSITO_ECUADOR
const pkgIds = await this.prisma.client.consolidationPackage.findMany({
where: { consolidationId: id },
select: { packageId: true },
});
await this.prisma.client.package.updateMany({
where: { id: { in: pkgIds.map(p => p.packageId) } },
data: { status: "EN_TRANSITO_ECUADOR" },
});
this.logger.log(`[CONSOLIDATION] Dispatched ${id}${pkgIds.length} packages → EN_TRANSITO_ECUADOR`);
return this.prisma.client.consolidation.update({
where: { id },
data: { status: "DESPACHADA", courierTracking },
});
}
/** Recalcula totales de peso y valor */
private async recalcTotals(id: string, _tenantId: string): Promise<any> {
const cp = await this.prisma.client.consolidationPackage.findMany({
where: { consolidationId: id },
include: { package: { select: { declaredValue: true, actualWeight: true, declaredWeight: true } } },
});
const totalWeightLb = cp.reduce((s, cp) =>
s + Number(cp.package.actualWeight ?? cp.package.declaredWeight ?? 0), 0);
const totalValue = cp.reduce((s, cp) => s + Number(cp.package.declaredValue ?? 0), 0);
return this.prisma.client.consolidation.update({
where: { id },
data: {
totalWeightLb: Math.round(totalWeightLb * 100) / 100,
totalValue: Math.round(totalValue * 100) / 100,
},
include: {
packages: {
include: {
package: { select: { id: true, trackingId: true, description: true, declaredValue: true, actualWeight: true, status: true } },
},
},
},
});
}
}
@@ -0,0 +1,168 @@
import { Test, TestingModule } from "@nestjs/testing";
import { NotFoundException } from "@nestjs/common";
import { NotificationsService } from "./notifications.service";
import { PrismaService } from "../prisma/prisma.service";
const mockTemplate = {
id: "tpl-1",
tenantId: "tenant-1",
event: "REGISTRADO",
channel: "EMAIL",
subject: "Tu paquete {{trackingId}} fue registrado",
body: "Hola {{firstName}}, tu paquete {{trackingId}} fue registrado.",
isActive: true,
updatedAt: new Date(),
};
const mockPackage = {
id: "pkg-1",
tenantId: "tenant-1",
userId: "user-1",
trackingId: "EC-20260601-ABCDEF",
status: "REGISTRADO",
};
const mockUser = { id: "user-1", firstName: "Juan", lastName: "Pérez", suite: { code: "EC-00001" } };
const mockPrisma = {
client: {
notificationTemplate: {
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
upsert: jest.fn(),
},
notification: {
create: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
findMany: jest.fn(),
},
},
};
describe("NotificationsService", () => {
let service: NotificationsService;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
NotificationsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<NotificationsService>(NotificationsService);
});
// ─── getTemplates ────────────────────────────────────────────
describe("getTemplates", () => {
it("devuelve plantillas del tenant", async () => {
mockPrisma.client.notificationTemplate.findMany.mockResolvedValue([mockTemplate]);
const result = await service.getTemplates("tenant-1");
expect(result).toHaveLength(1);
expect(mockPrisma.client.notificationTemplate.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { tenantId: "tenant-1" } })
);
});
});
// ─── updateTemplate ──────────────────────────────────────────
describe("updateTemplate", () => {
it("actualiza una plantilla correctamente", async () => {
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue(mockTemplate);
mockPrisma.client.notificationTemplate.update.mockResolvedValue({ ...mockTemplate, body: "Nuevo cuerpo" });
const result = await service.updateTemplate("tpl-1", "tenant-1", "Nuevo cuerpo", undefined, true);
expect(mockPrisma.client.notificationTemplate.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "tpl-1" } })
);
});
it("lanza NotFoundException si la plantilla no existe", async () => {
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue(null);
await expect(service.updateTemplate("invalid", "tenant-1", "body"))
.rejects.toThrow(NotFoundException);
});
it("lanza NotFoundException si el tenantId no coincide", async () => {
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue({ ...mockTemplate, tenantId: "otro-tenant" });
await expect(service.updateTemplate("tpl-1", "tenant-1", "body"))
.rejects.toThrow(NotFoundException);
});
});
// ─── seedDefaultTemplates ───────────────────────────────────
describe("seedDefaultTemplates", () => {
it("crea upserts para 11 eventos × 3 canales = 33 plantillas", async () => {
mockPrisma.client.notificationTemplate.upsert.mockResolvedValue(mockTemplate);
const result = await service.seedDefaultTemplates("tenant-1");
expect(result.seeded).toBe(33);
expect(mockPrisma.client.notificationTemplate.upsert).toHaveBeenCalledTimes(33);
});
it("es idempotente — usa upsert con update vacío", async () => {
mockPrisma.client.notificationTemplate.upsert.mockResolvedValue(mockTemplate);
await service.seedDefaultTemplates("tenant-1");
const call = mockPrisma.client.notificationTemplate.upsert.mock.calls[0][0];
expect(call.update).toEqual({});
});
});
// ─── notifyStatusChange ──────────────────────────────────────
describe("notifyStatusChange", () => {
beforeEach(() => {
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue(null); // usa fallback
mockPrisma.client.notification.create.mockResolvedValue({ id: "notif-1" });
mockPrisma.client.notification.update.mockResolvedValue({});
});
it("crea notificaciones para los 3 canales por defecto", async () => {
await service.notifyStatusChange(mockPackage, mockUser);
expect(mockPrisma.client.notification.create).toHaveBeenCalledTimes(3); // EMAIL, WHATSAPP, PUSH
});
it("interpola {{trackingId}} y {{firstName}} en el body", async () => {
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue({
...mockTemplate,
body: "Hola {{firstName}}, tu paquete es {{trackingId}}.",
});
await service.notifyStatusChange(mockPackage, mockUser);
const createCall = mockPrisma.client.notification.create.mock.calls[0][0];
expect(createCall.data.body).toContain("Juan");
expect(createCall.data.body).toContain("EC-20260601-ABCDEF");
});
it("no envía notificación si la plantilla está inactiva", async () => {
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue({ ...mockTemplate, isActive: false });
await service.notifyStatusChange(mockPackage, mockUser);
// Solo EMAIL está deshabilitado, WHATSAPP y PUSH usan fallback (activo)
// El mock devuelve el template inactivo para todos
expect(mockPrisma.client.notification.create).not.toHaveBeenCalled();
});
it("no lanza excepción si la creación de notificación falla", async () => {
mockPrisma.client.notification.create.mockRejectedValue(new Error("DB error"));
await expect(service.notifyStatusChange(mockPackage, mockUser)).resolves.not.toThrow();
});
});
// ─── findByUser ──────────────────────────────────────────────
describe("findByUser", () => {
it("devuelve notificaciones del usuario ordenadas por fecha", async () => {
mockPrisma.client.notification.findMany.mockResolvedValue([]);
await service.findByUser("user-1");
expect(mockPrisma.client.notification.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1" },
orderBy: { createdAt: "desc" },
take: 20,
})
);
});
});
});
@@ -0,0 +1,203 @@
import { Test, TestingModule } from "@nestjs/testing";
import { NotFoundException, BadRequestException } from "@nestjs/common";
import { PaymentsService } from "./payments.service";
import { PrismaService } from "../prisma/prisma.service";
// Mock del PrismaService
const mockPayment = {
id: "pay-1",
tenantId: "tenant-1",
packageId: "pkg-1",
userId: "user-1",
amount: 35.0,
currency: "USD",
provider: "stripe",
providerRef: "pi_stub_123",
status: "PENDIENTE",
paidAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockPackage = {
id: "pkg-1",
tenantId: "tenant-1",
userId: "user-1",
trackingId: "EC-20260601-ABCDEF",
status: "VERIFICADO",
declaredValue: "100.00",
actualWeight: "3.5",
declaredWeight: "3.0",
description: "Smartphone",
paidAt: null,
payment: null,
};
const mockTariff = {
pricePerLb: "3.50",
insurancePct: "0.02",
fodinfaPct: "0.005",
};
const mockPrisma = {
client: {
payment: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
package: {
findFirst: jest.fn(),
update: jest.fn(),
},
tariff: {
findUnique: jest.fn(),
},
$transaction: jest.fn(),
},
};
describe("PaymentsService", () => {
let service: PaymentsService;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
PaymentsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<PaymentsService>(PaymentsService);
});
// ─── createIntent ───────────────────────────────────────────
describe("createIntent", () => {
beforeEach(() => {
mockPrisma.client.package.findFirst.mockResolvedValue(mockPackage);
mockPrisma.client.tariff.findUnique.mockResolvedValue(mockTariff);
mockPrisma.client.payment.findUnique.mockResolvedValue(null);
mockPrisma.client.payment.create.mockResolvedValue(mockPayment);
});
it("crea un PaymentIntent correctamente", async () => {
const result = await service.createIntent("pkg-1", "user-1", "tenant-1");
expect(mockPrisma.client.payment.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
packageId: "pkg-1",
userId: "user-1",
tenantId: "tenant-1",
status: "PENDIENTE",
provider: "stripe",
}),
})
);
expect(result.status).toBe("PENDIENTE");
});
it("lanza NotFoundException si el paquete no existe", async () => {
mockPrisma.client.package.findFirst.mockResolvedValue(null);
await expect(service.createIntent("invalid", "user-1", "tenant-1"))
.rejects.toThrow(NotFoundException);
});
it("lanza BadRequestException si el paquete ya fue pagado", async () => {
mockPrisma.client.package.findFirst.mockResolvedValue({ ...mockPackage, paidAt: new Date() });
await expect(service.createIntent("pkg-1", "user-1", "tenant-1"))
.rejects.toThrow(BadRequestException);
});
it("reutiliza un intent PENDIENTE existente", async () => {
mockPrisma.client.payment.findUnique.mockResolvedValue(mockPayment);
const result = await service.createIntent("pkg-1", "user-1", "tenant-1");
expect(mockPrisma.client.payment.create).not.toHaveBeenCalled();
expect(result).toEqual(mockPayment);
});
it("calcula el monto correctamente (flete + seguro)", async () => {
// peso 3.5lb × $3.50 = $12.25 flete + $100 × 2% = $2 seguro = $14.25
mockPrisma.client.payment.create.mockImplementation(({ data }: any) =>
Promise.resolve({ ...mockPayment, amount: data.amount })
);
const result = await service.createIntent("pkg-1", "user-1", "tenant-1");
expect(Number(result.amount)).toBeCloseTo(14.25, 1);
});
});
// ─── confirm ─────────────────────────────────────────────────
describe("confirm", () => {
it("confirma el pago y marca el paquete como pagado", async () => {
mockPrisma.client.payment.findUnique.mockResolvedValue(mockPayment);
const confirmed = { ...mockPayment, status: "COMPLETADO", paidAt: new Date() };
mockPrisma.client.$transaction.mockResolvedValue([confirmed, {}]);
const result = await service.confirm("pay-1", "tenant-1");
expect(mockPrisma.client.$transaction).toHaveBeenCalled();
});
it("lanza NotFoundException si el pago no existe", async () => {
mockPrisma.client.payment.findUnique.mockResolvedValue(null);
await expect(service.confirm("invalid", "tenant-1"))
.rejects.toThrow(NotFoundException);
});
it("lanza BadRequestException si el pago ya está completado", async () => {
mockPrisma.client.payment.findUnique.mockResolvedValue({ ...mockPayment, status: "COMPLETADO" });
await expect(service.confirm("pay-1", "tenant-1"))
.rejects.toThrow(BadRequestException);
});
it("lanza NotFoundException si el tenantId no coincide", async () => {
mockPrisma.client.payment.findUnique.mockResolvedValue({ ...mockPayment, tenantId: "other-tenant" });
await expect(service.confirm("pay-1", "tenant-1"))
.rejects.toThrow(NotFoundException);
});
});
// ─── list ─────────────────────────────────────────────────────
describe("list", () => {
it("lista pagos del tenant", async () => {
mockPrisma.client.payment.findMany.mockResolvedValue([mockPayment]);
const result = await service.list("tenant-1");
expect(result).toHaveLength(1);
expect(mockPrisma.client.payment.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ tenantId: "tenant-1" }) })
);
});
it("filtra por status si se provee", async () => {
mockPrisma.client.payment.findMany.mockResolvedValue([]);
await service.list("tenant-1", "COMPLETADO");
expect(mockPrisma.client.payment.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: "COMPLETADO" }),
})
);
});
});
// ─── findByPackageForUser ─────────────────────────────────────
describe("findByPackageForUser", () => {
it("devuelve desglose de costos correcto", async () => {
mockPrisma.client.package.findFirst.mockResolvedValue({ ...mockPackage, payment: mockPayment });
mockPrisma.client.tariff.findUnique.mockResolvedValue(mockTariff);
const result = await service.findByPackageForUser("pkg-1", "user-1", "tenant-1");
expect(result.breakdown).toBeDefined();
expect(result.breakdown.freight).toBeCloseTo(12.25, 1);
expect(result.breakdown.insurance).toBeCloseTo(2.0, 1);
expect(result.breakdown.total).toBeGreaterThan(0);
});
it("lanza NotFoundException si el paquete no pertenece al usuario", async () => {
mockPrisma.client.package.findFirst.mockResolvedValue(null);
await expect(service.findByPackageForUser("pkg-1", "other-user", "tenant-1"))
.rejects.toThrow(NotFoundException);
});
});
});
+1
View File
@@ -9,6 +9,7 @@ const NAV = [
{ href: "/admin", icon: "◈", label: "Dashboard" },
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
{ href: "/admin/pagos", icon: "💳", label: "Cobros" },
{ href: "/admin/notificaciones", icon: "📋", label: "Notificaciones" },
{ href: "/admin/configuracion", icon: "⚙️", label: "Configuración" },
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
+158 -31
View File
@@ -3,72 +3,199 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
// §12 Admin Dashboard — métricas reales de usuarios, paquetes, pagos y B2B
export default function AdminDashboard() {
const [users, setUsers] = useState<any[]>([]);
const [packages, setPackages] = useState<any[]>([]);
const [b2b, setB2b] = useState<any[]>([]);
const [payments, setPayments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([api.users.list(), api.b2b.list()])
.then(([u, b]) => { setUsers(u); setB2b(b); })
.catch(() => {}).finally(() => setLoading(false));
Promise.all([
api.users.list(),
api.packages.list(),
api.b2b.list(),
api.payments.list(),
])
.then(([u, p, b, pay]) => { setUsers(u); setPackages(p); setB2b(b); setPayments(pay); })
.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>
);
// Métricas
const clientes = users.filter(u => u.role === "CLIENTE").length;
const activos = users.filter(u => u.isActive).length;
const b2bPending = b2b.filter(r => r.status === "PENDIENTE").length;
const incidencias = packages.filter(p => p.status === "INCIDENCIA").length;
const enTransito = packages.filter(p => p.status === "EN_TRANSITO_ECUADOR").length;
const entregados = packages.filter(p => p.status === "ENTREGADO").length;
const pendientePago = packages.filter(p => p.status === "VERIFICADO" && !p.paidAt).length;
const ingresoTotal = payments
.filter(p => p.status === "COMPLETADO")
.reduce((s: number, p: any) => s + Number(p.amount), 0);
// Paquetes por estado para mini-gráfico
const byStatus: Record<string, number> = {};
packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; });
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Dashboard Admin</h1></div>
<div className="grid-4" style={{ marginBottom: "2rem" }}>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Dashboard Admin</h1>
<p className="dash-page-subtitle">Vista ejecutiva ingresos, operaciones y alertas del sistema.</p>
</div>
{/* Alertas críticas */}
{(incidencias > 0 || b2bPending > 0 || pendientePago > 0) && (
<div style={{ display: "flex", flexWrap: "wrap", gap: ".75rem", marginBottom: "1.5rem" }}>
{incidencias > 0 && (
<Link href="/admin/reportes" style={{
display: "flex", alignItems: "center", gap: ".5rem", textDecoration: "none",
background: "#FEF2F2", border: "1px solid #FCA5A5", borderRadius: 8,
padding: ".625rem 1rem", fontSize: ".85rem", color: "#991B1B", fontWeight: 600,
}}>
{incidencias} incidencia{incidencias !== 1 ? "s" : ""} abiertas
</Link>
)}
{b2bPending > 0 && (
<div style={{
display: "flex", alignItems: "center", gap: ".5rem",
background: "#FFFBEB", border: "1px solid #FCD34D", borderRadius: 8,
padding: ".625rem 1rem", fontSize: ".85rem", color: "#92400E", fontWeight: 600,
}}>
📋 {b2bPending} solicitud{b2bPending !== 1 ? "es" : ""} B2B pendiente{b2bPending !== 1 ? "s" : ""}
</div>
)}
{pendientePago > 0 && (
<Link href="/admin/pagos" style={{
display: "flex", alignItems: "center", gap: ".5rem", textDecoration: "none",
background: "#EFF6FF", border: "1px solid #93C5FD", borderRadius: 8,
padding: ".625rem 1rem", fontSize: ".85rem", color: "#1E40AF", fontWeight: 600,
}}>
💳 {pendientePago} paquete{pendientePago !== 1 ? "s" : ""} pendiente{pendientePago !== 1 ? "s" : ""} de pago
</Link>
)}
</div>
)}
{/* KPIs principales */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Total usuarios", value: users.length, color: "var(--primary)" },
{ label: "Clientes", value: clientes, color: "var(--green)" },
{ label: "Solicitudes B2B", value: b2bPending, color: "var(--yellow)" },
{ label: "Activos", value: users.filter(u=>u.isActive).length, color: "var(--accent)" },
{ label: "Ingresos cobrados", value: `$${ingresoTotal.toFixed(2)}`, color: "var(--green)", icon: "💰" },
{ label: "Total paquetes", value: packages.length, color: "var(--primary)", icon: "📦" },
{ label: "Entregados", value: entregados, color: "var(--green)", icon: "✅" },
{ label: "En tránsito a EC", value: enTransito, color: "var(--accent)", icon: "✈️" },
{ label: "Clientes activos", value: clientes, color: "var(--primary)", icon: "👤" },
{ label: "Usuarios totales", value: activos, color: "var(--gray-600)", icon: "👥" },
{ label: "Incidencias", value: incidencias, color: "var(--red)", icon: "⚠️" },
{ label: "Pendiente de pago", value: pendientePago, color: "var(--yellow)", icon: "💳" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div style={{ fontSize: "1.25rem", marginBottom: ".25rem" }}>{s.icon}</div>
<div className="stat-value" style={{ color: s.color, fontSize: "1.6rem" }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
{/* Distribución de paquetes por estado */}
<div className="card">
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Últimos usuarios</span>
<Link href="/admin/usuarios" className="btn btn-ghost btn-sm text-primary">Ver todos </Link>
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600 }}>Paquetes por estado</span>
<Link href="/admin/reportes" className="btn btn-ghost" style={{ fontSize: ".8rem", padding: ".25rem .6rem" }}>Reportes </Link>
</div>
<div className="table-wrap" style={{ borderRadius: 0, border: "none" }}>
<div className="card-body">
{[
{ status: "RECIBIDO_BODEGA", label: "Recibidos en bodega", color: "#3B82F6" },
{ status: "EN_VERIFICACION", label: "En verificación", color: "#F59E0B" },
{ status: "VERIFICADO", label: "Verificados", color: "#10B981" },
{ status: "DECLARACION_ADUANERA", label: "Declaración aduanera", color: "#0057FF" },
{ status: "EN_TRANSITO_ECUADOR", label: "En tránsito a Ecuador", color: "#F97316" },
{ status: "EN_ADUANA_ECUADOR", label: "En aduana Ecuador", color: "#EF4444" },
{ status: "ENTREGADO", label: "Entregados", color: "#10B981" },
{ status: "INCIDENCIA", label: "Incidencias", color: "#EF4444" },
].map(({ status, label, color }) => {
const count = byStatus[status] ?? 0;
const pct = packages.length ? Math.round((count / packages.length) * 100) : 0;
return (
<div key={status} style={{ marginBottom: ".6rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: ".15rem" }}>
<span style={{ fontSize: ".8rem", color: "var(--gray-600)" }}>{label}</span>
<span style={{ fontSize: ".8rem", fontWeight: 700 }}>{count}</span>
</div>
<div style={{ height: 4, background: "var(--gray-100)", borderRadius: 999 }}>
<div style={{ height: 4, borderRadius: 999, background: color, width: `${pct}%`, transition: "width .4s" }} />
</div>
</div>
);
})}
{packages.length === 0 && <p style={{ color: "var(--gray-400)", fontSize: ".875rem" }}>Sin paquetes aún.</p>}
</div>
</div>
{/* Panel derecho */}
<div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* Usuarios recientes */}
<div className="card">
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600 }}>Usuarios recientes</span>
<Link href="/admin/usuarios" className="btn btn-ghost" style={{ fontSize: ".8rem", padding: ".25rem .6rem" }}>Ver todos </Link>
</div>
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<thead><tr><th>Nombre</th><th>Email</th><th>Rol</th><th>Estado</th></tr></thead>
<thead><tr><th>Nombre</th><th>Rol</th><th>Estado</th></tr></thead>
<tbody>
{users.slice(0,8).map(u => (
{users.slice(0, 5).map(u => (
<tr key={u.id}>
<td className="font-medium">{u.firstName} {u.lastName}</td>
<td className="text-sm text-muted">{u.email}</td>
<td><span className="badge badge-blue">{u.role}</span></td>
<td><span className={`badge ${u.isActive ? "badge-green" : "badge-gray"}`}>{u.isActive ? "Activo" : "Inactivo"}</span></td>
<td style={{ fontSize: ".82rem" }}>
<div style={{ fontWeight: 600 }}>{u.firstName} {u.lastName}</div>
<div style={{ color: "var(--gray-400)", fontSize: ".75rem" }}>{u.email}</div>
</td>
<td><span className="badge badge-blue" style={{ fontSize: ".7rem" }}>{u.role}</span></td>
<td><span className={`badge ${u.isActive ? "badge-green" : "badge-gray"}`} style={{ fontSize: ".7rem" }}>{u.isActive ? "Activo" : "Inactivo"}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Acciones rápidas */}
<div className="card">
<div className="card-header"><span className="font-semibold">Solicitudes B2B pendientes</span></div>
{b2bPending === 0 ? (
<p style={{ padding: "1.5rem", color: "var(--gray-500)" }}>Sin solicitudes pendientes.</p>
) : (
b2b.filter(r=>r.status==="PENDIENTE").map(r => (
<div key={r.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)" }}>
<div style={{ fontWeight: 600 }}>{r.companyName}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{r.contactEmail}</div>
<div className="card-header"><span style={{ fontWeight: 600 }}>Acciones rápidas</span></div>
<div className="card-body" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".5rem" }}>
{[
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
{ href: "/admin/pagos", icon: "💳", label: "Cobros" },
{ href: "/admin/notificaciones", icon: "📋", label: "Plantillas" },
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
{ href: "/admin/auditoria", icon: "🔍", label: "Auditoría" },
].map(a => (
<Link key={a.href} href={a.href}
style={{
display: "flex", alignItems: "center", gap: ".5rem",
padding: ".625rem .75rem", borderRadius: 8, textDecoration: "none",
color: "var(--gray-700)", background: "var(--gray-50)",
fontSize: ".82rem", fontWeight: 500, transition: "background .15s",
}}
onMouseEnter={e => { e.currentTarget.style.background = "var(--gray-100)"; }}
onMouseLeave={e => { e.currentTarget.style.background = "var(--gray-50)"; }}
>
<span>{a.icon}</span>{a.label}
</Link>
))}
</div>
</div>
))
)}
</div>
</div>
</div>
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
// Admin — Gestión de cobros / pagos del tenant
const STATUS_LABEL: Record<string, string> = {
PENDIENTE: "Pendiente",
PROCESANDO: "Procesando",
COMPLETADO: "Completado",
FALLIDO: "Fallido",
REEMBOLSADO: "Reembolsado",
};
const STATUS_BADGE: Record<string, string> = {
PENDIENTE: "badge-yellow",
PROCESANDO: "badge-blue",
COMPLETADO: "badge-green",
FALLIDO: "badge-red",
REEMBOLSADO: "badge-gray",
};
export default function AdminPagosPage() {
const [payments, setPayments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<string>("ALL");
const [search, setSearch] = useState("");
const load = () => {
setLoading(true);
api.payments.list()
.then(setPayments)
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const filtered = payments.filter(p => {
const matchStatus = filter === "ALL" || p.status === filter;
const matchSearch = !search ||
p.package?.trackingId?.toLowerCase().includes(search.toLowerCase()) ||
p.providerRef?.toLowerCase().includes(search.toLowerCase());
return matchStatus && matchSearch;
});
// Métricas
const total = payments.reduce((s, p) => s + Number(p.amount), 0);
const cobrado = payments.filter(p => p.status === "COMPLETADO").reduce((s, p) => s + Number(p.amount), 0);
const pendiente = payments.filter(p => p.status === "PENDIENTE").reduce((s, p) => s + Number(p.amount), 0);
const fallidos = payments.filter(p => p.status === "FALLIDO").length;
if (loading) return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
);
return (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Gestión de Cobros</h1>
<p className="dash-page-subtitle">Historial de pagos vinculados a paquetes del tenant.</p>
</div>
{/* KPIs */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Total facturado", value: `$${total.toFixed(2)}`, color: "var(--primary)" },
{ label: "Cobrado", value: `$${cobrado.toFixed(2)}`, color: "var(--green)" },
{ label: "Pendiente cobro", value: `$${pendiente.toFixed(2)}`,color: "var(--yellow)" },
{ label: "Pagos fallidos", value: fallidos, color: "var(--red)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
{/* Filtros */}
<div style={{ display: "flex", gap: "1rem", marginBottom: "1.25rem", flexWrap: "wrap", alignItems: "center" }}>
<input
className="form-input"
style={{ maxWidth: 260 }}
placeholder="Buscar por tracking o ref. pago..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<div style={{ display: "flex", gap: ".5rem" }}>
{["ALL", "PENDIENTE", "COMPLETADO", "FALLIDO", "REEMBOLSADO"].map(s => (
<button
key={s}
className={`btn ${filter === s ? "btn-primary" : "btn-ghost"}`}
style={{ fontSize: ".8rem", padding: ".35rem .75rem" }}
onClick={() => setFilter(s)}
>
{s === "ALL" ? "Todos" : STATUS_LABEL[s]}
</button>
))}
</div>
<span style={{ marginLeft: "auto", fontSize: ".85rem", color: "var(--gray-500)" }}>
{filtered.length} registro{filtered.length !== 1 ? "s" : ""}
</span>
</div>
{/* Tabla */}
<div className="card">
<div className="table-wrap" style={{ border: "none" }}>
<table>
<thead>
<tr>
<th>Tracking</th>
<th>Descripción</th>
<th>Proveedor</th>
<th>Referencia</th>
<th>Monto</th>
<th>Estado</th>
<th>Fecha</th>
</tr>
</thead>
<tbody>
{filtered.map(p => (
<tr key={p.id}>
<td style={{ fontWeight: 700, color: "var(--primary)", fontSize: ".82rem" }}>
{p.package?.trackingId ?? "—"}
</td>
<td style={{ fontSize: ".8rem", color: "var(--gray-600)", maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{p.package?.description ?? "—"}
</td>
<td style={{ fontSize: ".82rem", textTransform: "capitalize" }}>{p.provider}</td>
<td style={{ fontSize: ".78rem", fontFamily: "monospace", color: "var(--gray-500)" }}>
{p.providerRef ?? "—"}
</td>
<td style={{ fontWeight: 700, fontSize: ".9rem" }}>${Number(p.amount).toFixed(2)}</td>
<td>
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`} style={{ fontSize: ".7rem" }}>
{STATUS_LABEL[p.status] ?? p.status}
</span>
</td>
<td style={{ fontSize: ".78rem", color: "var(--gray-500)" }}>
{p.paidAt
? new Date(p.paidAt).toLocaleString("es-EC")
: new Date(p.createdAt).toLocaleDateString("es-EC")}
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: "center", color: "var(--gray-400)", padding: "3rem" }}>
Sin registros de pago.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
@@ -0,0 +1,211 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
// §11 — Declaraciones SENAE desde el portal del agente aduanero
// Misma lógica que /bodega/declaraciones pero accesible solo para AGENTE_ADUANERO
const CATEGORIES = [
{ value: "REGIMEN_4X4", label: "Régimen 4×4 — 0% (≤$400, ≤4kg)" },
{ value: "CATEGORIA_B", label: "Categoría B — 10% (bienes generales)" },
{ value: "CATEGORIA_C", label: "Categoría C — 20% (textiles, calzado, hogar)" },
{ value: "CATEGORIA_D", label: "Categoría D — 015% (electrónicos)" },
];
function autoCategory(pkg: any): string {
const value = parseFloat(pkg.declaredValue ?? 0);
const weightKg = parseFloat(pkg.actualWeight ?? pkg.declaredWeight ?? 0) * 0.453592;
if (value <= 400 && weightKg <= 4) return "REGIMEN_4X4";
return "CATEGORIA_B";
}
export default function AduaneroDeclaracionesPage() {
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<any | null>(null);
const [form, setForm] = useState({ category: "REGIMEN_4X4", agentNotes: "" });
const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
const load = () => {
setLoading(true);
api.packages.pendingDeclaration()
.then(setPackages)
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const handleSelect = (pkg: any) => {
setSelected(pkg);
setForm({ category: autoCategory(pkg), agentNotes: "" });
setMsg(null);
};
const handleDeclare = async (e: React.FormEvent) => {
e.preventDefault();
if (!selected) return;
setSubmitting(true);
setMsg(null);
try {
const result = await api.packages.senaeDeclare(selected.id, form);
setMsg({ type: "success", text: `DSI generada. N° Autorización SENAE: ${result.authNumber ?? result.senaeAuthNumber ?? "STUB-" + Date.now()}` });
setSelected(null);
load();
} catch (err: any) {
setMsg({ type: "error", text: err.message ?? "Error al generar declaración" });
} finally {
setSubmitting(false);
}
};
return (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Declaraciones SENAE</h1>
<p className="dash-page-subtitle">
Cola de paquetes verificados pendientes de DSI. Genera la Declaración Simplificada (DSI) o inicia el proceso formal (DAI). §11
</p>
</div>
{msg && (
<div style={{
padding: ".875rem 1.25rem", borderRadius: 8, marginBottom: "1.25rem",
background: msg.type === "success" ? "#F0FDF4" : "#FEF2F2",
border: `1px solid ${msg.type === "success" ? "#86EFAC" : "#FCA5A5"}`,
color: msg.type === "success" ? "#166534" : "#991B1B",
fontWeight: 600, fontSize: ".875rem",
}}>
{msg.type === "success" ? "✅ " : "❌ "}{msg.text}
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem", alignItems: "start" }}>
{/* Cola */}
<div className="card">
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600 }}>Pendientes de declaración</span>
<span className="badge badge-orange">{packages.length}</span>
</div>
{loading ? (
<div style={{ display: "flex", justifyContent: "center", padding: "2rem" }}><div className="spinner" /></div>
) : packages.length === 0 ? (
<div style={{ padding: "2.5rem", textAlign: "center", color: "var(--gray-400)" }}>
<div style={{ fontSize: "2rem", marginBottom: ".5rem" }}>🎉</div>
<p style={{ fontSize: ".9rem" }}>No hay paquetes pendientes de declaración.</p>
<p style={{ fontSize: ".8rem", color: "var(--gray-300)", marginTop: ".25rem" }}>
Los paquetes en estado VERIFICADO aparecerán aquí.
</p>
</div>
) : (
<div className="table-wrap" style={{ border: "none" }}>
<table>
<thead>
<tr><th>Tracking</th><th>Valor</th><th>Peso</th><th>Régimen</th></tr>
</thead>
<tbody>
{packages.map(p => {
const supera4x4 = parseFloat(p.declaredValue ?? 0) > 400;
return (
<tr
key={p.id}
onClick={() => handleSelect(p)}
style={{ cursor: "pointer", background: selected?.id === p.id ? "var(--blue-50, #EFF6FF)" : undefined }}
>
<td style={{ fontWeight: 700, color: "var(--primary)", fontSize: ".82rem" }}>{p.trackingId}</td>
<td style={{ fontWeight: 600, color: supera4x4 ? "var(--red)" : "var(--green)", fontSize: ".82rem" }}>
${p.declaredValue}
</td>
<td style={{ fontSize: ".82rem" }}>{p.actualWeight ? `${p.actualWeight}lb` : "—"}</td>
<td>
<span className={`badge ${supera4x4 ? "badge-red" : "badge-green"}`} style={{ fontSize: ".7rem" }}>
{supera4x4 ? "DAI" : "4×4"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Formulario DSI */}
{selected ? (
<div className="card">
<div className="card-header">
<span style={{ fontWeight: 600 }}>Generar DSI {selected.trackingId}</span>
</div>
<div className="card-body">
{/* Resumen */}
<div style={{ background: "var(--gray-50)", borderRadius: 8, padding: "1rem", marginBottom: "1rem", fontSize: ".85rem" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".5rem" }}>
<div><strong>Descripción:</strong> {selected.description}</div>
<div><strong>Tienda:</strong> {selected.store ?? "—"}</div>
<div>
<strong>Valor:</strong>{" "}
<span style={{ color: parseFloat(selected.declaredValue) > 400 ? "var(--red)" : "var(--green)", fontWeight: 700 }}>
${selected.declaredValue}
</span>
</div>
<div><strong>Peso real:</strong> {selected.actualWeight ? `${selected.actualWeight}lb` : selected.declaredWeight ? `~${selected.declaredWeight}lb` : "—"}</div>
<div><strong>Cliente:</strong> {selected.user?.firstName ?? "—"} {selected.user?.lastName ?? ""}</div>
</div>
{parseFloat(selected.declaredValue ?? 0) > 400 && (
<div style={{ marginTop: ".75rem", padding: ".625rem .875rem", background: "#FFF7ED", borderRadius: 6, borderLeft: "3px solid #F97316", fontSize: ".8rem", color: "#9A3412" }}>
<strong>Supera $400 proceso DAI obligatorio.</strong> Coordinar con importador.
</div>
)}
</div>
<form onSubmit={handleDeclare} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<label style={{ display: "block", fontWeight: 600, fontSize: ".85rem", marginBottom: ".35rem" }}>
Categoría SENAE *
</label>
<select
className="form-input"
value={form.category}
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
<div>
<label style={{ display: "block", fontWeight: 600, fontSize: ".85rem", marginBottom: ".35rem" }}>
Notas del agente
</label>
<textarea
className="form-input"
rows={3}
value={form.agentNotes}
onChange={e => setForm(f => ({ ...f, agentNotes: e.target.value }))}
placeholder="Partida arancelaria, observaciones, documentación adicional..."
style={{ resize: "vertical" }}
/>
</div>
<div style={{ display: "flex", gap: ".75rem" }}>
<button type="submit" className="btn btn-primary" disabled={submitting} style={{ flex: 1 }}>
{submitting ? "Generando DSI…" : "🛃 Generar y enviar a SENAE"}
</button>
<button type="button" className="btn btn-ghost" onClick={() => setSelected(null)}>
Cancelar
</button>
</div>
</form>
</div>
</div>
) : (
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
<div style={{ fontSize: "2.5rem", marginBottom: ".75rem" }}>👈</div>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>
Selecciona un paquete de la lista para generar su declaración DSI.
</p>
</div>
)}
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { getUser, clearAuth, api } from "@/lib/api";
// §11 — Portal exclusivo del Agente Aduanero
// Solo AGENTE_ADUANERO, ADMIN_EMPRESA y SUPER_ADMIN pueden acceder
const NAV = [
{ href: "/aduanero", icon: "◈", label: "Dashboard" },
{ href: "/aduanero/declaraciones", icon: "🛃", label: "Declaraciones DSI" },
{ href: "/admin", icon: "⚙️", label: "→ Admin" },
];
const ALLOWED = ["AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN"];
export default function AduaneroLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<any>(null);
useEffect(() => {
const u = getUser();
if (!u) { router.replace("/login"); return; }
if (!ALLOWED.includes(u.role)) { router.replace("/portal"); return; }
setUser(u);
}, [router]);
const handleLogout = async () => {
try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {}
clearAuth();
router.push("/login");
};
if (!user) return <div className="loading-overlay"><div className="spinner" /></div>;
return (
<div className="dash-layout">
<aside className="dash-sidebar">
<div className="dash-logo" style={{ color: "#F59E0B" }}>Aduana<span style={{ color: "white" }}>EC</span></div>
<nav className="dash-nav">
{NAV.map(item => (
<Link
key={item.href}
href={item.href}
className={`dash-nav-item ${pathname === item.href || (item.href !== "/aduanero" && pathname.startsWith(item.href)) ? "active" : ""}`}
>
<span>{item.icon}</span>
<span>{item.label}</span>
</Link>
))}
</nav>
<div className="dash-user">
<div className="dash-user-name">{user.firstName} {user.lastName}</div>
<div className="dash-user-role" style={{ color: "#F59E0B" }}>{user.role}</div>
<button
className="btn btn-ghost btn-sm"
style={{ marginTop: ".5rem", color: "rgba(255,255,255,.5)", fontSize: ".8rem" }}
onClick={handleLogout}
>
Cerrar sesión
</button>
</div>
</aside>
<div className="dash-main">
<header className="dash-topbar">
<span style={{ fontSize: "1rem", fontWeight: 600 }}>Portal Agente Aduanero</span>
<span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{user.email}</span>
</header>
<main className="dash-content">{children}</main>
</div>
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
// §11 — Dashboard del Agente Aduanero
// Ve la cola de paquetes VERIFICADOS pendientes de DSI, métricas y acciones rápidas
export default function AduaneroDashboard() {
const [pending, setPending] = useState<any[]>([]);
const [allPackages, setAllPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api.packages.pendingDeclaration(),
api.packages.list(),
])
.then(([pend, all]) => { setPending(pend); setAllPackages(all); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
);
const declaradas = allPackages.filter(p => p.status === "DECLARACION_ADUANERA").length;
const enAduana = allPackages.filter(p => p.status === "EN_ADUANA_ECUADOR").length;
const enTransito = allPackages.filter(p => p.status === "EN_TRANSITO_ECUADOR").length;
const entregadas = allPackages.filter(p => p.status === "ENTREGADO").length;
// Paquetes que superan el límite 4×4 (requieren DAI)
const requierenDAI = pending.filter(p => parseFloat(p.declaredValue ?? 0) > 400);
return (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Dashboard Agente Aduanero</h1>
<p className="dash-page-subtitle">Cola de declaraciones SENAE DSI simplificada y DAI formal. §11</p>
</div>
{/* Alerta si hay paquetes DAI pendientes */}
{requierenDAI.length > 0 && (
<div style={{
background: "#FFFBEB", border: "1px solid #FCD34D", borderRadius: 8,
padding: ".875rem 1.25rem", marginBottom: "1.5rem",
display: "flex", alignItems: "center", gap: ".75rem",
fontSize: ".875rem", color: "#92400E",
}}>
<span style={{ fontSize: "1.2rem" }}></span>
<span>
<strong>{requierenDAI.length}</strong> paquete{requierenDAI.length !== 1 ? "s" : ""} supera{requierenDAI.length === 1 ? "" : "n"} el límite 4×4 ($400) requiere{requierenDAI.length === 1 ? "" : "n"} <strong>importación formal (DAI)</strong>.
</span>
</div>
)}
{/* KPIs */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Pendientes de DSI", value: pending.length, color: "#F59E0B", icon: "⏳" },
{ label: "Requieren DAI", value: requierenDAI.length, color: "var(--red)", icon: "⚠️" },
{ label: "Con DSI aprobada", value: declaradas, color: "var(--primary)", icon: "✅" },
{ label: "En aduana Ecuador", value: enAduana, color: "#8B5CF6", icon: "🏛️" },
{ label: "En tránsito", value: enTransito, color: "var(--accent)", icon: "✈️" },
{ label: "Entregados", value: entregadas, color: "var(--green)", icon: "📬" },
{ label: "Total en sistema", value: allPackages.length, color: "var(--gray-600)", icon: "📦" },
{ label: "Pendiente hoy", value: pending.filter(p => {
const d = new Date(p.createdAt ?? 0);
const today = new Date();
return d.toDateString() === today.toDateString();
}).length, color: "#0057FF", icon: "📅" },
].map(s => (
<div key={s.label} className="stat-card">
<div style={{ fontSize: "1.25rem", marginBottom: ".25rem" }}>{s.icon}</div>
<div className="stat-value" style={{ color: s.color, fontSize: "1.6rem" }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
{/* Cola de pendientes */}
<div className="card">
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600 }}>Cola de declaraciones pendientes</span>
<Link href="/aduanero/declaraciones" className="btn btn-primary" style={{ fontSize: ".8rem", padding: ".35rem .75rem" }}>
Ir a declaraciones
</Link>
</div>
{pending.length === 0 ? (
<div style={{ padding: "2.5rem", textAlign: "center", color: "var(--gray-400)" }}>
<div style={{ fontSize: "2rem", marginBottom: ".5rem" }}>🎉</div>
<p style={{ fontSize: ".9rem" }}>Cola vacía todos los paquetes están declarados.</p>
</div>
) : (
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<thead>
<tr>
<th>Tracking</th>
<th>Descripción</th>
<th>Valor</th>
<th>Peso</th>
<th>Régimen</th>
</tr>
</thead>
<tbody>
{pending.slice(0, 10).map(p => {
const supera4x4 = parseFloat(p.declaredValue ?? 0) > 400;
return (
<tr key={p.id}>
<td style={{ fontWeight: 700, color: "var(--primary)", fontSize: ".82rem" }}>{p.trackingId}</td>
<td style={{ fontSize: ".8rem", maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{p.description ?? "—"}
</td>
<td style={{ fontSize: ".82rem", fontWeight: 600, color: supera4x4 ? "var(--red)" : "var(--green)" }}>
${p.declaredValue}
</td>
<td style={{ fontSize: ".82rem" }}>
{p.actualWeight ? `${p.actualWeight}lb` : "—"}
</td>
<td>
<span className={`badge ${supera4x4 ? "badge-red" : "badge-green"}`} style={{ fontSize: ".7rem" }}>
{supera4x4 ? "DAI" : "4×4"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
{pending.length > 10 && (
<div style={{ padding: ".75rem 1.25rem", fontSize: ".8rem", color: "var(--gray-400)", textAlign: "center" }}>
y {pending.length - 10} más
</div>
)}
</div>
)}
</div>
{/* Info SENAE + acciones */}
<div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* Resumen de regímenes */}
<div className="card">
<div className="card-header"><span style={{ fontWeight: 600 }}>Resumen por régimen aduanero (§15)</span></div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: ".6rem" }}>
{[
{ label: "Régimen 4×4 (DSI simplificada)", desc: "≤$400 · ≤4kg · 0% arancel", count: pending.filter(p => parseFloat(p.declaredValue ?? 0) <= 400).length, color: "var(--green)" },
{ label: "Importación formal (DAI)", desc: ">$400 o >4kg — aranceles SENAE", count: requierenDAI.length, color: "var(--red)" },
].map(r => (
<div key={r.label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: ".5rem .75rem", background: "var(--gray-50)", borderRadius: 8 }}>
<div>
<div style={{ fontWeight: 600, fontSize: ".85rem" }}>{r.label}</div>
<div style={{ fontSize: ".75rem", color: "var(--gray-500)" }}>{r.desc}</div>
</div>
<span style={{ fontWeight: 700, fontSize: "1.25rem", color: r.color }}>{r.count}</span>
</div>
))}
</div>
</div>
{/* Acciones */}
<div className="card">
<div className="card-header"><span style={{ fontWeight: 600 }}>Acciones</span></div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: ".5rem" }}>
<Link href="/aduanero/declaraciones"
style={{
display: "flex", alignItems: "center", gap: ".75rem", padding: ".75rem 1rem",
background: "#F59E0B", borderRadius: 8, textDecoration: "none", color: "#fff",
fontWeight: 700, fontSize: ".9rem",
}}
>
<span>🛃</span> Ir a cola de declaraciones
</Link>
<Link href="/bodega/despacho"
style={{
display: "flex", alignItems: "center", gap: ".75rem", padding: ".625rem 1rem",
background: "var(--gray-50)", borderRadius: 8, textDecoration: "none",
color: "var(--gray-700)", fontSize: ".875rem", fontWeight: 500,
}}
>
<span></span> Ver despachos
</Link>
<Link href="/admin/auditoria"
style={{
display: "flex", alignItems: "center", gap: ".75rem", padding: ".625rem 1rem",
background: "var(--gray-50)", borderRadius: 8, textDecoration: "none",
color: "var(--gray-700)", fontSize: ".875rem", fontWeight: 500,
}}
>
<span>🔍</span> Logs de auditoría
</Link>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,250 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { api } from "@/lib/api";
// §21 — Bodega: gestión de consolidaciones
// El operador ve todas las consolidaciones, puede cerrarlas y despacharlas
const STATUS_BADGE: Record<string, string> = {
ABIERTA: "badge-blue",
CERRADA: "badge-yellow",
DESPACHADA: "badge-orange",
ENTREGADA: "badge-green",
CANCELADA: "badge-gray",
};
const STATUS_LABEL: Record<string, string> = {
ABIERTA: "Abierta",
CERRADA: "Cerrada — lista para despacho",
DESPACHADA: "Despachada",
ENTREGADA: "Entregada",
CANCELADA: "Cancelada",
};
export default function BodegaConsolidacionPage() {
const [consolidations, setConsolidations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<string | null>(null);
const [modal, setModal] = useState<{ id: string; action: "close" | "dispatch" } | null>(null);
const [courierTracking, setCourierTracking] = useState("");
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const [filter, setFilter] = useState("ALL");
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 3500); };
const load = useCallback(async () => {
setLoading(true);
try {
const data = await api.consolidations.list();
setConsolidations(data);
} catch {
setConsolidations([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const filtered = consolidations.filter(c => filter === "ALL" || c.status === filter);
const handleAction = async () => {
if (!modal) return;
setSubmitting(true);
try {
if (modal.action === "close") {
await api.consolidations.close(modal.id, courierTracking || undefined);
showToast("Consolidación cerrada y lista para despacho.");
} else {
if (!courierTracking.trim()) { showToast("Ingresa el número de tracking del courier."); setSubmitting(false); return; }
await api.consolidations.dispatch(modal.id, courierTracking);
showToast("Consolidación despachada. Paquetes actualizados a EN_TRANSITO_ECUADOR.");
}
setModal(null);
setCourierTracking("");
await load();
} catch (e: any) {
showToast(e?.message ?? "Error.");
} finally {
setSubmitting(false);
}
};
if (loading) return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
);
// KPIs
const abiertas = consolidations.filter(c => c.status === "ABIERTA").length;
const cerradas = consolidations.filter(c => c.status === "CERRADA").length;
const despachadas = consolidations.filter(c => c.status === "DESPACHADA").length;
return (
<div>
{toast && (
<div style={{
position: "fixed", top: "1rem", right: "1rem", zIndex: 9999,
background: "var(--primary)", color: "#fff", borderRadius: 8,
padding: ".75rem 1.25rem", fontWeight: 600, fontSize: ".875rem",
boxShadow: "0 4px 16px rgba(0,0,0,.2)",
}}>{toast}</div>
)}
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Consolidaciones</h1>
<p className="dash-page-subtitle">Gestión de envíos consolidados de clientes. §21</p>
</div>
{/* KPIs */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Abiertas", value: abiertas, color: "var(--primary)" },
{ label: "Listas despacho", value: cerradas, color: "var(--yellow)" },
{ label: "Despachadas", value: despachadas, color: "var(--accent)" },
{ label: "Total", value: consolidations.length, color: "var(--gray-600)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
{/* Filtros */}
<div style={{ display: "flex", gap: ".5rem", marginBottom: "1.25rem", flexWrap: "wrap" }}>
{["ALL", "ABIERTA", "CERRADA", "DESPACHADA", "ENTREGADA"].map(s => (
<button
key={s}
className={`btn ${filter === s ? "btn-primary" : "btn-ghost"}`}
style={{ fontSize: ".8rem", padding: ".35rem .75rem" }}
onClick={() => setFilter(s)}
>
{s === "ALL" ? "Todas" : STATUS_LABEL[s] ?? s}
</button>
))}
</div>
{/* Lista */}
{filtered.length === 0 ? (
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
<div style={{ fontSize: "2.5rem", marginBottom: ".5rem" }}>📦</div>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>Sin consolidaciones.</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{filtered.map(c => (
<div key={c.id} className="card">
<div
style={{ padding: "1rem 1.25rem", cursor: "pointer", display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: ".75rem" }}
onClick={() => setExpanded(expanded === c.id ? null : c.id)}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div>
<span style={{ fontWeight: 700, color: "var(--primary)" }}>{c.code}</span>
<span className={`badge ${STATUS_BADGE[c.status] ?? "badge-gray"}`} style={{ marginLeft: ".75rem", fontSize: ".7rem" }}>{c.status}</span>
</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>
{c.packages?.length ?? 0} paquete{c.packages?.length !== 1 ? "s" : ""} ·
{c.totalWeightLb ? ` ${c.totalWeightLb}lb` : ""} ·
{c.totalValue ? ` $${c.totalValue}` : ""}
</div>
</div>
<div style={{ display: "flex", gap: ".5rem" }}>
{c.status === "ABIERTA" && (
<button
className="btn btn-ghost"
style={{ fontSize: ".8rem", padding: ".35rem .7rem" }}
onClick={e => { e.stopPropagation(); setModal({ id: c.id, action: "close" }); setCourierTracking(""); }}
>
Cerrar consolidación
</button>
)}
{c.status === "CERRADA" && (
<button
className="btn btn-primary"
style={{ fontSize: ".8rem", padding: ".35rem .7rem" }}
onClick={e => { e.stopPropagation(); setModal({ id: c.id, action: "dispatch" }); setCourierTracking(""); }}
>
Despachar
</button>
)}
</div>
</div>
{expanded === c.id && (
<div style={{ borderTop: "1px solid var(--gray-100)", padding: "1rem 1.25rem" }}>
{c.notes && <p style={{ fontSize: ".82rem", color: "var(--gray-500)", marginBottom: ".75rem" }}>Notas: {c.notes}</p>}
{c.courierTracking && (
<p style={{ fontSize: ".82rem", color: "var(--gray-600)", marginBottom: ".75rem" }}>
Courier tracking: <code style={{ fontWeight: 700 }}>{c.courierTracking}</code>
</p>
)}
<div className="table-wrap" style={{ border: "none" }}>
<table>
<thead><tr><th>Tracking</th><th>Descripción</th><th>Valor</th><th>Peso</th><th>Estado</th></tr></thead>
<tbody>
{c.packages?.map((cp: any) => (
<tr key={cp.id}>
<td style={{ fontWeight: 700, fontSize: ".82rem", color: "var(--primary)" }}>{cp.package?.trackingId}</td>
<td style={{ fontSize: ".8rem" }}>{cp.package?.description ?? "—"}</td>
<td style={{ fontSize: ".82rem" }}>${cp.package?.declaredValue}</td>
<td style={{ fontSize: ".82rem" }}>{cp.package?.actualWeight ?? "—"}lb</td>
<td><span className="badge badge-blue" style={{ fontSize: ".7rem" }}>{cp.package?.status?.replace(/_/g," ")}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Modal cerrar / despachar */}
{modal && (
<div className="modal-overlay" onClick={() => setModal(null)}>
<div className="modal-box" onClick={e => e.stopPropagation()} style={{ maxWidth: 440 }}>
<div className="modal-header">
<span style={{ fontWeight: 700 }}>
{modal.action === "close" ? "Cerrar consolidación" : "Despachar consolidación"}
</span>
<button className="btn btn-ghost" style={{ padding: ".25rem .5rem" }} onClick={() => setModal(null)}></button>
</div>
<div className="modal-body">
<p style={{ fontSize: ".875rem", color: "var(--gray-600)", marginBottom: "1rem" }}>
{modal.action === "close"
? "Cierra la consolidación cuando todos los paquetes estén listos. No se podrán agregar más paquetes."
: "Al despachar, todos los paquetes de esta consolidación pasarán a estado EN_TRANSITO_ECUADOR."}
</p>
<label style={{ display: "block", fontWeight: 600, fontSize: ".85rem", marginBottom: ".35rem" }}>
Número de tracking courier {modal.action === "dispatch" ? "*" : "(opcional)"}
</label>
<input
className="form-input"
value={courierTracking}
onChange={e => setCourierTracking(e.target.value)}
placeholder="Ej: 7489234823 (FedEx / DHL / UPS)"
/>
</div>
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setModal(null)}>Cancelar</button>
<button
className={`btn ${modal.action === "dispatch" ? "btn-primary" : "btn-ghost"}`}
style={modal.action === "close" ? { background: "var(--yellow)", color: "#000" } : {}}
onClick={handleAction}
disabled={submitting}
>
{submitting ? "..." : modal.action === "close" ? "Cerrar consolidación" : "✈️ Confirmar despacho"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+1
View File
@@ -10,6 +10,7 @@ const NAV = [
{ href: "/bodega/paquetes", icon: "📦", label: "Paquetes" },
{ href: "/bodega/verificacion", icon: "✅", label: "Verificación" },
{ href: "/bodega/declaraciones", icon: "🛃", label: "Declaraciones SENAE" },
{ href: "/bodega/consolidacion", icon: "🗃️", label: "Consolidaciones" },
{ href: "/bodega/despacho", icon: "🚢", label: "Despacho" },
{ href: "/admin", icon: "⚙️", label: "→ Admin" },
];
+119 -25
View File
@@ -3,6 +3,21 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
// §08 — estados que interesan al operador de bodega NJ
const STATUS_BADGE: Record<string, string> = {
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",
};
export default function BodegaDashboard() {
const [packages, setPackages] = useState<any[]>([]);
const [preAlerts, setPreAlerts] = useState<any[]>([]);
@@ -11,64 +26,143 @@ export default function BodegaDashboard() {
useEffect(() => {
Promise.all([api.packages.list(), api.preAlerts.list()])
.then(([p, a]) => { setPackages(p); setPreAlerts(a); })
.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 recibidos = packages.filter(p => p.status === "RECIBIDO_EN_NJ").length;
const enCamino = packages.filter(p => p.status === "EN_CAMINO_A_ECUADOR").length;
const listos = packages.filter(p => p.status === "LISTO_PARA_RETIRO").length;
// KPIs con estados §08 correctos
const recibidos = packages.filter(p => p.status === "RECIBIDO_BODEGA").length;
const enVerif = packages.filter(p => p.status === "EN_VERIFICACION").length;
const verificados = packages.filter(p => p.status === "VERIFICADO").length;
const enTransito = packages.filter(p => p.status === "EN_TRANSITO_ECUADOR").length;
const incidencias = packages.filter(p => p.status === "INCIDENCIA").length;
const alertasPend = preAlerts.filter(p => p.status === "PENDIENTE").length;
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Dashboard Bodega</h1></div>
<div className="grid-4" style={{ marginBottom: "2rem" }}>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Dashboard Bodega NJ</h1>
<p className="dash-page-subtitle">Operaciones en tiempo real 150 N Day St, City of Orange, NJ 07050</p>
</div>
{/* KPIs */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Recibidos en NJ", value: recibidos, color: "var(--primary)" },
{ label: "En camino a EC", value: enCamino, color: "var(--yellow)" },
{ label: "Listos para retiro",value: listos, color: "var(--green)" },
{ label: "Pre-alertas pend.", value: alertasPend, color: "var(--accent)" },
{ label: "Recibidos hoy", value: recibidos, color: "var(--primary)", href: "/bodega/paquetes" },
{ label: "En verificación", value: enVerif, color: "var(--yellow)", href: "/bodega/verificacion" },
{ label: "Verificados", value: verificados, color: "var(--green)", href: "/bodega/declaraciones" },
{ label: "En tránsito a EC", value: enTransito, color: "var(--accent)", href: "/bodega/despacho" },
{ label: "Incidencias abiertas", value: incidencias, color: "var(--red)", href: "/bodega/paquetes" },
{ label: "Pre-alertas pend.", value: alertasPend, color: "#8B5CF6", href: "/bodega/paquetes" },
{ label: "Total en sistema", value: packages.length, color: "var(--gray-600)", href: "/bodega/paquetes" },
{ label: "Listos despacho", value: packages.filter(p => p.status === "DECLARACION_ADUANERA").length, color: "#0057FF", href: "/bodega/despacho" },
].map(s => (
<div key={s.label} className="stat-card">
<Link key={s.label} href={s.href} style={{ textDecoration: "none" }}>
<div className="stat-card" style={{ cursor: "pointer", transition: "transform .15s" }}
onMouseEnter={e => (e.currentTarget.style.transform = "translateY(-2px)")}
onMouseLeave={e => (e.currentTarget.style.transform = "translateY(0)")}
>
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
</Link>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
{/* Paquetes recientes */}
<div className="card">
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Paquetes recientes</span>
<Link href="/bodega/paquetes" className="btn btn-ghost btn-sm text-primary">Ver todos </Link>
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600 }}>Paquetes recientes</span>
<Link href="/bodega/paquetes" className="btn btn-ghost" style={{ fontSize: ".8rem", padding: ".25rem .6rem" }}>Ver todos </Link>
</div>
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<thead><tr><th>Tracking</th><th>Cliente</th><th>Estado</th></tr></thead>
<thead>
<tr>
<th>Tracking</th>
<th>Descripción</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
{packages.slice(0, 8).map(p => (
<tr key={p.id}>
<td className="font-semibold text-primary">{p.trackingId}</td>
<td className="text-sm">{p.suite?.user?.firstName ?? "—"} {p.suite?.user?.lastName ?? ""}</td>
<td><span className="badge badge-blue">{p.status.replace(/_/g," ")}</span></td>
<td style={{ fontWeight: 600, color: "var(--primary)", fontSize: ".85rem" }}>{p.trackingId}</td>
<td style={{ fontSize: ".82rem", color: "var(--gray-600)", maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{p.description ?? ""}
</td>
<td>
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`} style={{ fontSize: ".7rem" }}>
{p.status?.replace(/_/g, " ")}
</span>
</td>
</tr>
))}
{packages.length === 0 && (
<tr><td colSpan={3} style={{ textAlign: "center", color: "var(--gray-400)", padding: "2rem" }}>Sin paquetes registrados</td></tr>
)}
</tbody>
</table>
</div>
</div>
{/* Pre-alertas + acciones rápidas */}
<div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
<div className="card">
<div className="card-header"><span className="font-semibold">Pre-alertas pendientes</span></div>
{alertasPend === 0 ? <p style={{ padding: "1.5rem", color: "var(--gray-500)" }}>Sin pre-alertas pendientes.</p> : (
preAlerts.filter(a => a.status === "PENDIENTE").map(a => (
<div key={a.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)" }}>
<div style={{ fontWeight: 600 }}>{a.store} {a.orderNumber}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{a.description ?? "Sin descripción"}</div>
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600 }}>Pre-alertas pendientes</span>
<span style={{ fontSize: ".8rem", color: alertasPend > 0 ? "var(--yellow)" : "var(--gray-400)", fontWeight: 600 }}>
{alertasPend} pendiente{alertasPend !== 1 ? "s" : ""}
</span>
</div>
{alertasPend === 0 ? (
<p style={{ padding: "1.5rem", color: "var(--gray-500)", fontSize: ".875rem" }}>Sin pre-alertas pendientes.</p>
) : (
preAlerts.filter(a => a.status === "PENDIENTE").slice(0, 5).map(a => (
<div key={a.id} style={{ padding: ".875rem 1.25rem", borderBottom: "1px solid var(--gray-100)" }}>
<div style={{ fontWeight: 600, fontSize: ".875rem" }}>{a.store}</div>
<div style={{ fontSize: ".78rem", color: "var(--gray-500)", marginTop: ".15rem" }}>
{a.description ?? "Sin descripción"} · ${a.declaredValue}
</div>
</div>
))
)}
</div>
{/* Acciones rápidas */}
<div className="card">
<div className="card-header"><span style={{ fontWeight: 600 }}>Acciones rápidas</span></div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: ".5rem" }}>
{[
{ href: "/bodega/verificacion", icon: "⚖️", label: "Verificar paquetes (peso + fotos)" },
{ href: "/bodega/declaraciones", icon: "🛃", label: "Generar declaraciones DSI" },
{ href: "/bodega/despacho", icon: "✈️", label: "Despachar hacia Ecuador" },
{ href: "/bodega/paquetes", icon: "🔍", label: "Buscar / gestionar paquetes" },
].map(a => (
<Link key={a.href} href={a.href}
style={{
display: "flex", alignItems: "center", gap: ".75rem",
padding: ".625rem .75rem", borderRadius: 8, textDecoration: "none",
color: "var(--gray-700)", background: "var(--gray-50)",
fontSize: ".875rem", fontWeight: 500, transition: "background .15s",
}}
onMouseEnter={e => { e.currentTarget.style.background = "var(--gray-100)"; }}
onMouseLeave={e => { e.currentTarget.style.background = "var(--gray-50)"; }}
>
<span style={{ fontSize: "1.1rem" }}>{a.icon}</span>
{a.label}
</Link>
))}
</div>
</div>
</div>
</div>
</div>
);
+2 -1
View File
@@ -29,7 +29,8 @@ export default function LoginPage() {
// Redirigir según rol
const role = data.user.role;
if (role === "ADMIN_EMPRESA" || role === "SUPER_ADMIN") router.push("/admin");
else if (role === "OPERADOR_BODEGA" || role === "AGENTE_ADUANERO") router.push("/bodega");
else if (role === "AGENTE_ADUANERO") router.push("/aduanero");
else if (role === "OPERADOR_BODEGA") router.push("/bodega");
else router.push("/portal");
} catch (err: any) {
setError(err.message ?? "Error al iniciar sesión");
+7
View File
@@ -428,6 +428,13 @@ function Footer() {
<Link href="/casillero">Casillero</Link>
<Link href="/carga-pesada">Carga Pesada</Link>
</div>
{/* LOPDP §21 — aviso de protección de datos */}
<div style={{ borderTop: "1px solid rgba(255,255,255,.07)", marginTop: 24, paddingTop: 16, fontSize: ".72rem", color: "rgba(255,255,255,.3)", lineHeight: 1.7, textAlign: "center" }}>
🛡 Los datos personales de clientes ecuatorianos están protegidos por la{" "}
<strong style={{ color: "rgba(255,255,255,.5)" }}>Ley Orgánica de Protección de Datos Personales (LOPDP) de Ecuador</strong>.
Al procesarse en EE.UU., aplica también la normativa del estado de New Jersey.
Moraworld Imports S.A.S. (Ecuador) · Mora Global Import LLC (NJ, EE.UU.) · Documento v1.1 2026
</div>
</footer>
);
}
@@ -0,0 +1,238 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { api } from "@/lib/api";
// §21 — Portal cliente: solicitar consolidación de paquetes
// El cliente elige paquetes en estado VERIFICADO o RECIBIDO_BODEGA
// y los agrupa en un solo envío para optimizar costos
const CONSOLIDABLE = ["VERIFICADO", "RECIBIDO_BODEGA"];
const STATUS_BADGE: Record<string, string> = {
ABIERTA: "badge-blue",
CERRADA: "badge-yellow",
DESPACHADA: "badge-orange",
ENTREGADA: "badge-green",
CANCELADA: "badge-gray",
};
export default function ConsolidacionPage() {
const [packages, setPackages] = useState<any[]>([]);
const [consolidations, setConsolidations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [notes, setNotes] = useState("");
const [adding, setAdding] = useState<string | null>(null); // consolidationId en progreso
const [toast, setToast] = useState<string | null>(null);
const [expanded, setExpanded] = useState<string | null>(null);
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 3500); };
const load = useCallback(async () => {
setLoading(true);
try {
const [pkgs, cons] = await Promise.all([api.packages.list(), api.consolidations.list()]);
setPackages(pkgs);
setConsolidations(cons);
} catch {
// silencioso
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const consolidablePackages = packages.filter(p =>
CONSOLIDABLE.includes(p.status) && !p.consolidation
);
const handleCreate = async () => {
setCreating(true);
try {
await api.consolidations.create(notes || undefined);
setNotes("");
await load();
showToast("Consolidación creada. Ahora agrega paquetes.");
} catch (e: any) {
showToast(e?.message ?? "Error al crear consolidación.");
} finally {
setCreating(false);
}
};
const handleAdd = async (consolidationId: string, packageId: string) => {
setAdding(packageId);
try {
await api.consolidations.addPackage(consolidationId, packageId);
await load();
showToast("Paquete agregado.");
} catch (e: any) {
showToast(e?.message ?? "Error al agregar paquete.");
} finally {
setAdding(null);
}
};
const handleRemove = async (consolidationId: string, packageId: string) => {
try {
await api.consolidations.removePackage(consolidationId, packageId);
await load();
showToast("Paquete removido.");
} catch (e: any) {
showToast(e?.message ?? "Error al remover paquete.");
}
};
if (loading) return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
);
const openConsolidations = consolidations.filter(c => c.status === "ABIERTA");
return (
<div>
{toast && (
<div style={{
position: "fixed", top: "1rem", right: "1rem", zIndex: 9999,
background: "var(--primary)", color: "#fff", borderRadius: 8,
padding: ".75rem 1.25rem", fontWeight: 600, fontSize: ".875rem",
boxShadow: "0 4px 16px rgba(0,0,0,.2)",
}}>{toast}</div>
)}
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Consolidación de paquetes</h1>
<p className="dash-page-subtitle">
Agrupa varios paquetes en un solo envío para optimizar costos de flete y reducir declaraciones aduaneras. §21
</p>
</div>
<div className="grid-2" style={{ gap: "1.5rem", alignItems: "start" }}>
{/* Columna izquierda — crear + paquetes disponibles */}
<div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* Nueva consolidación */}
<div className="card">
<div className="card-header"><span style={{ fontWeight: 600 }}>Nueva consolidación</span></div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<label style={{ display: "block", fontWeight: 600, fontSize: ".85rem", marginBottom: ".35rem" }}>
Notas (opcional)
</label>
<input
className="form-input"
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Ej: Pedidos de Amazon de diciembre..."
/>
</div>
<button
className="btn btn-primary"
onClick={handleCreate}
disabled={creating || consolidablePackages.length === 0}
>
{creating ? "Creando..." : "Crear consolidación"}
</button>
{consolidablePackages.length === 0 && (
<p style={{ fontSize: ".8rem", color: "var(--gray-400)", textAlign: "center" }}>
No tienes paquetes en estado VERIFICADO disponibles para consolidar.
</p>
)}
</div>
</div>
{/* Paquetes disponibles */}
{consolidablePackages.length > 0 && (
<div className="card">
<div className="card-header">
<span style={{ fontWeight: 600 }}>Paquetes disponibles para consolidar</span>
<span className="badge badge-green" style={{ marginLeft: ".5rem" }}>{consolidablePackages.length}</span>
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
{consolidablePackages.map(p => (
<div key={p.id} style={{ padding: ".875rem 1.25rem", borderBottom: "1px solid var(--gray-100)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<div style={{ fontWeight: 700, color: "var(--primary)", fontSize: ".875rem" }}>{p.trackingId}</div>
<div style={{ fontSize: ".78rem", color: "var(--gray-500)" }}>
{p.description} · ${p.declaredValue} · {p.actualWeight ?? p.declaredWeight ?? "?"}lb
</div>
</div>
{openConsolidations.length > 0 && (
<button
className="btn btn-ghost"
style={{ fontSize: ".75rem", padding: ".25rem .6rem" }}
disabled={adding === p.id}
onClick={() => handleAdd(openConsolidations[0].id, p.id)}
>
{adding === p.id ? "..." : "+ Agregar"}
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Columna derecha — mis consolidaciones */}
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<h2 style={{ fontSize: "1rem", fontWeight: 700, margin: 0 }}>Mis consolidaciones</h2>
{consolidations.length === 0 ? (
<div className="card" style={{ padding: "2.5rem", textAlign: "center" }}>
<div style={{ fontSize: "2.5rem", marginBottom: ".5rem" }}>📦</div>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>No tienes consolidaciones aún.</p>
</div>
) : (
consolidations.map(c => (
<div key={c.id} className="card">
<div
className="card-header"
style={{ cursor: "pointer", display: "flex", justifyContent: "space-between", alignItems: "center" }}
onClick={() => setExpanded(expanded === c.id ? null : c.id)}
>
<div>
<span style={{ fontWeight: 700, color: "var(--primary)", fontSize: ".9rem" }}>{c.code}</span>
<span className={`badge ${STATUS_BADGE[c.status] ?? "badge-gray"}`} style={{ marginLeft: ".75rem", fontSize: ".7rem" }}>{c.status}</span>
</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>
{c.packages?.length ?? 0} paq · {c.totalWeightLb ?? 0}lb · ${c.totalValue ?? 0}
</div>
</div>
{expanded === c.id && (
<div className="card-body" style={{ borderTop: "1px solid var(--gray-100)" }}>
{c.notes && <p style={{ fontSize: ".82rem", color: "var(--gray-500)", marginBottom: ".75rem" }}>{c.notes}</p>}
{c.packages?.length === 0 ? (
<p style={{ fontSize: ".85rem", color: "var(--gray-400)" }}>Sin paquetes aún.</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: ".35rem" }}>
{c.packages.map((cp: any) => (
<div key={cp.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: ".4rem .5rem", background: "var(--gray-50)", borderRadius: 6 }}>
<div>
<span style={{ fontWeight: 700, fontSize: ".82rem", color: "var(--primary)" }}>{cp.package?.trackingId}</span>
<span style={{ fontSize: ".75rem", color: "var(--gray-500)", marginLeft: ".5rem" }}>{cp.package?.description}</span>
</div>
{c.status === "ABIERTA" && (
<button
className="btn btn-ghost"
style={{ fontSize: ".72rem", padding: ".2rem .5rem", color: "var(--red)" }}
onClick={() => handleRemove(c.id, cp.package?.id)}
>
</button>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
))
)}
</div>
</div>
</div>
);
}
+1
View File
@@ -10,6 +10,7 @@ const NAV = [
{ href: "/portal/mi-casillero", icon: "📦", label: "Mi Casillero" },
{ href: "/portal/mis-paquetes", icon: "🚚", label: "Mis Paquetes" },
{ href: "/portal/pre-alerta", icon: "🔔", label: "Pre-Alerta" },
{ href: "/portal/consolidacion", icon: "🗃️", label: "Consolidar" },
{ href: "/portal/calculadora", icon: "🧮", label: "Calculadora" },
{ href: "/portal/perfil", icon: "👤", label: "Mi Perfil" },
];
+16
View File
@@ -92,6 +92,22 @@ export default function RegistroPage() {
<Link href="/privacidad" style={{ color: "var(--primary)" }}>Política de Privacidad</Link>.
</p>
{/* LOPDP §21 */}
<div style={{
marginTop: ".875rem",
padding: ".625rem .875rem",
background: "rgba(255,255,255,.04)",
border: "1px solid rgba(255,255,255,.08)",
borderRadius: 8,
fontSize: ".72rem",
color: "rgba(255,255,255,.4)",
lineHeight: 1.6,
textAlign: "center",
}}>
🛡 Tus datos están protegidos por la <strong style={{ color: "rgba(255,255,255,.6)" }}>Ley Orgánica de Protección de Datos Personales (LOPDP)</strong> de Ecuador
y la normativa del estado de New Jersey (EE.UU.). Moraworld Imports S.A.S. · Mora Global Import LLC.
</div>
<div className="text-center mt-4">
<p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>
¿Ya tienes cuenta?{" "}
+13
View File
@@ -169,4 +169,17 @@ export const api = {
confirm: (paymentId: string) =>
request<any>(`/payments/${paymentId}/confirm`, { method: "POST" }),
},
consolidations: {
list: () => request<any[]>("/consolidations"),
get: (id: string) => request<any>(`/consolidations/${id}`),
create: (notes?: string) => request<any>("/consolidations", { method: "POST", body: JSON.stringify({ notes }) }),
addPackage: (id: string, packageId: string) =>
request<any>(`/consolidations/${id}/packages`, { method: "POST", body: JSON.stringify({ packageId }) }),
removePackage: (id: string, packageId: string) =>
request<any>(`/consolidations/${id}/packages/${packageId}`, { method: "DELETE" }),
close: (id: string, courierTracking?: string) =>
request<any>(`/consolidations/${id}/close`, { method: "POST", body: JSON.stringify({ courierTracking }) }),
dispatch: (id: string, courierTracking: string) =>
request<any>(`/consolidations/${id}/dispatch`, { method: "POST", body: JSON.stringify({ courierTracking }) }),
},
};
+54 -1
View File
@@ -1,4 +1,4 @@
// Moraworld Imports — Schema v0.3 (Fase 2Bodegas + Integraciones)
// Moraworld Imports — Schema v0.5 (Fase 3Consolidación + Pagos + Plantillas)
// Multi-tenant por tenant_id en todas las tablas de negocio
// Sincronizado con documentacion.html v1.1
@@ -11,6 +11,14 @@ datasource db {
url = env("DATABASE_URL")
}
enum ConsolidationStatus {
ABIERTA // Recibiendo paquetes
CERRADA // Lista para despacho
DESPACHADA // Enviada al courier internacional
ENTREGADA // Entregada en Ecuador
CANCELADA
}
enum PaymentStatus {
PENDIENTE
PROCESANDO
@@ -102,6 +110,7 @@ model Tenant {
integrations Integration[]
notificationTemplates NotificationTemplate[]
payments Payment[]
consolidations Consolidation[]
}
// ─── Usuarios ────────────────────────────────────────────────
@@ -207,6 +216,7 @@ model Package {
preAlert PreAlert?
notifications Notification[]
payment Payment?
consolidation ConsolidationPackage?
@@index([tenantId, status])
@@index([userId])
@@ -387,6 +397,49 @@ model Warehouse {
@@index([tenantId])
}
// ─── Consolidaciones (§21) ────────────────────────────────────
/// Agrupa múltiples paquetes del mismo cliente en un solo envío.
/// Optimiza costos de flete y reduce declaraciones aduaneras.
model Consolidation {
id String @id @default(cuid())
tenantId String
userId String // cliente dueño de los paquetes
/// Código de consolidación: CON-YYYYMMDD-XXXXXX
code String @unique
status ConsolidationStatus @default(ABIERTA)
/// Peso total real en libras (suma de paquetes)
totalWeightLb Decimal? @db.Decimal(8, 2)
/// Valor declarado total en USD
totalValue Decimal? @db.Decimal(10, 2)
/// Notas del operador
notes String?
/// Número de seguimiento del courier internacional (FedEx / DHL / UPS)
courierTracking String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy String?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
packages ConsolidationPackage[]
@@index([tenantId, status])
@@index([userId])
}
/// Tabla intermedia — paquetes incluidos en una consolidación
model ConsolidationPackage {
id String @id @default(cuid())
consolidationId String
packageId String @unique // un paquete solo puede estar en una consolidación
addedAt DateTime @default(now())
consolidation Consolidation @relation(fields: [consolidationId], references: [id], onDelete: Cascade)
package Package @relation(fields: [packageId], references: [id], onDelete: Cascade)
@@index([consolidationId])
}
// ─── Pagos (§09 paso 8 / §14 paso 5) ─────────────────────────
/// Registro de pagos vinculados a un paquete.