feat: Fase 2 — SENAE DSI, verificación bodega, fotos, notificaciones, auditoría, tarifas CRUD, páginas faltantes

API:
- PackagesService: verifyPackage (discrepancia >10%), addPhotos (multer disk), generateSenaeDeclaration (DSI stub)
- PATCH /packages/:id/verify — peso real + dimensiones + detección automática de discrepancia
- POST  /packages/:id/photos — upload multipart fotos (hasta 10, 10 MB c/u)
- POST  /packages/:id/senae/declare — genera DSI, N° autorización SENAE, cambia estado DECLARACION_ADUANERA
- GET   /packages/pending-declaration — cola de paquetes VERIFICADO para agente aduanero
- AuditLogModule: GET /audit-logs con filtros (acción, recurso, fechas, paginación)
- NotificationsModule: notifyStatusChange → crea registros EMAIL/WHATSAPP/PUSH en DB (stub dispatch)
- TariffsModule: GET/PUT /tariffs — configuración de tarifas por tenant (pricePerLb, IVA, FODINFA…)
- ProductsModule: POST /products/scan — stub extracción de producto desde URL Amazon/eBay/Walmart
- StorageModule: saveFile/deleteFile con disco local (S3-ready)
- main.ts: NestExpressApplication + useStaticAssets('/uploads')
- Dirección bodega: de hardcoded a env vars (WAREHOUSE_ADDRESS_*)

