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:
@@ -38,3 +38,4 @@ coverage/
|
||||
|
||||
# Coolify / Docker local overrides
|
||||
docker-compose.override.yml
|
||||
.tasker
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 — 0–15% (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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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?{" "}
|
||||
|
||||
@@ -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 }) }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Moraworld Imports — Schema v0.3 (Fase 2 — Bodegas + Integraciones)
|
||||
// Moraworld Imports — Schema v0.5 (Fase 3 — Consolidació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.
|
||||
|
||||
Reference in New Issue
Block a user