feat: Fase 1 — Auth JWT+MFA, portales CRUD, UI completa

API:
- AuthModule: register, login, refresh, logout, MFA/TOTP setup+verify
- JwtStrategy + JwtAuthGuard + RolesGuard + CurrentUser decorator
- PackagesModule: CRUD paquetes + historial de estados
- PreAlertsModule: pre-alertas por usuario
- UsersModule: gestión de usuarios + roles + activación
- B2BModule: solicitudes de carga pesada/cotización
- ValidationPipe global + CORS configurado

Web (Next.js 15):
- globals.css completo (design system + utility classes)
- Layout raíz con WhatsApp flotante
- /login + /registro funcionales con JWT y redirección por rol
- /portal: dashboard, mi-casillero, mis-paquetes, pre-alerta, calculadora, perfil
- /admin: dashboard, usuarios (gestión roles/activación), tarifas, reportes, auditoría
- /bodega: dashboard, paquetes (crear+actualizar estado), verificación, despacho
- /tracking: tracking real con progreso visual + historial
- /calculadora: calculadora interactiva real (API SENAE §15)
- /como-funciona, /tarifas, /quienes-somos, /casillero
- /carga-pesada + /carga-pesada/cotizacion (formulario B2B)
- lib/api.ts: cliente HTTP con auto-refresh de token

Roles sincronizados con schema: SUPER_ADMIN, ADMIN_EMPRESA,
OPERADOR_BODEGA, AGENTE_ADUANERO, CLIENTE, SOPORTE
This commit is contained in:
Lizandro Guarnizo
2026-06-01 09:42:38 -05:00
parent 93db76897d
commit 4872053fd1
57 changed files with 4330 additions and 192 deletions
+34
View File
@@ -0,0 +1,34 @@
import { Controller, Get, Post, Patch, Body, Param, UseGuards, HttpCode, HttpStatus } from "@nestjs/common";
import { B2BService, CreateB2BDto, UpdateB2BStatusDto } from "./b2b.service";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
const TENANT_FALLBACK = "moraworld"; // B2B público usa tenantId del slug
@Controller("b2b")
export class B2BController {
constructor(private svc: B2BService) {}
/** POST /api/b2b — público */
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() dto: CreateB2BDto, @CurrentUser() user: any): Promise<any> {
// Si hay usuario autenticado usa su tenantId, sino carga el tenant por slug
const tenantId = user?.tenantId ?? TENANT_FALLBACK;
return this.svc.create(dto, tenantId);
}
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
findAll(@CurrentUser() user: any): Promise<any[]> {
return this.svc.findAll(user.tenantId);
}
@Patch(":id/status")
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
updateStatus(@Param("id") id: string, @Body() dto: UpdateB2BStatusDto): Promise<any> {
return this.svc.updateStatus(id, dto);
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Module } from "@nestjs/common";
import { B2BController } from "./b2b.controller";
import { B2BService } from "./b2b.service";
@Module({ controllers: [B2BController], providers: [B2BService] })
export class B2BModule {}
+64
View File
@@ -0,0 +1,64 @@
import { IsString, IsOptional, IsNumber, Min } from "class-validator";
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { generateTrackingId } from "../common/utils/tracking-id.util";
export class CreateB2BDto {
@IsString() contactName!: string;
@IsString() contactEmail!: string;
@IsOptional() @IsString() contactPhone?: string;
@IsOptional() @IsString() companyName?: string;
@IsString() merchandiseType!: string;
@IsString() description!: string;
@IsOptional() @IsNumber() @Min(0) commercialValue?: number;
}
export class UpdateB2BStatusDto {
@IsString() status!: string;
@IsOptional() @IsString() quotationNotes?: string;
@IsOptional() @IsNumber() @Min(0) quotationAmount?: number;
}
@Injectable()
export class B2BService {
constructor(private prisma: PrismaService) {}
async findAll(tenantId: string): Promise<any[]> {
return this.prisma.client.b2BRequest.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
});
}
async create(dto: CreateB2BDto, tenantId: string): Promise<any> {
const count = await this.prisma.client.b2BRequest.count();
const trackingId = `B2B-${String(count + 1).padStart(6, "0")}`;
return this.prisma.client.b2BRequest.create({
data: {
tenantId,
trackingId,
contactName: dto.contactName,
contactEmail: dto.contactEmail,
contactPhone: dto.contactPhone,
companyName: dto.companyName,
merchandiseType: dto.merchandiseType,
description: dto.description,
commercialValue: dto.commercialValue ?? null,
status: "PENDIENTE",
},
});
}
async updateStatus(id: string, dto: UpdateB2BStatusDto): Promise<any> {
const req = await this.prisma.client.b2BRequest.findUnique({ where: { id } });
if (!req) throw new NotFoundException("Solicitud B2B no encontrada.");
return this.prisma.client.b2BRequest.update({
where: { id },
data: {
status: dto.status as any,
quotationNotes: dto.quotationNotes,
quotationAmount: dto.quotationAmount ?? null,
},
});
}
}