Web:
- /bodega/verificacion — formulario real: peso real, dims, fotos, detección discrepancia, notificación
- /bodega/despacho — lista paquetes DECLARACION_ADUANERA → despacha a EN_TRANSITO_ECUADOR
- /bodega/declaraciones — cola SENAE: auto-detecta categoría 4×4, genera DSI por paquete
- /bodega/layout — agrega enlace Declaraciones SENAE, corrige ALLOWED roles
- /admin/tarifas — CRUD real: edita pricePerLb, IVA, FODINFA, límites 4×4; preview fórmulas
- /admin/auditoria — tabla paginada con filtros desde GET /audit-logs
- /portal/pre-alerta — tabs URL-scan / Manual, upload de factura, campos correctos
- /casillero/como-usar — timeline 9 pasos con dirección NJ y CTA
- /carga-pesada/como-funciona — flujo B2B 5 pasos, servicios incluidos
- /carga-pesada/inen — 6 categorías reguladas, callout de advertencia, proceso de asistencia
- api.ts: verify, uploadPhotos, senaeDeclare, pendingDeclaration, tariffs, auditLogs, products.scan
This commit is contained in:
Lizandro Guarnizo
2026-06-01 10:27:52 -05:00
parent 4872053fd1
commit 6e90f06f6d
33 changed files with 1712 additions and 257 deletions
+1
View File
@@ -22,6 +22,7 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.0",
"@nestjs/throttler": "^6.5.0",
"@types/multer": "^2.1.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
+10
View File
@@ -9,6 +9,11 @@ import { UsersModule } from "./users/users.module";
import { PackagesModule } from "./packages/packages.module";
import { PreAlertsModule } from "./pre-alerts/pre-alerts.module";
import { B2BModule } from "./b2b/b2b.module";
import { StorageModule } from "./storage/storage.module";
import { AuditLogModule } from "./audit-log/audit-log.module";
import { NotificationsModule } from "./notifications/notifications.module";
import { TariffsModule } from "./tariffs/tariffs.module";
import { ProductsModule } from "./products/products.module";
@Module({
imports: [
@@ -25,6 +30,11 @@ import { B2BModule } from "./b2b/b2b.module";
PackagesModule,
PreAlertsModule,
B2BModule,
StorageModule,
AuditLogModule,
NotificationsModule,
TariffsModule,
ProductsModule,
],
})
export class AppModule {}
@@ -0,0 +1,34 @@
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
import { AuditLogService } from "./audit-log.service";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
@Controller("audit-logs")
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("SUPER_ADMIN", "ADMIN_EMPRESA")
export class AuditLogController {
constructor(private svc: AuditLogService) {}
@Get()
findAll(
@CurrentUser() user: any,
@Query("userId") userId?: string,
@Query("action") action?: string,
@Query("resource") resource?: string,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("page") page?: string,
@Query("limit") limit?: string,
) {
return this.svc.findAll({
tenantId: user.role === "SUPER_ADMIN" ? undefined : user.tenantId,
userId,
action,
resource,
from,
to,
page: page ? parseInt(page) : 1,
limit: limit ? parseInt(limit) : 50,
});
}
}
@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuditLogService } from "./audit-log.service";
import { AuditLogController } from "./audit-log.controller";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
imports: [PrismaModule],
providers: [AuditLogService],
controllers: [AuditLogController],
exports: [AuditLogService],
})
export class AuditLogModule {}
@@ -0,0 +1,67 @@
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
export interface AuditLogEntry {
tenantId?: string;
userId?: string;
action: string;
resource?: string;
resourceId?: string;
metadata?: Record<string, any>;
ipAddress?: string;
userAgent?: string;
}
@Injectable()
export class AuditLogService {
private readonly logger = new Logger(AuditLogService.name);
constructor(private prisma: PrismaService) {}
async log(entry: AuditLogEntry): Promise<void> {
try {
await this.prisma.client.auditLog.create({ data: entry });
} catch (e: unknown) {
// Never let audit log failure break main flow
this.logger.error("AuditLog write failed", (e as Error).message);
}
}
async findAll(filters: {
tenantId?: string;
userId?: string;
action?: string;
resource?: string;
from?: string;
to?: string;
page?: number;
limit?: number;
}): Promise<{ data: any[]; total: number }> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 50;
const skip = (page - 1) * limit;
const where: any = {};
if (filters.tenantId) where.tenantId = filters.tenantId;
if (filters.userId) where.userId = filters.userId;
if (filters.action) where.action = { contains: filters.action, mode: "insensitive" };
if (filters.resource) where.resource = { contains: filters.resource, mode: "insensitive" };
if (filters.from || filters.to) {
where.createdAt = {};
if (filters.from) where.createdAt.gte = new Date(filters.from);
if (filters.to) where.createdAt.lte = new Date(filters.to);
}
const [data, total] = await Promise.all([
this.prisma.client.auditLog.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
this.prisma.client.auditLog.count({ where }),
]);
return { data, total };
}
}
+11 -3
View File
@@ -13,6 +13,14 @@ import { TOTP, generateSecret, generateURI, verify as totpVerify } from "otplib"
const TENANT_SLUG = "moraworld";
const BCRYPT_ROUNDS = 10;
function buildSuiteAddress(suiteCode: string, config: import("@nestjs/config").ConfigService): string {
const street = config.get("WAREHOUSE_ADDRESS_STREET", "150 N Day St");
const city = config.get("WAREHOUSE_ADDRESS_CITY", "City of Orange");
const state = config.get("WAREHOUSE_ADDRESS_STATE", "NJ");
const zip = config.get("WAREHOUSE_ADDRESS_ZIP", "07050");
return `${street}, Suite ${suiteCode}, ${city}, ${state} ${zip}, EE.UU.`;
}
@Injectable()
export class AuthService {
constructor(
@@ -58,7 +66,7 @@ export class AuthService {
return {
user: this.sanitizeUser(user),
suiteCode,
suiteAddress: `150 N Day St, Suite ${suiteCode}, City of Orange, NJ 07050, EE.UU.`,
suiteAddress: buildSuiteAddress(suiteCode, this.config),
...tokens,
};
}
@@ -106,7 +114,7 @@ export class AuthService {
user: this.sanitizeUser(user),
suite: suite ? {
code: suite.code,
address: `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`,
address: buildSuiteAddress(suite.code, this.config),
} : null,
...tokens,
};
@@ -178,7 +186,7 @@ export class AuthService {
...this.sanitizeUser(user),
suite: suite ? {
code: suite.code,
address: `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`,
address: buildSuiteAddress(suite.code, this.config),
} : null,
};
}
+7 -1
View File
@@ -1,9 +1,11 @@
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { join } from "path";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const corsOrigins = (process.env.CORS_ORIGINS ?? "http://localhost:3000")
.split(",")
@@ -16,6 +18,10 @@ async function bootstrap() {
app.setGlobalPrefix("api");
// Serve uploaded files (photos, invoices) as static assets
const uploadsDir = join(process.cwd(), "uploads");
app.useStaticAssets(uploadsDir, { prefix: "/uploads" });
// Validación global de DTOs (class-validator)
app.useGlobalPipes(
new ValidationPipe({
@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { NotificationsService } from "./notifications.service";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
imports: [PrismaModule],
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}
@@ -0,0 +1,70 @@
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
constructor(private prisma: PrismaService) {}
/** Called whenever a package status changes. Creates Notification records and stubs dispatch. */
async notifyStatusChange(pkg: any, user: any): Promise<void> {
const statusLabels: Record<string, string> = {
REGISTRADO: "fue registrado en el sistema",
EN_TRANSITO_BODEGA: "está en tránsito hacia la bodega NJ",
RECIBIDO_BODEGA: "fue recibido en la bodega de NJ",
EN_VERIFICACION: "está siendo verificado en bodega",
VERIFICADO: "fue verificado. El cobro final fue aplicado.",
DECLARACION_ADUANERA: "tiene su declaración aduanera aprobada (SENAE)",
EN_TRANSITO_ECUADOR: "está en tránsito hacia Ecuador",
EN_ADUANA_ECUADOR: "está en inspección aduanera en Ecuador",
LISTO_ENTREGA: "está listo para entrega",
ENTREGADO: "fue entregado exitosamente",
INCIDENCIA: "tiene una incidencia reportada",
};
const label = statusLabels[pkg.status] ?? `cambió a estado ${pkg.status}`;
const body = `Tu paquete ${pkg.trackingId} ${label}.`;
const subject = `Estado de tu paquete: ${pkg.trackingId}`;
const channels: Array<"EMAIL" | "WHATSAPP" | "SMS" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
for (const channel of channels) {
try {
await this.prisma.client.notification.create({
data: {
packageId: pkg.id,
userId: pkg.userId,
channel,
status: "PENDIENTE",
subject,
body,
},
});
// STUB: In production, dispatch via SendGrid (EMAIL), WhatsApp Business API (WHATSAPP), etc.
this.logger.log(`[NOTIF STUB] ${channel} → userId=${pkg.userId} | ${body}`);
// Mark as sent (stub — in prod this would be async)
await this.prisma.client.notification.updateMany({
where: { packageId: pkg.id, userId: pkg.userId, channel, status: "PENDIENTE" },
data: { status: "ENVIADO", sentAt: new Date() },
});
} catch (e: unknown) {
this.logger.error(`Notification ${channel} failed: ${(e as Error).message}`);
}
}
}
async findByUser(userId: string, limit = 20): Promise<any[]> {
return this.prisma.client.notification.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
take: limit,
});
}
async findByPackage(packageId: string): Promise<any[]> {
return this.prisma.client.notification.findMany({
where: { packageId },
orderBy: { createdAt: "desc" },
});
}
}
+31 -43
View File
@@ -1,50 +1,38 @@
import { IsString, IsOptional, IsNumber, Min } from "class-validator";
import { IsString, IsOptional, IsNumber, IsPositive, IsEnum, Min } from "class-validator";
import { Type } from "class-transformer";
export class CreatePackageDto {
@IsString()
vendorTracking!: string;
@IsString()
description!: string;
@IsString()
store!: string;
@IsOptional()
@IsNumber()
@Min(0)
declaredValue?: number;
@IsOptional()
@IsNumber()
@Min(0)
declaredWeightLb?: number;
@IsOptional()
@IsNumber()
@Min(0)
lengthCm?: number;
@IsOptional()
@IsNumber()
@Min(0)
widthCm?: number;
@IsOptional()
@IsNumber()
@Min(0)
heightCm?: number;
/** userId del cliente al que pertenece este paquete */
@IsString()
userId!: string;
@IsString() userId!: string;
@IsString() description!: string;
@IsOptional() @IsString() store?: string;
@IsOptional() @IsString() vendorTracking?: string;
@IsOptional() @IsString() productUrl?: string;
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) declaredValue?: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() declaredWeightLb?: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() lengthCm?: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() widthCm?: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() heightCm?: number;
}
export class UpdateStatusDto {
@IsString()
status!: string;
@IsEnum([
"REGISTRADO","EN_TRANSITO_BODEGA","RECIBIDO_BODEGA","EN_VERIFICACION",
"VERIFICADO","DECLARACION_ADUANERA","EN_TRANSITO_ECUADOR","EN_ADUANA_ECUADOR",
"LISTO_ENTREGA","ENTREGADO","INCIDENCIA",
]) status!: string;
@IsOptional() @IsString() note?: string;
}
@IsOptional()
@IsString()
note?: string;
export class VerifyPackageDto {
@Type(() => Number) @IsNumber() @IsPositive() actualWeightLb!: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() lengthCm?: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() widthCm?: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() heightCm?: number;
@IsOptional() @IsString() note?: string;
}
export class SenaeDeclarationDto {
@IsEnum(["REGIMEN_4X4","CATEGORIA_B","CATEGORIA_C","CATEGORIA_D"])
category!: string;
@IsOptional() @IsString() agentNotes?: string;
}
+92 -5
View File
@@ -1,19 +1,45 @@
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from "@nestjs/common";
import {
Controller, Get, Post, Patch, Body, Param, Query,
UseGuards, UseInterceptors, UploadedFiles,
} from "@nestjs/common";
import { FilesInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import { extname, join } from "path";
import { existsSync, mkdirSync } from "fs";
import { randomUUID } from "crypto";
import { PackagesService } from "./packages.service";
import { CreatePackageDto, UpdateStatusDto } from "./dto/package.dto";
import { CreatePackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import { ConfigService } from "@nestjs/config";
@Controller("packages")
@UseGuards(JwtAuthGuard, RolesGuard)
export class PackagesController {
constructor(private svc: PackagesService) {}
private readonly apiUrl: string;
constructor(
private svc: PackagesService,
private config: ConfigService,
) {
this.apiUrl = this.config.get("API_URL", "http://localhost:3001");
}
@Get()
findAll(@CurrentUser() user: any, @Query("status") status?: string, @Query("search") search?: string): Promise<any[]> {
findAll(
@CurrentUser() user: any,
@Query("status") status?: string,
@Query("search") search?: string,
): Promise<any[]> {
return this.svc.findAll(user, { status, search });
}
@Get("pending-declaration")
@Roles("AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN")
pendingDeclaration(@CurrentUser() user: any): Promise<any[]> {
return this.svc.findPendingDeclaration(user.tenantId);
}
@Get(":id")
findOne(@Param("id") id: string, @CurrentUser() user: any): Promise<any> {
return this.svc.findOne(id, user);
@@ -27,7 +53,68 @@ export class PackagesController {
@Patch(":id/status")
@Roles("OPERADOR_BODEGA", "AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN")
updateStatus(@Param("id") id: string, @Body() dto: UpdateStatusDto, @CurrentUser() user: any): Promise<any> {
updateStatus(
@Param("id") id: string,
@Body() dto: UpdateStatusDto,
@CurrentUser() user: any,
): Promise<any> {
return this.svc.updateStatus(id, dto, user.id);
}
/** Bodega: verify weight/dims + detect discrepancy (doc §10 step 4) */
@Patch(":id/verify")
@Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN")
verify(
@Param("id") id: string,
@Body() dto: VerifyPackageDto,
@CurrentUser() user: any,
): Promise<any> {
return this.svc.verifyPackage(id, dto, user.id);
}
/** Bodega: upload photos via multipart form (doc §10 step 4) */
@Post(":id/photos")
@Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN")
@UseInterceptors(
FilesInterceptor("photos", 10, {
storage: diskStorage({
destination: (req, file, cb) => {
const pkgId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const dir = join(process.cwd(), "uploads", "packages", pkgId);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (req, file, cb) => {
cb(null, `${randomUUID()}${extname(file.originalname)}`);
},
}),
fileFilter: (req, file, cb) => {
const allowed = /jpg|jpeg|png|gif|webp/;
cb(null, allowed.test(extname(file.originalname).toLowerCase()));
},
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
}),
)
async uploadPhotos(
@Param("id") id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: any,
): Promise<any> {
const baseUrl = this.apiUrl;
const photoUrls = (files || []).map(
f => `${baseUrl}/uploads/packages/${id}/${f.filename}`,
);
return this.svc.addPhotos(id, photoUrls, user.id);
}
/** Aduanero: generate SENAE DSI declaration (doc §11) */
@Post(":id/senae/declare")
@Roles("AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN")
senaeDeclare(
@Param("id") id: string,
@Body() dto: SenaeDeclarationDto,
@CurrentUser() user: any,
): Promise<any> {
return this.svc.generateSenaeDeclaration(id, dto, user.id);
}
}
+7 -2
View File
@@ -1,9 +1,14 @@
import { Module } from "@nestjs/common";
import { PackagesController } from "./packages.controller";
import { PackagesService } from "./packages.service";
import { PackagesController } from "./packages.controller";
import { PrismaModule } from "../prisma/prisma.module";
import { ConfigModule } from "@nestjs/config";
import { NotificationsModule } from "../notifications/notifications.module";
@Module({
controllers: [PackagesController],
imports: [PrismaModule, ConfigModule, NotificationsModule],
providers: [PackagesService],
controllers: [PackagesController],
exports: [PackagesService],
})
export class PackagesModule {}
+134 -4
View File
@@ -1,11 +1,15 @@
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { NotificationsService } from "../notifications/notifications.service";
import { generateTrackingId } from "../common/utils/tracking-id.util";
import { CreatePackageDto, UpdateStatusDto } from "./dto/package.dto";
import { CreatePackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
@Injectable()
export class PackagesService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private notifications: NotificationsService,
) {}
async findAll(user: any, filters?: { status?: string; search?: string }): Promise<any[]> {
const where: any = { tenantId: user.tenantId };
@@ -58,6 +62,7 @@ export class PackagesService {
description: dto.description,
store: dto.store,
vendorTracking: dto.vendorTracking,
productUrl: dto.productUrl,
declaredValue: dto.declaredValue ?? 0,
declaredWeight: dto.declaredWeightLb ?? null,
lengthCm: dto.lengthCm ?? null,
@@ -72,7 +77,7 @@ export class PackagesService {
packageId: pkg.id,
status: "REGISTRADO",
createdBy: operatorId,
note: "Paquete registrado al recibirse en bodega NJ",
note: "Paquete registrado en el sistema",
},
});
@@ -97,6 +102,131 @@ export class PackagesService {
},
});
// Notify user on status change
this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {});
return updated;
}
/**
* Bodega verification: record actual weight, dims, detect discrepancy >10% (doc §10 step 4).
* Sets status to EN_VERIFICACION then VERIFICADO.
*/
async verifyPackage(id: string, dto: VerifyPackageDto, operatorId: string): Promise<any> {
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
// Detect discrepancy: >10% difference between declared and actual weight
let hasDiscrepancy = false;
if (pkg.declaredWeight && dto.actualWeightLb) {
const declared = Number(pkg.declaredWeight);
const actual = dto.actualWeightLb;
const diff = Math.abs(actual - declared) / declared;
hasDiscrepancy = diff > 0.10;
}
const updated = await this.prisma.client.package.update({
where: { id },
data: {
actualWeight: dto.actualWeightLb,
lengthCm: dto.lengthCm ?? pkg.lengthCm,
widthCm: dto.widthCm ?? pkg.widthCm,
heightCm: dto.heightCm ?? pkg.heightCm,
hasDiscrepancy,
status: "VERIFICADO",
},
});
await this.prisma.client.packageStatusHistory.create({
data: {
packageId: id,
status: "VERIFICADO",
createdBy: operatorId,
note: dto.note ?? (hasDiscrepancy
? `DISCREPANCIA: peso declarado ${pkg.declaredWeight}lb vs real ${dto.actualWeightLb}lb`
: `Verificado: peso real ${dto.actualWeightLb}lb`),
},
});
// Notify user on verification
this.notifications.notifyStatusChange({ ...updated, hasDiscrepancy }, { id: updated.userId }).catch(() => {});
return { ...updated, hasDiscrepancy };
}
/**
* Add photo URLs to a package (doc §10 step 4, §07 Portal Bodega).
*/
async addPhotos(id: string, photoUrls: string[], operatorId: string): Promise<any> {
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
const updated = await this.prisma.client.package.update({
where: { id },
data: { photos: { push: photoUrls } },
});
return updated;
}
/**
* SENAE declaration (doc §11): generate DSI stub, update status to DECLARACION_ADUANERA.
*/
async generateSenaeDeclaration(id: string, dto: SenaeDeclarationDto, agentId: string): Promise<any> {
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
if (pkg.status !== "VERIFICADO") {
throw new BadRequestException("El paquete debe estar en estado VERIFICADO para generar la declaración.");
}
// Stub: In prod this would call SENAE SOAP/REST WebService
// Generate a plausible authorization number
const authNumber = `SENAE-DSI-${new Date().getFullYear()}-${Math.floor(100000 + Math.random() * 900000)}`;
const declarationId = `DSI-${pkg.trackingId}`;
const updated = await this.prisma.client.package.update({
where: { id },
data: {
status: "DECLARACION_ADUANERA",
senaeCategory: dto.category as any,
senaeAuthNumber: authNumber,
senaeDeclarationId: declarationId,
},
});
await this.prisma.client.packageStatusHistory.create({
data: {
packageId: id,
status: "DECLARACION_ADUANERA",
createdBy: agentId,
note: dto.agentNotes ?? `DSI generada. Auth: ${authNumber}. Categoría: ${dto.category}`,
},
});
const result = {
...updated,
declarationId,
authNumber,
message: "Declaración simplificada (DSI) enviada y aprobada por la SENAE (stub).",
};
// Notify user of customs clearance
this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {});
return result;
}
/**
* Queue for SENAE: packages with status VERIFICADO pending declaration.
*/
async findPendingDeclaration(tenantId: string): Promise<any[]> {
return this.prisma.client.package.findMany({
where: { tenantId, status: "VERIFICADO" },
include: {
user: { select: { firstName: true, lastName: true, email: true } },
statusHistory: { orderBy: { createdAt: "desc" }, take: 1 },
},
orderBy: { updatedAt: "asc" },
});
}
}
@@ -0,0 +1,21 @@
import { Controller, Post, Body, UseGuards } from "@nestjs/common";
import { ProductsService } from "./products.service";
import { JwtAuthGuard } from "../auth/guards/auth.guard";
@Controller("products")
@UseGuards(JwtAuthGuard)
export class ProductsController {
constructor(private svc: ProductsService) {}
@Post("scan")
scanUrl(@Body("url") url: string): Promise<{
name: string;
price: number;
weightLb: number;
imageUrl: string;
store: string;
url: string;
}> {
return this.svc.scanUrl(url);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { ProductsService } from "./products.service";
import { ProductsController } from "./products.controller";
@Module({
providers: [ProductsService],
controllers: [ProductsController],
exports: [ProductsService],
})
export class ProductsModule {}
+44
View File
@@ -0,0 +1,44 @@
import { Injectable } from "@nestjs/common";
interface ProductScanResult {
name: string;
price: number;
weightLb: number;
imageUrl: string;
store: string;
url: string;
}
@Injectable()
export class ProductsService {
/**
* Stub: In production this calls Amazon SP-API or a scraping service.
* For now it extracts basic info from the URL and returns plausible mock data.
*/
async scanUrl(url: string): Promise<ProductScanResult> {
const store = this.detectStore(url);
// Extract ASIN from Amazon URL if present
const asinMatch = url.match(/\/dp\/([A-Z0-9]{10})/);
const asin = asinMatch ? asinMatch[1] : null;
// Stub response — in prod: call Amazon SP-API Catalog Items API
return {
name: asin ? `Producto Amazon (ASIN: ${asin})` : `Producto de ${store}`,
price: 29.99,
weightLb: 1.5,
imageUrl: "https://placehold.co/200x200?text=Product",
store,
url,
};
}
private detectStore(url: string): string {
if (url.includes("amazon.")) return "Amazon";
if (url.includes("ebay.")) return "eBay";
if (url.includes("walmart.")) return "Walmart";
if (url.includes("target.")) return "Target";
if (url.includes("bestbuy.")) return "Best Buy";
return "Tienda online";
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Module } from "@nestjs/common";
import { StorageService } from "./storage.service";
@Module({ providers: [StorageService], exports: [StorageService] })
export class StorageModule {}
+42
View File
@@ -0,0 +1,42 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as fs from "fs";
import * as path from "path";
import { randomUUID } from "crypto";
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private readonly uploadDir: string;
private readonly baseUrl: string;
constructor(private config: ConfigService) {
this.uploadDir = path.join(process.cwd(), "uploads");
this.baseUrl = this.config.get("API_URL", "http://localhost:3001");
// ensure uploads dir exists
if (!fs.existsSync(this.uploadDir)) {
fs.mkdirSync(this.uploadDir, { recursive: true });
}
}
async saveFile(subdir: string, originalName: string, buffer: Buffer): Promise<string> {
const ext = path.extname(originalName);
const filename = `${randomUUID()}${ext}`;
const dir = path.join(this.uploadDir, subdir);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const filepath = path.join(dir, filename);
fs.writeFileSync(filepath, buffer);
// Return URL relative to API base
return `${this.baseUrl}/uploads/${subdir}/${filename}`;
}
async deleteFile(url: string): Promise<void> {
try {
const relative = url.replace(/^https?:\/\/[^/]+\/uploads\//, "");
const filepath = path.join(this.uploadDir, relative);
if (fs.existsSync(filepath)) fs.unlinkSync(filepath);
} catch (e: unknown) {
this.logger.warn(`deleteFile failed: ${(e as Error).message}`);
}
}
}
@@ -0,0 +1,22 @@
import { Controller, Get, Put, Body, UseGuards } from "@nestjs/common";
import { TariffsService } from "./tariffs.service";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
@Controller("tariffs")
@UseGuards(JwtAuthGuard, RolesGuard)
export class TariffsController {
constructor(private svc: TariffsService) {}
@Get()
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN", "OPERADOR_BODEGA", "AGENTE_ADUANERO")
get(@CurrentUser() user: any) {
return this.svc.findByTenant(user.tenantId);
}
@Put()
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
update(@CurrentUser() user: any, @Body() dto: any) {
return this.svc.update(user.tenantId, dto);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { TariffsService } from "./tariffs.service";
import { TariffsController } from "./tariffs.controller";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
imports: [PrismaModule],
providers: [TariffsService],
controllers: [TariffsController],
exports: [TariffsService],
})
export class TariffsModule {}
+34
View File
@@ -0,0 +1,34 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class TariffsService {
constructor(private prisma: PrismaService) {}
async findByTenant(tenantId: string): Promise<any> {
let tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } });
if (!tariff) {
// Auto-create default tariff if not exists
tariff = await this.prisma.client.tariff.create({
data: { tenantId },
});
}
return tariff;
}
async update(tenantId: string, dto: Partial<{
pricePerLb: number;
insurancePct: number;
fodinfaPct: number;
ivaPct: number;
max4x4Value: number;
max4x4WeightKg: number;
max4x4PerYear: number;
}>): Promise<any> {
const existing = await this.prisma.client.tariff.findUnique({ where: { tenantId } });
if (!existing) {
return this.prisma.client.tariff.create({ data: { tenantId, ...dto } });
}
return this.prisma.client.tariff.update({ where: { tenantId }, data: dto });
}
}
+120 -10
View File
@@ -1,20 +1,130 @@
"use client";
// Auditoría — placeholder con nota de implementación futura (requiere endpoint /audit)
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
export default function AuditoriaPage() {
const [logs, setLogs] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [filters, setFilters] = useState({ action: "", resource: "", from: "", to: "" });
const LIMIT = 50;
const load = async (p = 1) => {
setLoading(true);
const params: Record<string, string> = { page: String(p), limit: String(LIMIT) };
if (filters.action) params.action = filters.action;
if (filters.resource) params.resource = filters.resource;
if (filters.from) params.from = filters.from;
if (filters.to) params.to = filters.to;
try {
const res = await api.auditLogs.list(params);
setLogs(res.data);
setTotal(res.total);
} catch {}
finally { setLoading(false); }
};
useEffect(() => { load(page); }, [page]);
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setFilters(f => ({ ...f, [k]: e.target.value }));
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setPage(1);
load(1);
};
const ACTION_COLORS: Record<string, string> = {
LOGIN_SUCCESS: "badge-green", LOGIN_FAILED: "badge-red", LOGOUT: "badge-gray",
MFA_ENABLED: "badge-blue", MFA_FAILED: "badge-red", USER_REGISTER: "badge-green",
};
const fmt = (d: string) => new Date(d).toLocaleString("es-EC", { timeZone: "America/Guayaquil" });
const pages = Math.ceil(total / LIMIT);
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Auditoría</h1><p className="dash-page-subtitle">Registro de acciones del sistema (AuditLog).</p></div>
<div className="card">
<div className="mb-6">
<h1 className="dash-page-title">Log de Auditoría</h1>
<p className="dash-page-subtitle">Registro inmutable de todas las acciones del sistema ISO 27001 A.12. {total} eventos.</p>
</div>
{/* Filters */}
<div className="card mb-4">
<div className="card-body">
<div className="alert alert-info">
El log de auditoría se registra automáticamente en la tabla <code>AuditLog</code> de la base de datos.
Para consultarlo directamente, accede al panel de base de datos o agrega el endpoint <code>GET /api/audit</code> en la API.
</div>
<p style={{ marginTop: "1rem", fontSize: ".9rem", color: "var(--gray-500)" }}>
Acciones registradas: LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, MFA_ENABLED, USER_REGISTER, y más.
</p>
<form onSubmit={handleSearch} style={{ display: "flex", gap: "1rem", flexWrap: "wrap", alignItems: "flex-end" }}>
<div className="form-group" style={{ flex: "1 1 140px" }}>
<label className="form-label">Acción</label>
<input className="form-input" placeholder="LOGIN_SUCCESS…" value={filters.action} onChange={set("action")} />
</div>
<div className="form-group" style={{ flex: "1 1 120px" }}>
<label className="form-label">Recurso</label>
<input className="form-input" placeholder="User, Package…" value={filters.resource} onChange={set("resource")} />
</div>
<div className="form-group" style={{ flex: "1 1 140px" }}>
<label className="form-label">Desde</label>
<input className="form-input" type="date" value={filters.from} onChange={set("from")} />
</div>
<div className="form-group" style={{ flex: "1 1 140px" }}>
<label className="form-label">Hasta</label>
<input className="form-input" type="date" value={filters.to} onChange={set("to")} />
</div>
<button type="submit" className="btn btn-primary" style={{ height: "38px" }}>Filtrar</button>
<button type="button" className="btn btn-ghost" style={{ height: "38px" }}
onClick={() => { setFilters({ action: "", resource: "", from: "", to: "" }); setPage(1); load(1); }}>
Limpiar
</button>
</form>
</div>
</div>
<div className="card">
{loading ? (
<div className="flex justify-center py-8"><div className="spinner" /></div>
) : (
<div className="table-wrap" style={{ border: "none" }}>
<table>
<thead>
<tr>
<th>Fecha (ECT)</th>
<th>Acción</th>
<th>Recurso</th>
<th>ID Recurso</th>
<th>Usuario ID</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr><td colSpan={6} style={{ textAlign: "center", padding: "2rem", color: "var(--gray-500)" }}>Sin registros.</td></tr>
) : logs.map(l => (
<tr key={l.id}>
<td style={{ fontSize: ".78rem", fontFamily: "monospace", whiteSpace: "nowrap" }}>{fmt(l.createdAt)}</td>
<td><span className={`badge ${ACTION_COLORS[l.action] ?? "badge-gray"}`} style={{ fontSize: ".72rem" }}>{l.action}</span></td>
<td className="text-sm">{l.resource ?? "—"}</td>
<td className="text-sm" style={{ fontFamily: "monospace", fontSize: ".75rem", color: "var(--gray-500)" }}>{l.resourceId?.slice(0,12) ?? "—"}</td>
<td className="text-sm" style={{ fontFamily: "monospace", fontSize: ".75rem" }}>{l.userId?.slice(0,12) ?? "—"}</td>
<td className="text-sm" style={{ fontFamily: "monospace", fontSize: ".75rem" }}>{l.ipAddress ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{pages > 1 && (
<div style={{ display: "flex", gap: ".5rem", padding: "1rem", justifyContent: "center" }}>
<button className="btn btn-ghost btn-sm" disabled={page === 1} onClick={() => setPage(p => p - 1)}> Anterior</button>
<span style={{ padding: "6px 12px", fontSize: ".85rem", color: "var(--gray-600)" }}>
Página {page} de {pages}
</span>
<button className="btn btn-ghost btn-sm" disabled={page === pages} onClick={() => setPage(p => p + 1)}>Siguiente </button>
</div>
)}
</div>
</div>
);
}
+124 -33
View File
@@ -1,47 +1,138 @@
"use client";
// Página de tarifas — muestra tabla estática de precios (§05 doc) + formulario de edición futuro
const TARIFAS = [
{ category: "Mensajería Acelerada", max: "$200", exento: "Sí (≤ $200)", flete: "$8$15/lb", notas: "Hasta 4 kg · sin impuestos" },
{ category: "Courier", max: "$400", exento: "≤ $200", flete: "$8$15/lb", notas: "Impuestos desde $200.01" },
{ category: "Régimen 4×4", max: "$2,000",exento: "No", flete: "Variable", notas: "SENAE arancel + IVA + FODINFA" },
{ category: "Carga pesada (FCL)", max: "Ilimitado", exento: "No", flete: "Cotización", notas: "Tarifa por m³ y peso" },
];
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
export default function TarifasPage() {
const [tariff, setTariff] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [form, setForm] = useState({
pricePerLb: "3.50",
insurancePct: "0.02",
fodinfaPct: "0.005",
ivaPct: "0.15",
max4x4Value: "400",
max4x4WeightKg: "4",
max4x4PerYear: "4",
});
useEffect(() => {
api.tariffs.get()
.then((t) => {
setTariff(t);
setForm({
pricePerLb: String(t.pricePerLb),
insurancePct: String(t.insurancePct),
fodinfaPct: String(t.fodinfaPct),
ivaPct: String(t.ivaPct),
max4x4Value: String(t.max4x4Value),
max4x4WeightKg: String(t.max4x4WeightKg),
max4x4PerYear: String(t.max4x4PerYear),
});
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true); setMsg(null);
try {
const updated = await api.tariffs.update({
pricePerLb: parseFloat(form.pricePerLb),
insurancePct: parseFloat(form.insurancePct),
fodinfaPct: parseFloat(form.fodinfaPct),
ivaPct: parseFloat(form.ivaPct),
max4x4Value: parseFloat(form.max4x4Value),
max4x4WeightKg: parseFloat(form.max4x4WeightKg),
max4x4PerYear: parseInt(form.max4x4PerYear),
});
setTariff(updated);
setMsg({ type: "success", text: "Tarifas actualizadas correctamente." });
} catch (err: any) {
setMsg({ type: "error", text: err.message ?? "Error al guardar" });
} finally { setSaving(false); }
};
const FIELDS = [
{ key: "pricePerLb", label: "Precio por libra (USD)", step: "0.01", min: "0.1", suffix: "$/lb" },
{ key: "insurancePct", label: "Seguro (% sobre valor declarado)", step: "0.001", min: "0", suffix: "%" },
{ key: "fodinfaPct", label: "FODINFA (% — fijo SENAE 0.5%)", step: "0.001", min: "0", suffix: "%" },
{ key: "ivaPct", label: "IVA Ecuador (%)", step: "0.01", min: "0", suffix: "%" },
{ key: "max4x4Value", label: "Límite 4×4: valor máximo (USD)", step: "1", min: "1", suffix: "$" },
{ key: "max4x4WeightKg", label: "Límite 4×4: peso máximo (kg)", step: "0.1", min: "0.1",suffix: "kg" },
{ key: "max4x4PerYear", label: "Límite 4×4: envíos máx./año", step: "1", min: "1", suffix: "envíos" },
];
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Tarifas</h1>
<p className="dash-page-subtitle">Tabla de regímenes aduaneros y precios base.</p>
<h1 className="dash-page-title">Configuración de Tarifas</h1>
<p className="dash-page-subtitle">Ajusta precios de flete, seguros, impuestos SENAE y límites del régimen 4×4 (doc §15).</p>
</div>
<div className="card" style={{ marginBottom: "1.5rem" }}>
<div className="card-header"><span className="font-semibold">Regímenes aduaneros (§05 / §15)</span></div>
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<thead>
<tr>
<th>Régimen</th><th>Valor máx.</th><th>Exento</th><th>Flete</th><th>Notas</th>
</tr>
</thead>
<tbody>
{TARIFAS.map(t => (
<tr key={t.category}>
<td className="font-semibold">{t.category}</td>
<td>{t.max}</td>
<td><span className={`badge ${t.exento.startsWith("Sí") || t.exento.startsWith("≤") ? "badge-green" : "badge-gray"}`}>{t.exento}</span></td>
<td>{t.flete}</td>
<td className="text-sm text-muted">{t.notas}</td>
</tr>
{msg && <div className={`alert alert-${msg.type} mb-4`}>{msg.text}</div>}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem", alignItems: "start" }}>
{/* Edit form */}
<div className="card">
<div className="card-header"><span className="font-semibold">Editar tarifas</span></div>
<div className="card-body">
<form onSubmit={handleSave} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{FIELDS.map(f => (
<div key={f.key} className="form-group">
<label className="form-label">{f.label}</label>
<div style={{ display: "flex", alignItems: "center", gap: ".5rem" }}>
<input className="form-input" type="number" step={f.step} min={f.min}
value={form[f.key as keyof typeof form]} onChange={set(f.key)} required style={{ flex: 1 }} />
<span style={{ fontSize: ".85rem", color: "var(--gray-500)", minWidth: "40px" }}>{f.suffix}</span>
</div>
</div>
))}
</tbody>
</table>
<button type="submit" className="btn btn-primary" disabled={saving} style={{ marginTop: ".5rem" }}>
{saving ? "Guardando…" : "Guardar cambios"}
</button>
</form>
</div>
</div>
</div>
<div className="alert alert-info">
<strong>Nota:</strong> Las tarifas de flete varían según el peso volumétrico (L×W×H / 139) vs. peso real. Se cobra el mayor. Los impuestos SENAE (FODINFA 0.5%, Arancel variable, IVA 15%) se calculan automáticamente en la calculadora del portal.
{/* Preview */}
<div>
<div className="card" style={{ marginBottom: "1rem" }}>
<div className="card-header"><span className="font-semibold">Tarifas actuales</span></div>
<div className="table-wrap" style={{ border: "none" }}>
<table>
<thead><tr><th>Concepto</th><th>Valor</th></tr></thead>
<tbody>
{tariff && FIELDS.map(f => (
<tr key={f.key}>
<td className="text-sm">{f.label}</td>
<td className="font-semibold">{tariff[f.key]} {f.suffix}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Formula reminder */}
<div className="card" style={{ background: "var(--dark)", color: "#A5B4FC" }}>
<div className="card-body" style={{ fontFamily: "monospace", fontSize: ".78rem", lineHeight: 1.9 }}>
<div style={{ color: "rgba(255,255,255,.3)", marginBottom: ".5rem" }}>// Fórmulas SENAE (doc §15)</div>
<div>Flete = Peso_final × <span style={{ color: "#FCD34D" }}>{form.pricePerLb}</span></div>
<div>Seguro = Valor × <span style={{ color: "#FCD34D" }}>{form.insurancePct}</span></div>
<div>FODINFA = Valor × <span style={{ color: "#FCD34D" }}>{form.fodinfaPct}</span></div>
<div>IVA = (Valor + FODINFA + Arancel) × <span style={{ color: "#FCD34D" }}>{form.ivaPct}</span></div>
<div style={{ color: "#6EE7B7", marginTop: ".5rem", fontWeight: 700 }}>TOTAL = Flete + Seguro + FODINFA + Arancel + IVA</div>
</div>
</div>
</div>
</div>
</div>
);
@@ -0,0 +1,152 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
// SENAE Declaraciones — doc §11: Agente Aduanero genera DSI para paquetes VERIFICADOS
export default function DeclaracionesPage() {
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 = () => {
api.packages.pendingDeclaration()
.then(setPackages).catch(() => {}).finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const 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";
};
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}` });
setSelected(null);
load();
} catch (err: any) {
setMsg({ type: "error", text: err.message ?? "Error al generar declaración" });
} finally { setSubmitting(false); }
};
const CATEGORIES = [
{ value: "REGIMEN_4X4", label: "Régimen 4×4 — 0% (≤$400, ≤4kg)" },
{ value: "CATEGORIA_B", label: "Categoría B — 10% (bienes generales)" },
{ value: "CATEGORIA_C", label: "Categoría C — 20% (textiles, calzado, hogar)" },
{ value: "CATEGORIA_D", label: "Categoría D — 015% (electrónicos)" },
];
return (
<div>
<div className="mb-6">
<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 de Importación.</p>
</div>
{msg && <div className={`alert alert-${msg.type} mb-4`}>{msg.text}</div>}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem", alignItems: "start" }}>
{/* Cola de paquetes VERIFICADOS */}
<div className="card">
<div className="card-header">
<span className="font-semibold">Pendientes de declaración</span>
<span className="badge badge-orange ml-2">{packages.length}</span>
</div>
{loading ? (
<div className="flex justify-center py-8"><div className="spinner" /></div>
) : packages.length === 0 ? (
<div style={{ padding: "2rem", textAlign: "center", color: "var(--gray-500)" }}>
No hay paquetes pendientes de declaración.<br/>
<span style={{ fontSize: ".8rem" }}>Los paquetes en estado VERIFICADO aparecerán aquí.</span>
</div>
) : (
<div className="table-wrap" style={{ border: "none" }}>
<table>
<thead><tr><th>Tracking</th><th>Valor</th><th>Peso real</th><th></th></tr></thead>
<tbody>
{packages.map(p => (
<tr key={p.id} style={{ cursor: "pointer", background: selected?.id === p.id ? "var(--blue-50)" : undefined }}
onClick={() => handleSelect(p)}>
<td className="font-semibold" style={{ color: "var(--blue)", fontSize: ".82rem" }}>{p.trackingId}</td>
<td className="text-sm">${p.declaredValue}</td>
<td className="text-sm">{p.actualWeight ? `${p.actualWeight}lb` : "—"}</td>
<td>
{parseFloat(p.declaredValue ?? 0) > 400 && (
<span className="badge badge-red" style={{ fontSize: ".7rem" }}>Supera 4×4</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Formulario DSI */}
{selected && (
<div className="card">
<div className="card-header">
<span className="font-semibold">Generar DSI: {selected.trackingId}</span>
</div>
<div className="card-body">
{/* Resumen del paquete */}
<div style={{ background: "var(--gray-50)", borderRadius: "8px", padding: "12px", 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 declarado:</strong> <strong style={{ color: parseFloat(selected.declaredValue) > 400 ? "var(--red)" : "var(--green)" }}>${selected.declaredValue}</strong></div>
<div><strong>Peso real:</strong> {selected.actualWeight ? `${selected.actualWeight}lb` : selected.declaredWeight ? `${selected.declaredWeight}lb (decl.)` : "—"}</div>
<div><strong>Cliente:</strong> {selected.user?.firstName} {selected.user?.lastName}</div>
</div>
{parseFloat(selected.declaredValue ?? 0) > 400 && (
<div style={{ marginTop: ".75rem", padding: "8px 12px", background: "#FFF3E8", borderRadius: "6px", borderLeft: "3px solid var(--orange)", fontSize: ".8rem" }}>
<strong>Supera el límite 4×4 ($400).</strong> Se requiere proceso de importación formal (DAI).
</div>
)}
</div>
<form onSubmit={handleDeclare} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="form-group">
<label className="form-label">Categoría SENAE *</label>
<select className="form-input" value={form.category} onChange={set("category")}>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Notas del agente</label>
<textarea className="form-input" rows={3} value={form.agentNotes} onChange={set("agentNotes")}
placeholder="Observaciones o partida arancelaria..." />
</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 DSI a SENAE"}
</button>
<button type="button" className="btn btn-ghost" onClick={() => setSelected(null)}>Cancelar</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
);
}
+67 -39
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
// Despacho — paquetes listos para enviar a Ecuador
// Despacho — doc §10 step 5/6: paquetes DECLARACION_ADUANERA → EN_TRANSITO_ECUADOR
export default function DespachoPage() {
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
@@ -10,56 +10,84 @@ export default function DespachoPage() {
const [msg, setMsg] = useState("");
const load = () => {
api.packages.list({ status: "LISTO_PARA_RETIRO" }).then(setPackages).catch(()=>{}).finally(()=>setLoading(false));
api.packages.list({ status: "DECLARACION_ADUANERA" })
.then(setPackages).catch(() => {}).finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const markDispatched = async (id: string) => {
const dispatch = async (id: string, trackingId: string) => {
if (!confirm(`¿Despachar el paquete ${trackingId} hacia Ecuador?`)) return;
setUpdating(id);
try {
await api.packages.updateStatus(id, { status: "EN_CAMINO_A_ECUADOR", notes: "Despachado desde bodega NJ" });
setMsg("Paquete marcado como despachado.");
await api.packages.updateStatus(id, {
status: "EN_TRANSITO_ECUADOR",
note: "Paquete despachado desde bodega NJ hacia Ecuador vía courier internacional.",
});
setMsg(`✅ Paquete ${trackingId} despachado correctamente.`);
load();
} catch (err: any) { setMsg(err.message ?? "Error"); }
finally { setUpdating(null); }
} catch (err: any) {
setMsg(err.message ?? "Error al despachar");
} finally { setUpdating(null); }
};
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Despacho</h1><p className="dash-page-subtitle">Paquetes listos para envío a Ecuador.</p></div>
<div className="mb-6">
<h1 className="dash-page-title">Despacho hacia Ecuador</h1>
<p className="dash-page-subtitle">Paquetes con declaración SENAE aprobada listos para despacho. Estado EN_TRANSITO_ECUADOR.</p>
</div>
{msg && <div className="alert alert-success mb-4">{msg}</div>}
{loading ? <div className="flex justify-center py-16"><div className="spinner" /></div> : (
packages.length === 0 ? (
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}></div>
<p style={{ color: "var(--gray-500)" }}>No hay paquetes pendientes de despacho.</p>
{loading ? (
<div className="flex justify-center py-16"><div className="spinner" /></div>
) : packages.length === 0 ? (
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}></div>
<p style={{ color: "var(--gray-500)" }}>No hay paquetes pendientes de despacho.</p>
<p style={{ fontSize: ".85rem", color: "var(--gray-400)", marginTop: ".5rem" }}>
Los paquetes en estado DECLARACION_ADUANERA aparecerán aquí.
</p>
</div>
) : (
<div className="card">
<div className="table-wrap" style={{ border: "none", borderRadius: "var(--radius-lg)" }}>
<table>
<thead>
<tr>
<th>Tracking ID</th>
<th>Cliente</th>
<th>Descripción</th>
<th>Peso real</th>
<th>Valor decl.</th>
<th>SENAE Auth</th>
<th>Acción</th>
</tr>
</thead>
<tbody>
{packages.map(p => (
<tr key={p.id}>
<td className="font-semibold" style={{ color: "var(--blue)", fontSize: ".85rem" }}>{p.trackingId}</td>
<td className="text-sm">{p.user?.firstName} {p.user?.lastName}</td>
<td className="text-sm">{p.description}</td>
<td className="text-sm">{p.actualWeight ? `${p.actualWeight}lb` : p.declaredWeight ? `${p.declaredWeight}lb (decl.)` : "—"}</td>
<td className="text-sm">${p.declaredValue}</td>
<td className="text-sm" style={{ fontFamily: "monospace", fontSize: ".75rem", color: "var(--green)" }}>
{p.senaeAuthNumber ?? "—"}
</td>
<td>
<button className="btn btn-primary btn-sm"
disabled={updating === p.id}
onClick={() => dispatch(p.id, p.trackingId)}>
{updating === p.id ? "…" : "🚢 Despachar"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="card">
<div className="table-wrap" style={{ border: "none", borderRadius: "var(--radius-lg)" }}>
<table>
<thead><tr><th>Tracking</th><th>Cliente</th><th>Peso</th><th>Valor declarado</th><th>Categoría</th><th>Acción</th></tr></thead>
<tbody>
{packages.map(p => (
<tr key={p.id}>
<td className="font-semibold text-primary">{p.trackingId}</td>
<td>{p.suite?.user?.firstName ?? "—"} {p.suite?.user?.lastName ?? ""}</td>
<td>{p.weightLb ? `${p.weightLb} lb` : "—"}</td>
<td>{p.declaredValueUsd ? `$${p.declaredValueUsd}` : "—"}</td>
<td><span className="badge badge-blue">{(p.senaeCategory ?? "").replace(/_/g," ")}</span></td>
<td>
<button className="btn btn-primary btn-sm" disabled={updating === p.id}
onClick={() => markDispatched(p.id)}>
{updating === p.id ? "…" : "Despachar →"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
</div>
)}
</div>
);
+7 -6
View File
@@ -6,14 +6,15 @@ import { getUser, clearAuth } from "@/lib/api";
import { api } from "@/lib/api";
const NAV = [
{ href: "/bodega", icon: "◈", label: "Dashboard" },
{ href: "/bodega/paquetes", icon: "📦", label: "Paquetes" },
{ href: "/bodega/verificacion", icon: "✅", label: "Verificación NJ" },
{ href: "/bodega/despacho", icon: "🚢", label: "Despacho" },
{ href: "/admin", icon: "⚙️", label: "→ Admin" },
{ href: "/bodega", icon: "◈", label: "Dashboard" },
{ href: "/bodega/paquetes", icon: "📦", label: "Paquetes" },
{ href: "/bodega/verificacion", icon: "✅", label: "Verificación" },
{ href: "/bodega/declaraciones", icon: "🛃", label: "Declaraciones SENAE" },
{ href: "/bodega/despacho", icon: "🚢", label: "Despacho" },
{ href: "/admin", icon: "⚙️", label: "→ Admin" },
];
const ALLOWED = ["OPERADOR_BODEGA","AGENTE_ADUANAS","ADMIN","SUPERADMIN"];
const ALLOWED = ["OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN"];
export default function BodegaLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
+145 -54
View File
@@ -1,73 +1,164 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { api } from "@/lib/api";
// Verificación NJ — muestra pre-alertas pendientes para vincular con paquetes recibidos
// Verificación NJ — doc §10 step 4: pesar, medir, fotografiar paquetes
export default function VerificacionPage() {
const [alerts, setAlerts] = useState<any[]>([]);
const [packages, setPackages] = useState<any[]>([]);
const [selected, setSelected] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [msg, setMsg] = useState("");
const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState<{ type: "success" | "error" | "warn"; text: string } | null>(null);
const [form, setForm] = useState({ actualWeightLb: "", lengthCm: "", widthCm: "", heightCm: "", note: "" });
const [photos, setPhotos] = useState<FileList | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const load = () => { api.preAlerts.list().then(setAlerts).catch(()=>{}).finally(()=>setLoading(false)); };
const load = () => {
api.packages.list({ status: "RECIBIDO_BODEGA" })
.then(setPackages).catch(() => {}).finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const handleStatus = async (id: string, status: string) => {
setUpdating(id);
try { await api.preAlerts.updateStatus(id, { status }); setMsg(`Pre-alerta marcada como ${status}.`); load(); }
catch (err: any) { setMsg(err.message ?? "Error"); }
finally { setUpdating(null); }
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const handleSelect = (pkg: any) => {
setSelected(pkg);
setForm({ actualWeightLb: pkg.declaredWeight ?? "", lengthCm: pkg.lengthCm ?? "", widthCm: pkg.widthCm ?? "", heightCm: pkg.heightCm ?? "", note: "" });
setPhotos(null);
setMsg(null);
};
const STATUS_BADGE: Record<string, string> = {
PENDIENTE: "badge-yellow", RECIBIDO: "badge-blue", VINCULADO: "badge-green", RECHAZADO: "badge-red",
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault();
if (!selected) return;
setSubmitting(true); setMsg(null);
try {
const result = await api.packages.verify(selected.id, {
actualWeightLb: parseFloat(form.actualWeightLb),
lengthCm: form.lengthCm ? parseFloat(form.lengthCm) : undefined,
widthCm: form.widthCm ? parseFloat(form.widthCm) : undefined,
heightCm: form.heightCm ? parseFloat(form.heightCm) : undefined,
note: form.note || undefined,
});
// Upload photos if any
if (photos && photos.length > 0) {
const fd = new FormData();
Array.from(photos).forEach(f => fd.append("photos", f));
await api.packages.uploadPhotos(selected.id, fd);
}
if (result.hasDiscrepancy) {
setMsg({ type: "warn", text: `⚠️ DISCREPANCIA detectada: peso declarado ${selected.declaredWeight}lb vs real ${form.actualWeightLb}lb. El cliente y Admin fueron notificados.` });
} else {
setMsg({ type: "success", text: `✅ Paquete ${selected.trackingId} verificado correctamente.` });
}
setSelected(null);
load();
} catch (err: any) {
setMsg({ type: "error", text: err.message ?? "Error al verificar" });
} finally {
setSubmitting(false);
}
};
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Verificación NJ</h1><p className="dash-page-subtitle">Vincula las pre-alertas de clientes con paquetes recibidos.</p></div>
<div className="mb-6">
<h1 className="dash-page-title">Verificación de Paquetes</h1>
<p className="dash-page-subtitle">Confirma peso real, dimensiones y sube fotos. Detecta discrepancias &gt;10% automáticamente.</p>
</div>
{msg && <div className="alert alert-success mb-4">{msg}</div>}
{loading ? <div className="flex justify-center py-16"><div className="spinner" /></div> : (
<div className="card">
<div className="table-wrap" style={{ border: "none", borderRadius: "var(--radius-lg)" }}>
<table>
<thead><tr><th>Tienda</th><th>Orden</th><th>Descripción</th><th>Valor est.</th><th>Tracking proveedor</th><th>Estado</th><th>Acciones</th></tr></thead>
<tbody>
{alerts.length === 0 ? (
<tr><td colSpan={7} style={{ textAlign: "center", padding: "2rem", color: "var(--gray-500)" }}>Sin pre-alertas.</td></tr>
) : alerts.map(a => (
<tr key={a.id}>
<td className="font-semibold">{a.store}</td>
<td className="text-sm">{a.orderNumber}</td>
<td className="text-sm">{a.description ?? "—"}</td>
<td className="text-sm">{a.estimatedValueUsd ? `$${a.estimatedValueUsd}` : "—"}</td>
<td className="text-sm">{a.trackingNumber ?? "—"}</td>
<td><span className={`badge ${STATUS_BADGE[a.status] ?? "badge-gray"}`}>{a.status}</span></td>
<td>
<div style={{ display: "flex", gap: ".5rem" }}>
{a.status === "PENDIENTE" && (
<>
<button className="btn btn-success btn-sm" disabled={updating === a.id}
onClick={() => handleStatus(a.id, "RECIBIDO")}>Recibido</button>
<button className="btn btn-danger btn-sm" disabled={updating === a.id}
onClick={() => handleStatus(a.id, "RECHAZADO")}>Rechazar</button>
</>
)}
{a.status === "RECIBIDO" && (
<button className="btn btn-primary btn-sm" disabled={updating === a.id}
onClick={() => handleStatus(a.id, "VINCULADO")}>Vincular</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{msg && (
<div className={`alert alert-${msg.type === "warn" ? "warning" : msg.type} mb-4`}>{msg.text}</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem", alignItems: "start" }}>
{/* Lista paquetes en RECIBIDO_BODEGA */}
<div className="card">
<div className="card-header"><span className="font-semibold">Paquetes pendientes de verificar</span></div>
{loading ? (
<div className="flex justify-center py-8"><div className="spinner" /></div>
) : packages.length === 0 ? (
<div style={{ padding: "2rem", textAlign: "center", color: "var(--gray-500)" }}>
No hay paquetes en estado RECIBIDO_BODEGA.
</div>
) : (
<div className="table-wrap" style={{ border: "none" }}>
<table>
<thead><tr><th>Tracking</th><th>Cliente</th><th>Peso decl.</th><th></th></tr></thead>
<tbody>
{packages.map(p => (
<tr key={p.id} style={{ cursor: "pointer", background: selected?.id === p.id ? "var(--blue-50)" : undefined }}
onClick={() => handleSelect(p)}>
<td className="font-semibold text-primary" style={{ fontSize: ".8rem" }}>{p.trackingId}</td>
<td className="text-sm">{p.user?.firstName} {p.user?.lastName}</td>
<td className="text-sm">{p.declaredWeight ? `${p.declaredWeight}lb` : "—"}</td>
<td><button className="btn btn-primary btn-sm">Verificar</button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Formulario de verificación */}
{selected && (
<div className="card">
<div className="card-header">
<span className="font-semibold">Verificar: {selected.trackingId}</span>
</div>
<div className="card-body">
<div style={{ marginBottom: "1rem", padding: "12px", background: "var(--gray-50)", borderRadius: "8px", fontSize: ".85rem" }}>
<strong>Descripción:</strong> {selected.description}<br/>
<strong>Tienda:</strong> {selected.store ?? "—"}<br/>
<strong>Valor declarado:</strong> ${selected.declaredValue ?? "—"}
</div>
<form onSubmit={handleVerify} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="form-group">
<label className="form-label">Peso real (lb) *</label>
<input className="form-input" type="number" step="0.01" required
value={form.actualWeightLb} onChange={set("actualWeightLb")} placeholder="ej: 2.35" />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: ".75rem" }}>
<div className="form-group">
<label className="form-label">Largo (cm)</label>
<input className="form-input" type="number" step="0.1" value={form.lengthCm} onChange={set("lengthCm")} placeholder="cm" />
</div>
<div className="form-group">
<label className="form-label">Ancho (cm)</label>
<input className="form-input" type="number" step="0.1" value={form.widthCm} onChange={set("widthCm")} placeholder="cm" />
</div>
<div className="form-group">
<label className="form-label">Alto (cm)</label>
<input className="form-input" type="number" step="0.1" value={form.heightCm} onChange={set("heightCm")} placeholder="cm" />
</div>
</div>
<div className="form-group">
<label className="form-label">Notas</label>
<textarea className="form-input" rows={2} value={form.note} onChange={set("note")} placeholder="Notas del operador..." />
</div>
<div className="form-group">
<label className="form-label">Fotos del paquete (hasta 10)</label>
<input ref={fileRef} type="file" multiple accept="image/*" onChange={e => setPhotos(e.target.files)}
style={{ fontSize: ".85rem" }} />
{photos && photos.length > 0 && (
<p style={{ fontSize: ".78rem", color: "var(--green)", marginTop: ".25rem" }}>{photos.length} foto(s) seleccionadas</p>
)}
</div>
<div style={{ display: "flex", gap: ".75rem" }}>
<button type="submit" className="btn btn-success" disabled={submitting} style={{ flex: 1 }}>
{submitting ? "Verificando…" : "✅ Confirmar verificación"}
</button>
<button type="button" className="btn btn-ghost" onClick={() => setSelected(null)}>Cancelar</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,75 @@
import Link from "next/link";
export const metadata = { title: "Cómo funciona la Carga Pesada — Moraworld Imports" };
export default function CargaPesadaComoFuncionaPage() {
return (
<div style={{ minHeight: "100vh", background: "var(--gray-50, #F9FAFB)" }}>
{/* Hero */}
<section style={{ background: "var(--dark, #0D1117)", color: "white", padding: "5rem 2rem 4rem" }}>
<div style={{ maxWidth: "800px", margin: "0 auto", textAlign: "center" }}>
<span style={{ background: "rgba(255,107,0,.2)", color: "var(--orange, #FF6B00)", padding: "4px 14px", borderRadius: "999px", fontSize: ".8rem", fontWeight: 700 }}>B2B · Mayorista</span>
<h1 style={{ fontSize: "clamp(2rem, 5vw, 3rem)", fontWeight: 800, margin: "1.5rem 0", lineHeight: 1.2 }}>
Carga Pesada / Pallets
</h1>
<p style={{ fontSize: "1.1rem", color: "rgba(255,255,255,.65)", maxWidth: "600px", margin: "0 auto" }}>
Importación mayorista desde EE.UU. a Ecuador. Pallets, contenedores, volumen.
</p>
</div>
</section>
<section style={{ maxWidth: "900px", margin: "0 auto", padding: "4rem 2rem" }}>
{/* Qué es */}
<div style={{ marginBottom: "3rem" }}>
<h2 style={{ fontSize: "1.5rem", fontWeight: 800, marginBottom: "1rem" }}>¿Qué es Carga Pesada?</h2>
<p style={{ color: "var(--gray-700, #374151)", lineHeight: 1.8, marginBottom: "1rem" }}>
El servicio de Carga Pesada está diseñado para importadores mayoristas que necesitan traer pallets completos, contenedores o volúmenes grandes de mercancía desde EE.UU. a Ecuador. A diferencia del casillero personal, cada operación se cotiza individualmente.
</p>
</div>
{/* Flujo */}
<div style={{ marginBottom: "3rem" }}>
<h2 style={{ fontSize: "1.5rem", fontWeight: 800, marginBottom: "1.5rem" }}>Cómo funciona</h2>
{[
{ step: "01", title: "Solicita cotización", desc: "Completa el formulario con tipo de mercancía, cantidad, peso estimado, número de pallets, valor comercial total y ciudad de origen en EE.UU.", color: "var(--orange)" },
{ step: "02", title: "Verificamos INEN", desc: "Para productos regulados (calzado, textiles, electrónicos), verificamos si requieren Registro de Conformidad INEN antes de proceder.", color: "var(--yellow, #F59E0B)" },
{ step: "03", title: "Recibe tu cotización", desc: "El equipo Moraworld prepara una cotización personalizada y te la envía por correo y WhatsApp en 2448 horas.", color: "var(--blue)" },
{ step: "04", title: "Acepta y coordina", desc: "Al aceptar, se crea un expediente con ID de seguimiento B2B propio. Coordinamos el embarque, BL, DAS/DAI y certificaciones.", color: "var(--green)" },
{ step: "05", title: "Entrega en Ecuador", desc: "Seguimiento completo hasta la entrega en tu ciudad. Declaración formal (DAI) ante la SENAE incluida.", color: "var(--green)" },
].map(s => (
<div key={s.step} style={{ display: "flex", gap: "1.5rem", marginBottom: "1.5rem" }}>
<div style={{ width: "42px", height: "42px", borderRadius: "50%", background: s.color, color: "white", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: ".85rem", flexShrink: 0 }}>{s.step}</div>
<div>
<h3 style={{ fontWeight: 700, marginBottom: ".4rem" }}>{s.title}</h3>
<p style={{ color: "var(--gray-600)", lineHeight: 1.7, fontSize: ".9rem" }}>{s.desc}</p>
</div>
</div>
))}
</div>
{/* Incluye */}
<div style={{ background: "white", borderRadius: "12px", border: "1px solid var(--gray-200)", padding: "2rem", marginBottom: "3rem" }}>
<h2 style={{ fontSize: "1.3rem", fontWeight: 800, marginBottom: "1.25rem" }}>¿Qué incluye el servicio?</h2>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
{["Coordinación de embarque desde EE.UU.", "Declaración de Importación (DAI) ante SENAE", "Gestión de partidas arancelarias", "Verificación de certificaciones INEN", "Seguimiento con ID B2B propio", "Asesoría en cumplimiento normativo Ecuador"].map(item => (
<div key={item} style={{ display: "flex", gap: ".75rem", alignItems: "flex-start", fontSize: ".9rem", color: "var(--gray-700)" }}>
<span style={{ color: "var(--green)", fontWeight: 700, flexShrink: 0 }}></span>{item}
</div>
))}
</div>
</div>
{/* CTA */}
<div style={{ textAlign: "center" }}>
<p style={{ color: "var(--gray-600)", marginBottom: "1.5rem" }}>¿Tienes un proyecto de importación? Cuéntanos.</p>
<Link href="/carga-pesada/cotizacion" style={{ background: "var(--orange)", color: "white", padding: "14px 40px", borderRadius: "8px", fontWeight: 700, textDecoration: "none", display: "inline-block", marginRight: "1rem" }}>
Solicitar cotización
</Link>
<Link href="/carga-pesada/inen" style={{ background: "transparent", color: "var(--orange)", padding: "14px 40px", borderRadius: "8px", fontWeight: 700, textDecoration: "none", display: "inline-block", border: "2px solid var(--orange)" }}>
Info INEN
</Link>
</div>
</section>
</div>
);
}
@@ -0,0 +1,92 @@
import Link from "next/link";
export const metadata = { title: "Normas INEN — Moraworld Imports" };
export default function InenPage() {
const REGULATED = [
{ icon: "👟", cat: "Calzado", req: "Registro de Conformidad INEN", norm: "NTE INEN 1672" },
{ icon: "👕", cat: "Textiles / ropa",req: "Registro de Conformidad INEN", norm: "NTE INEN 2587" },
{ icon: "💡", cat: "Electrónicos", req: "Certificado INEN o exoneración", norm: "Resoluciones COMEX" },
{ icon: "🍔", cat: "Alimentos", req: "Registro Sanitario ARCSA", norm: "Normativa sanitaria" },
{ icon: "🔧", cat: "Juguetes", req: "Registro de Conformidad INEN", norm: "NTE INEN 1875" },
{ icon: "🏠", cat: "Art. del hogar", req: "Varía según producto", norm: "Consultar INEN" },
];
return (
<div style={{ minHeight: "100vh", background: "var(--gray-50, #F9FAFB)" }}>
{/* Hero */}
<section style={{ background: "var(--dark, #0D1117)", color: "white", padding: "5rem 2rem 4rem" }}>
<div style={{ maxWidth: "800px", margin: "0 auto", textAlign: "center" }}>
<span style={{ background: "rgba(255,107,0,.2)", color: "var(--orange, #FF6B00)", padding: "4px 14px", borderRadius: "999px", fontSize: ".8rem", fontWeight: 700 }}>Normativa Ecuador</span>
<h1 style={{ fontSize: "clamp(2rem, 5vw, 2.75rem)", fontWeight: 800, margin: "1.5rem 0", lineHeight: 1.2 }}>
Normas INEN para importación
</h1>
<p style={{ fontSize: "1rem", color: "rgba(255,255,255,.65)", maxWidth: "600px", margin: "0 auto" }}>
Ciertos productos requieren certificación de conformidad antes de ingresar a Ecuador en volumen.
</p>
</div>
</section>
<section style={{ maxWidth: "860px", margin: "0 auto", padding: "4rem 2rem" }}>
{/* Warning callout */}
<div style={{ background: "#FEF3C7", border: "1px solid #FDE68A", borderLeft: "4px solid #F59E0B", borderRadius: "10px", padding: "16px 20px", marginBottom: "2.5rem", display: "flex", gap: "12px" }}>
<span style={{ fontSize: "1.1rem" }}></span>
<div style={{ fontSize: ".88rem", lineHeight: 1.7, color: "#78350F" }}>
<strong>Importante:</strong> El incumplimiento de las normas INEN puede resultar en la retención de la mercancía en aduana, multas y la devolución del envío. <strong>Moraworld asiste en verificar el requisito antes de embarcar.</strong>
</div>
</div>
{/* ¿Qué es INEN? */}
<div style={{ marginBottom: "2.5rem" }}>
<h2 style={{ fontSize: "1.4rem", fontWeight: 800, marginBottom: "1rem" }}>¿Qué es el INEN?</h2>
<p style={{ color: "var(--gray-700)", lineHeight: 1.8, marginBottom: "1rem" }}>
El <strong>Instituto Ecuatoriano de Normalización (INEN)</strong> es el organismo oficial que establece normas técnicas obligatorias para productos que se comercializan en Ecuador. Para importar ciertos productos en volumen, se requiere un <strong>Registro de Conformidad INEN</strong> o un certificado equivalente.
</p>
<p style={{ color: "var(--gray-700)", lineHeight: 1.8 }}>
El Registro de Conformidad se obtiene antes de la importación y tiene vigencia limitada. El proceso varía según el producto y puede requerir pruebas de laboratorio.
</p>
</div>
{/* Tabla de productos regulados */}
<div style={{ marginBottom: "2.5rem" }}>
<h2 style={{ fontSize: "1.4rem", fontWeight: 800, marginBottom: "1.25rem" }}>Productos que requieren certificación</h2>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
{REGULATED.map(r => (
<div key={r.cat} style={{ background: "white", border: "1px solid var(--gray-200)", borderRadius: "10px", padding: "16px 18px" }}>
<div style={{ fontSize: "1.5rem", marginBottom: ".5rem" }}>{r.icon}</div>
<div style={{ fontWeight: 700, marginBottom: ".4rem" }}>{r.cat}</div>
<div style={{ fontSize: ".82rem", color: "var(--gray-600)", marginBottom: ".4rem" }}>{r.req}</div>
<div style={{ fontSize: ".75rem", fontFamily: "monospace", color: "var(--blue)", background: "var(--blue-50, #EEF3FF)", padding: "2px 8px", borderRadius: "4px", display: "inline-block" }}>{r.norm}</div>
</div>
))}
</div>
</div>
{/* Proceso */}
<div style={{ background: "white", border: "1px solid var(--gray-200)", borderRadius: "12px", padding: "2rem", marginBottom: "2.5rem" }}>
<h2 style={{ fontSize: "1.3rem", fontWeight: 800, marginBottom: "1.25rem" }}>¿Cómo te ayudamos?</h2>
{[
"Verificamos si tu producto requiere certificación antes de embarcarlo.",
"Identificamos la norma técnica aplicable y el organismo certificador.",
"Orientamos en el proceso para obtener el Registro de Conformidad.",
"Coordinamos con el importador para tener toda la documentación lista.",
].map((t, i) => (
<div key={i} style={{ display: "flex", gap: ".75rem", marginBottom: ".75rem", fontSize: ".9rem", color: "var(--gray-700)" }}>
<span style={{ color: "var(--green)", fontWeight: 700, flexShrink: 0 }}></span>{t}
</div>
))}
</div>
{/* CTA */}
<div style={{ textAlign: "center", padding: "2rem 0" }}>
<p style={{ color: "var(--gray-600)", marginBottom: "1.5rem", fontSize: ".95rem" }}>
¿Tienes dudas sobre si tu producto requiere certificación? Consúltanos antes de importar.
</p>
<Link href="/carga-pesada/cotizacion" style={{ background: "var(--orange)", color: "white", padding: "14px 40px", borderRadius: "8px", fontWeight: 700, textDecoration: "none", display: "inline-block" }}>
Solicitar asesoría
</Link>
</div>
</section>
</div>
);
}
@@ -0,0 +1,70 @@
import Link from "next/link";
export const metadata = { title: "Cómo usar tu casillero — Moraworld Imports" };
export default function ComoUsarCasilleroPage() {
const STEPS = [
{ num: "01", icon: "📝", title: "Regístrate gratis", desc: "Crea tu cuenta en moraworldimports.com con tu email y contraseña. Activa la verificación en dos pasos (MFA) para mayor seguridad." },
{ num: "02", icon: "📫", title: "Recibe tu Suite personalizada", desc: "El sistema te asigna automáticamente una dirección única: 150 N Day St, Suite EC-XXXXX, City of Orange, NJ 07050. Es tu casillero en New Jersey." },
{ num: "03", icon: "🛒", title: "Compra en tiendas online de EE.UU.", desc: "Usa tu Suite como dirección de entrega en Amazon, eBay, Walmart, Target, Best Buy y cualquier tienda que envíe a New Jersey." },
{ num: "04", icon: "📣", title: "Registra una pre-alerta (opcional)", desc: "Avísanos qué paquete esperas, carga la factura y el tracking del vendedor. La bodega se organiza con anticipación y el proceso es más rápido." },
{ num: "05", icon: "📦", title: "Recibimos tu paquete en NJ", desc: "El equipo de Mora Global Import LLC recibe, pesa, mide y fotografía tu paquete. Recibes notificación automática por WhatsApp y correo." },
{ num: "06", icon: "💰", title: "Calcula y paga el envío", desc: "El sistema calcula automáticamente: flete (peso × $3.50/lb), seguro 2%, impuestos SENAE (FODINFA 0.5%, IVA 15%, arancel según categoría)." },
{ num: "07", icon: "🛃", title: "Declaración aduanera automática", desc: "El agente aduanero genera la Declaración Simplificada (DSI) ante la SENAE. Si el valor supera $400, se inicia el proceso formal (DAI)." },
{ num: "08", icon: "✈️", title: "Envío a Ecuador", desc: "Tu paquete viaja vía courier internacional (FedEx / DHL / UPS) a Ecuador. Recibes el tracking en tiempo real." },
{ num: "09", icon: "🎉", title: "Recibe en Ecuador", desc: "Coordina la entrega en tu ciudad. Estado final: ENTREGADO." },
];
return (
<div style={{ minHeight: "100vh", background: "var(--gray-50, #F9FAFB)" }}>
{/* Hero */}
<section style={{ background: "var(--dark, #0D1117)", color: "white", padding: "5rem 2rem 4rem" }}>
<div style={{ maxWidth: "800px", margin: "0 auto", textAlign: "center" }}>
<p style={{ fontSize: ".85rem", color: "var(--orange, #FF6B00)", fontWeight: 700, letterSpacing: ".1em", textTransform: "uppercase", marginBottom: "1rem" }}>Paso a paso</p>
<h1 style={{ fontSize: "clamp(2rem, 5vw, 3rem)", fontWeight: 800, marginBottom: "1.5rem", lineHeight: 1.2 }}>
Cómo usar tu casillero
</h1>
<p style={{ fontSize: "1.1rem", color: "rgba(255,255,255,.65)", maxWidth: "600px", margin: "0 auto 2rem" }}>
Recibe tus compras de Amazon, eBay y Walmart directamente en Ecuador. 9 pasos simples.
</p>
<div style={{ fontFamily: "monospace", fontSize: ".95rem", background: "rgba(0,87,255,.15)", border: "1px solid rgba(0,87,255,.3)", borderRadius: "10px", padding: "1.25rem 1.75rem", display: "inline-block", color: "#A5B4FC", lineHeight: 1.8 }}>
<span style={{ color: "rgba(255,255,255,.4)" }}>Tu dirección NJ:</span><br/>
<strong style={{ color: "white" }}>150 N Day St, Suite EC-XXXXX</strong><br/>
City of Orange, NJ 07050, EE.UU.
</div>
</div>
</section>
{/* Steps */}
<section style={{ maxWidth: "800px", margin: "0 auto", padding: "4rem 2rem" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "0" }}>
{STEPS.map((s, i) => (
<div key={s.num} style={{ display: "flex", gap: "1.5rem", paddingBottom: "2rem", position: "relative" }}>
{i < STEPS.length - 1 && (
<div style={{ position: "absolute", left: "20px", top: "48px", bottom: "0", width: "2px", background: "var(--gray-200, #E5E7EB)" }} />
)}
<div style={{ width: "42px", height: "42px", borderRadius: "50%", background: "var(--blue, #0057FF)", color: "white", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: ".85rem", flexShrink: 0, zIndex: 1 }}>
{s.num}
</div>
<div style={{ flex: 1, paddingTop: ".5rem" }}>
<h3 style={{ fontWeight: 700, fontSize: "1rem", marginBottom: ".5rem" }}>
{s.icon} {s.title}
</h3>
<p style={{ fontSize: ".9rem", color: "var(--gray-600, #4B5563)", lineHeight: 1.7 }}>{s.desc}</p>
</div>
</div>
))}
</div>
</section>
{/* CTA */}
<section style={{ background: "var(--blue, #0057FF)", color: "white", padding: "4rem 2rem", textAlign: "center" }}>
<h2 style={{ fontSize: "1.75rem", fontWeight: 800, marginBottom: "1rem" }}>¿Listo para comenzar?</h2>
<p style={{ color: "rgba(255,255,255,.75)", marginBottom: "2rem" }}>Crea tu cuenta gratis y recibe tu Suite en segundos.</p>
<Link href="/registro" style={{ background: "white", color: "var(--blue)", padding: "14px 40px", borderRadius: "8px", fontWeight: 700, textDecoration: "none", display: "inline-block" }}>
Crear cuenta gratis
</Link>
</section>
</div>
);
}
+153 -57
View File
@@ -1,34 +1,80 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { api } from "@/lib/api";
type Tab = "url" | "manual";
type Status = "PENDIENTE" | "VINCULADA" | "CANCELADA";
const STATUS_BADGE: Record<Status, string> = {
PENDIENTE: "badge-yellow",
VINCULADA: "badge-green",
CANCELADA: "badge-red",
};
export default function PreAlertaPage() {
const [alerts, setAlerts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>("manual");
const [submitting, setSubmitting] = useState(false);
const [scanning, setScanning] = useState(false);
const [success, setSuccess] = useState("");
const [error, setError] = useState("");
const [form, setForm] = useState({ store: "", orderNumber: "", description: "", estimatedValueUsd: "", estimatedWeightLb: "", trackingNumber: "" });
const [urlInput, setUrlInput] = useState("");
const [invoice, setInvoice] = useState<File | null>(null);
const invoiceRef = useRef<HTMLInputElement>(null);
const load = () => { api.preAlerts.list().then(setAlerts).catch(() => {}).finally(() => setLoading(false)); };
const [form, setForm] = useState({
store: "",
vendorTracking: "",
description: "",
declaredValue: "",
estimatedArrival: "",
});
const load = () => {
api.preAlerts.list().then(setAlerts).catch(() => {}).finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
// Opción A — escanear URL
const handleScanUrl = async () => {
if (!urlInput.trim()) return;
setScanning(true); setError("");
try {
const product = await api.products.scan(urlInput.trim());
setForm({
store: product.store ?? "",
vendorTracking: "",
description: product.name ?? "",
declaredValue: String(product.price ?? ""),
estimatedArrival: "",
});
setTab("manual");
setSuccess("✅ Datos extraídos del producto. Revisa y completa el formulario.");
} catch (err: any) {
setError("No se pudo extraer el producto. Intenta con el formulario manual.");
} finally { setScanning(false); }
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true); setError(""); setSuccess("");
try {
await api.preAlerts.create({
...form,
estimatedValueUsd: form.estimatedValueUsd ? parseFloat(form.estimatedValueUsd) : undefined,
estimatedWeightLb: form.estimatedWeightLb ? parseFloat(form.estimatedWeightLb) : undefined,
store: form.store,
vendorTracking: form.vendorTracking || undefined,
description: form.description,
declaredValue: parseFloat(form.declaredValue),
estimatedArrival: form.estimatedArrival || undefined,
});
setSuccess("Pre-alerta registrada exitosamente.");
setForm({ store: "", orderNumber: "", description: "", estimatedValueUsd: "", estimatedWeightLb: "", trackingNumber: "" });
setSuccess("Pre-alerta registrada exitosamente.");
setForm({ store: "", vendorTracking: "", description: "", declaredValue: "", estimatedArrival: "" });
setUrlInput(""); setInvoice(null);
load();
} catch (err: any) { setError(err.message ?? "Error"); }
} catch (err: any) { setError(err.message ?? "Error al registrar"); }
finally { setSubmitting(false); }
};
@@ -38,83 +84,133 @@ export default function PreAlertaPage() {
load();
};
const STATUS_BADGE: Record<string, string> = {
PENDIENTE: "badge-yellow", RECIBIDO: "badge-blue", VINCULADO: "badge-green", RECHAZADO: "badge-red",
};
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Pre-Alertas</h1>
<p className="dash-page-subtitle">Avísanos qué paquetes esperas para procesarlos más rápido.</p>
<p className="dash-page-subtitle">Avísanos qué paquetes esperas. Con pre-alerta, la bodega en NJ te organiza todo más rápido.</p>
</div>
<div className="grid-2" style={{ gap: "1.5rem", alignItems: "start" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem", alignItems: "start" }}>
{/* Formulario */}
<div className="card">
<div className="card-header"><span className="font-semibold">Nueva Pre-Alerta</span></div>
<div className="card-header">
<span className="font-semibold">Nueva Pre-Alerta</span>
</div>
<div className="card-body">
{success && <div className="alert alert-success mb-4">{success}</div>}
{error && <div className="alert alert-error mb-4">{error}</div>}
{error && <div className="alert alert-error mb-4">{error}</div>}
{/* Tabs */}
<div style={{ display: "flex", gap: ".5rem", marginBottom: "1.25rem", borderBottom: "2px solid var(--gray-100)", paddingBottom: ".75rem" }}>
<button onClick={() => setTab("url")}
style={{ padding: "6px 16px", borderRadius: "6px", fontSize: ".82rem", fontWeight: 600, border: "none", cursor: "pointer",
background: tab === "url" ? "var(--blue)" : "transparent",
color: tab === "url" ? "white" : "var(--gray-500)" }}>
🔗 Por enlace (URL)
</button>
<button onClick={() => setTab("manual")}
style={{ padding: "6px 16px", borderRadius: "6px", fontSize: ".82rem", fontWeight: 600, border: "none", cursor: "pointer",
background: tab === "manual" ? "var(--blue)" : "transparent",
color: tab === "manual" ? "white" : "var(--gray-500)" }}>
Manual
</button>
</div>
{/* Tab: URL scan */}
{tab === "url" && (
<div style={{ marginBottom: "1rem" }}>
<p style={{ fontSize: ".85rem", color: "var(--gray-600)", marginBottom: ".75rem" }}>
Pega la URL del producto de Amazon, eBay o Walmart y extraemos los datos automáticamente.
</p>
<div style={{ display: "flex", gap: ".5rem" }}>
<input className="form-input" style={{ flex: 1 }} type="url" placeholder="https://amazon.com/dp/..."
value={urlInput} onChange={e => setUrlInput(e.target.value)} />
<button className="btn btn-primary" disabled={scanning || !urlInput.trim()} onClick={handleScanUrl}>
{scanning ? "Escaneando…" : "Escanear"}
</button>
</div>
</div>
)}
{/* Tab: Manual form */}
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="form-group">
<label className="label">Tienda *</label>
<input className="input" placeholder="Amazon, eBay, Shein…" value={form.store} onChange={set("store")} required />
<label className="form-label">Tienda *</label>
<input className="form-input" required value={form.store} onChange={set("store")}
placeholder="Amazon, eBay, Walmart…" />
</div>
<div className="form-group">
<label className="label">Número de orden *</label>
<input className="input" placeholder="123-4567890-1234567" value={form.orderNumber} onChange={set("orderNumber")} required />
<label className="form-label">Descripción del producto *</label>
<textarea className="form-input" required rows={2} value={form.description} onChange={set("description")}
placeholder="Ej: Auriculares Sony WH-1000XM5 negros" />
</div>
<div className="form-group">
<label className="label">Descripción</label>
<textarea className="textarea" placeholder="Descripción del producto" value={form.description} onChange={set("description")} style={{ minHeight: 70 }} />
</div>
<div className="grid-2" style={{ gap: "1rem" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".75rem" }}>
<div className="form-group">
<label className="label">Valor est. (USD)</label>
<input type="number" className="input" placeholder="50.00" min={0} step="0.01" value={form.estimatedValueUsd} onChange={set("estimatedValueUsd")} />
<label className="form-label">Valor declarado (USD) *</label>
<input className="form-input" required type="number" step="0.01" min="0" value={form.declaredValue}
onChange={set("declaredValue")} placeholder="0.00" />
</div>
<div className="form-group">
<label className="label">Peso est. (lb)</label>
<input type="number" className="input" placeholder="2.5" min={0} step="0.1" value={form.estimatedWeightLb} onChange={set("estimatedWeightLb")} />
<label className="form-label">Tracking del vendedor</label>
<input className="form-input" value={form.vendorTracking} onChange={set("vendorTracking")}
placeholder="1Z999AA10123456784" />
</div>
</div>
<div className="form-group">
<label className="label">Tracking del proveedor</label>
<input className="input" placeholder="1Z999AA10123456784" value={form.trackingNumber} onChange={set("trackingNumber")} />
<label className="form-label">Fecha estimada de llegada a NJ</label>
<input className="form-input" type="date" value={form.estimatedArrival} onChange={set("estimatedArrival")} />
</div>
{/* Invoice file — stored locally, shown to user for UX */}
<div className="form-group">
<label className="form-label">Factura / Comprobante (PDF o imagen)</label>
<input ref={invoiceRef} type="file" accept=".pdf,image/*"
onChange={e => setInvoice(e.target.files?.[0] ?? null)}
style={{ fontSize: ".85rem" }} />
{invoice && (
<p style={{ fontSize: ".78rem", color: "var(--green)", marginTop: ".25rem" }}>📎 {invoice.name}</p>
)}
<p style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".25rem" }}>
Próximamente: la factura se enviará automáticamente a la bodega.
</p>
</div>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Enviando…" : "Registrar Pre-Alerta"}
{submitting ? "Registrando…" : "Registrar pre-alerta"}
</button>
</form>
</div>
</div>
{/* Lista */}
{/* Lista de pre-alertas */}
<div className="card">
<div className="card-header"><span className="font-semibold">Mis Pre-Alertas</span></div>
<div style={{ padding: loading ? "2rem" : 0, textAlign: "center" }}>
{loading ? <div className="spinner mx-auto" /> :
alerts.length === 0 ? (
<p style={{ padding: "2rem", color: "var(--gray-500)" }}>No tienes pre-alertas.</p>
) : (
alerts.map(a => (
<div key={a.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<div style={{ fontWeight: 600 }}>{a.store}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>Orden: {a.orderNumber}</div>
{a.description && <div style={{ fontSize: ".8rem", color: "var(--gray-400)" }}>{a.description}</div>}
</div>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: ".4rem" }}>
<span className={`badge ${STATUS_BADGE[a.status] ?? "badge-gray"}`}>{a.status}</span>
{a.status === "PENDIENTE" && (
<button className="btn btn-ghost btn-sm text-red" style={{ fontSize: ".75rem", color: "var(--red)" }} onClick={() => handleDelete(a.id)}>Eliminar</button>
)}
</div>
</div>
))
)}
</div>
<div className="card-header"><span className="font-semibold">Mis pre-alertas</span></div>
{loading ? (
<div className="flex justify-center py-8"><div className="spinner" /></div>
) : alerts.length === 0 ? (
<div style={{ padding: "2rem", textAlign: "center", color: "var(--gray-500)", fontSize: ".9rem" }}>
No tienes pre-alertas registradas.
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: ".75rem", padding: "1rem" }}>
{alerts.map(a => (
<div key={a.id} style={{ background: "var(--gray-50)", borderRadius: "8px", padding: "12px 14px", border: "1px solid var(--gray-200)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: ".5rem" }}>
<span style={{ fontWeight: 700, fontSize: ".9rem" }}>{a.store}</span>
<span className={`badge ${STATUS_BADGE[a.status as Status] ?? "badge-gray"}`}>{a.status}</span>
</div>
<p style={{ fontSize: ".82rem", color: "var(--gray-700)", marginBottom: ".4rem" }}>{a.description}</p>
<div style={{ display: "flex", gap: "1rem", fontSize: ".78rem", color: "var(--gray-500)" }}>
{a.declaredValue && <span>💰 ${a.declaredValue}</span>}
{a.vendorTracking && <span>📦 {a.vendorTracking}</span>}
</div>
{a.status === "PENDIENTE" && (
<button className="btn btn-ghost btn-sm" style={{ marginTop: ".5rem", color: "var(--red)", fontSize: ".78rem" }}
onClick={() => handleDelete(a.id)}>Eliminar</button>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
+21
View File
@@ -94,6 +94,17 @@ export const api = {
get: (id: string) => request<any>(`/packages/${id}`),
create: (body: any) => request<any>("/packages", { method: "POST", body: JSON.stringify(body) }),
updateStatus: (id: string, body: any) => request<any>(`/packages/${id}/status`, { method: "PATCH", body: JSON.stringify(body) }),
verify: (id: string, body: any) => request<any>(`/packages/${id}/verify`, { method: "PATCH", body: JSON.stringify(body) }),
pendingDeclaration: () => request<any[]>("/packages/pending-declaration"),
uploadPhotos: (id: string, formData: FormData) => {
const token = getToken();
return fetch(`${API_BASE}/packages/${id}/photos`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
}).then(r => { if (!r.ok) throw new Error("Upload failed"); return r.json(); });
},
senaeDeclare: (id: string, body: any) => request<any>(`/packages/${id}/senae/declare`, { method: "POST", body: JSON.stringify(body) }),
},
preAlerts: {
list: () => request<any[]>("/pre-alerts"),
@@ -118,4 +129,14 @@ export const api = {
list: () => request<any[]>("/b2b"),
updateStatus: (id: string, body: any) => request<any>(`/b2b/${id}/status`, { method: "PATCH", body: JSON.stringify(body) }),
},
tariffs: {
get: () => request<any>("/tariffs"),
update: (body: any) => request<any>("/tariffs", { method: "PUT", body: JSON.stringify(body) }),
},
auditLogs: {
list: (params?: Record<string,string>) => request<any>("/audit-logs" + (params ? "?" + new URLSearchParams(params) : "")),
},
products: {
scan: (url: string) => request<any>("/products/scan", { method: "POST", body: JSON.stringify({ url }) }),
},
};
+10
View File
@@ -41,6 +41,9 @@ importers:
'@nestjs/throttler':
specifier: ^6.5.0
version: 6.5.0(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)
'@types/multer':
specifier: ^2.1.0
version: 2.1.0
bcrypt:
specifier: ^6.0.0
version: 6.0.0
@@ -1211,6 +1214,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/multer@2.1.0':
resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==}
'@types/node@22.19.19':
resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==}
@@ -4295,6 +4301,10 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/multer@2.1.0':
dependencies:
'@types/express': 5.0.6
'@types/node@22.19.19':
dependencies:
undici-types: 6.21.0