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:
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { StorageService } from "./storage.service";
|
||||
|
||||
@Module({ providers: [StorageService], exports: [StorageService] })
|
||||
export class StorageModule {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 — 0–15% (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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 >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 24–48 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) }),
|
||||
},
|
||||
};
|
||||
|
||||
Generated
+10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user