feat: C-8/C-1/C-2/C-3/C-4/C-6/M-1/M-5/M-6 — WebSocket gateway, SENAE real, SP-API, Twilio SMS, WhatsApp Business, soporte portal, HMAC audit, reportes CSV

This commit is contained in:
Lizandro Guarnizo
2026-06-01 21:28:42 -05:00
parent a5842278fb
commit 84c1fdec54
27 changed files with 1743 additions and 122 deletions
+3
View File
@@ -24,7 +24,9 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.0",
"@nestjs/platform-socket.io": "^11.1.24",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.24",
"@types/multer": "^2.1.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
@@ -35,6 +37,7 @@
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"socket.io": "^4.8.3",
"stripe": "^22.2.0"
},
"devDependencies": {
+67 -1
View File
@@ -1,4 +1,5 @@
import { Injectable, Logger } from "@nestjs/common";
import { createHmac } from "crypto";
import { PrismaService } from "../prisma/prisma.service";
export interface AuditLogEntry {
@@ -12,21 +13,86 @@ export interface AuditLogEntry {
userAgent?: string;
}
/**
* AuditLogService — M-5
* Adds HMAC-SHA256 integrity hash to every log entry so tampering can be detected.
* Hash is stored in metadata.integrity.
* Secret: env var AUDIT_HMAC_SECRET (falls back to a default for dev).
*/
@Injectable()
export class AuditLogService {
private readonly logger = new Logger(AuditLogService.name);
private readonly hmacSecret = process.env.AUDIT_HMAC_SECRET ?? "moraworld-audit-hmac-secret-change-in-prod";
constructor(private prisma: PrismaService) {}
/**
* Compute HMAC-SHA256 of the canonical log payload.
* Canonical form: JSON.stringify of { tenantId, userId, action, resource, resourceId, createdAt }
*/
private computeIntegrity(entry: AuditLogEntry, createdAt: Date): string {
const canonical = JSON.stringify({
tenantId: entry.tenantId ?? null,
userId: entry.userId ?? null,
action: entry.action,
resource: entry.resource ?? null,
resourceId: entry.resourceId ?? null,
createdAt: createdAt.toISOString(),
});
return createHmac("sha256", this.hmacSecret).update(canonical).digest("hex");
}
async log(entry: AuditLogEntry): Promise<void> {
try {
await this.prisma.client.auditLog.create({ data: entry });
const createdAt = new Date();
const integrity = this.computeIntegrity(entry, createdAt);
await this.prisma.client.auditLog.create({
data: {
...entry,
metadata: {
...(entry.metadata ?? {}),
integrity, // HMAC-SHA256 of canonical payload
},
createdAt,
},
});
} catch (e: unknown) {
// Never let audit log failure break main flow
this.logger.error("AuditLog write failed", (e as Error).message);
}
}
/**
* Verify the integrity hash of a stored audit log entry.
* Returns true if the hash matches, false if the record was tampered with.
*/
verify(entry: {
tenantId?: string | null;
userId?: string | null;
action: string;
resource?: string | null;
resourceId?: string | null;
createdAt: Date;
metadata?: any;
}): boolean {
const stored = entry.metadata?.integrity;
if (!stored) return false;
const canonical = JSON.stringify({
tenantId: entry.tenantId ?? null,
userId: entry.userId ?? null,
action: entry.action,
resource: entry.resource ?? null,
resourceId: entry.resourceId ?? null,
createdAt: entry.createdAt instanceof Date
? entry.createdAt.toISOString()
: new Date(entry.createdAt).toISOString(),
});
const expected = createHmac("sha256", this.hmacSecret).update(canonical).digest("hex");
return expected === stored;
}
async findAll(filters: {
tenantId?: string;
userId?: string;
@@ -15,6 +15,13 @@ export const INTEGRATION_CATALOG = [
{ key: "email_from_name", label: "Email remitente (nombre visible)", group: "notifications", required: false },
{ key: "whatsapp_nj_number", label: "WhatsApp NJ (operaciones)", group: "notifications", required: false },
{ key: "whatsapp_ec_number", label: "WhatsApp Ecuador (aduanas)", group: "notifications", required: false },
// ── WhatsApp Business Cloud API (Meta) — C-4 ─────────────
{ key: "whatsapp_api_token", label: "WhatsApp Business — API Token (Meta)", group: "notifications", required: false },
{ key: "whatsapp_phone_number_id", label: "WhatsApp Business — Phone Number ID", group: "notifications", required: false },
// ── Twilio SMS — C-3 ─────────────────────────────────────
{ key: "sms_account_sid", label: "Twilio — Account SID", group: "notifications", required: false },
{ key: "sms_auth_token", label: "Twilio — Auth Token", group: "notifications", required: false },
{ key: "sms_from_number", label: "Twilio — From Number (E.164)", group: "notifications", required: false },
// ── Aduana / SENAE ────────────────────────────────────────
{ key: "senae_endpoint", label: "SENAE — URL WebService", group: "customs", required: true },
{ key: "senae_api_key", label: "SENAE — API Key / Token", group: "customs", required: true },
+4
View File
@@ -1,6 +1,7 @@
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { IoAdapter } from "@nestjs/platform-socket.io";
import { join } from "path";
import { AppModule } from "./app.module";
@@ -16,6 +17,9 @@ async function bootstrap() {
credentials: true,
});
// Socket.io adapter for WebSocket gateway (C-6)
app.useWebSocketAdapter(new IoAdapter(app));
app.setGlobalPrefix("api");
// Serve uploaded files (photos, invoices) as static assets
@@ -0,0 +1,101 @@
import {
WebSocketGateway,
WebSocketServer,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
MessageBody,
ConnectedSocket,
} from "@nestjs/websockets";
import { Server, Socket } from "socket.io";
import { Logger } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
/**
* NotificationsGateway — C-6
* Real-time push of package status changes to connected portal clients.
*
* Connection flow:
* 1. Client connects with `auth: { token: "<jwt>" }` in socket options.
* 2. Gateway verifies JWT → places socket in room `user:<userId>`.
* 3. On package status change, NotificationsService calls `emitStatusChange()`.
* 4. All sockets in that user room receive `package:status` event.
*
* Client (Next.js portal):
* const socket = io("http://localhost:3001", { auth: { token: localStorage.getItem("mw_access") } });
* socket.on("package:status", (data) => { ... });
*/
@WebSocketGateway({
cors: {
origin: (process.env.CORS_ORIGINS ?? "http://localhost:3000").split(",").map(o => o.trim()),
credentials: true,
},
namespace: "/ws",
transports: ["websocket", "polling"],
})
export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server!: Server;
private readonly logger = new Logger(NotificationsGateway.name);
constructor(private jwtService: JwtService) {}
async handleConnection(client: Socket) {
try {
const token =
(client.handshake.auth as any)?.token ??
client.handshake.headers?.authorization?.replace("Bearer ", "");
if (!token) {
this.logger.warn(`[WS] Client ${client.id} rejected — no token`);
client.disconnect(true);
return;
}
const secret = process.env.JWT_SECRET ?? "changeme";
const payload = this.jwtService.verify(token, { secret });
const userId: string = payload.sub;
// Join personal room so we can target by userId
await client.join(`user:${userId}`);
client.data.userId = userId;
client.data.tenantId = payload.tenantId;
this.logger.log(`[WS] Connected: ${client.id} → user:${userId}`);
} catch {
this.logger.warn(`[WS] Client ${client.id} rejected — invalid token`);
client.disconnect(true);
}
}
handleDisconnect(client: Socket) {
this.logger.log(`[WS] Disconnected: ${client.id}`);
}
/** Emitted by NotificationsService on every package status change */
emitStatusChange(userId: string, pkg: {
id: string;
trackingId: string;
status: string;
description?: string;
}) {
this.server.to(`user:${userId}`).emit("package:status", {
packageId: pkg.id,
trackingId: pkg.trackingId,
status: pkg.status,
description: pkg.description ?? null,
at: new Date().toISOString(),
});
this.logger.log(`[WS] Emitted package:status to user:${userId}${pkg.trackingId}${pkg.status}`);
}
/** Broadcast to all sockets in a tenant room */
emitToTenant(tenantId: string, event: string, data: any) {
this.server.to(`tenant:${tenantId}`).emit(event, data);
}
/** Ping/pong — optional keep-alive */
@SubscribeMessage("ping")
handlePing(@ConnectedSocket() client: Socket, @MessageBody() _data: any) {
client.emit("pong", { at: new Date().toISOString() });
}
}
@@ -1,13 +1,22 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { NotificationsService } from "./notifications.service";
import { NotificationsGateway } from "./notifications.gateway";
import { NotificationsController, NotificationsUserController } from "./notifications.controller";
import { PrismaModule } from "../prisma/prisma.module";
import { IntegrationsModule } from "../integrations/integrations.module";
@Module({
imports: [PrismaModule, IntegrationsModule],
imports: [
PrismaModule,
IntegrationsModule,
JwtModule.register({
secret: process.env.JWT_SECRET ?? "changeme",
signOptions: { expiresIn: "15m" },
}),
],
controllers: [NotificationsController, NotificationsUserController],
providers: [NotificationsService],
exports: [NotificationsService],
providers: [NotificationsService, NotificationsGateway],
exports: [NotificationsService, NotificationsGateway],
})
export class NotificationsModule {}
@@ -139,9 +139,10 @@ describe("NotificationsService", () => {
mockPrisma.client.notification.update.mockResolvedValue({});
});
it("crea notificaciones para los 3 canales por defecto", async () => {
it("crea notificaciones para los 3 canales activos por defecto (EMAIL, WHATSAPP, SMS)", async () => {
await service.notifyStatusChange(mockPackage, mockUser);
// EMAIL (FALLIDO sin API key), WHATSAPP (wa.me link), PUSH (FALLIDO)
// EMAIL (FALLIDO sin API key), WHATSAPP (wa.me link), SMS (FALLIDO sin Twilio)
// PUSH se omite porque no hay plantilla activa y el fallback lo excluye
expect(mockPrisma.client.notification.create).toHaveBeenCalledTimes(3);
});
@@ -1,6 +1,7 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { Injectable, Logger, NotFoundException, Optional } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { IntegrationsService } from "../integrations/integrations.service";
import type { NotificationsGateway } from "./notifications.gateway";
// ─── Plantillas por defecto (fallback cuando no hay en DB) ────
const DEFAULT_SUBJECTS: Record<string, string> = {
@@ -36,11 +37,19 @@ function interpolate(tpl: string, vars: Record<string, string>): string {
return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
}
/** Normaliza número de teléfono para wa.me (solo dígitos, con código de país) */
/** Normaliza número de teléfono para wa.me / WhatsApp (solo dígitos, con código de país) */
function normalizePhone(phone: string): string {
return phone.replace(/\D/g, "");
}
/** Añade código de país Ecuador si no tiene prefijo internacional */
function toE164Ecuador(phone: string): string {
const digits = phone.replace(/\D/g, "");
if (digits.startsWith("593")) return `+${digits}`;
if (digits.startsWith("0")) return `+593${digits.slice(1)}`;
return `+${digits}`;
}
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
@@ -48,6 +57,7 @@ export class NotificationsService {
constructor(
private prisma: PrismaService,
private integrations: IntegrationsService,
@Optional() private gateway: NotificationsGateway | null,
) {}
// ─── Gestión de plantillas ────────────────────────────────
@@ -99,7 +109,7 @@ export class NotificationsService {
/** Called whenever a package status changes. */
async notifyStatusChange(pkg: any, user: any): Promise<void> {
// Load full user data to get phone and name (needed for wa.me)
// Load full user data to get phone and name
let fullUser = user;
if (!user?.firstName || !user?.phone) {
try {
@@ -115,15 +125,14 @@ export class NotificationsService {
suiteCode: fullUser?.suite?.code ?? "",
};
const channels: Array<"EMAIL" | "WHATSAPP" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
const channels: Array<"EMAIL" | "WHATSAPP" | "SMS" | "PUSH"> = ["EMAIL", "WHATSAPP", "SMS", "PUSH"];
for (const channel of channels) {
try {
// 1. Buscar plantilla en DB
const tpl = await this.prisma.client.notificationTemplate.findUnique({
where: { tenantId_event_channel: { tenantId: pkg.tenantId, event: pkg.status, channel } },
where: { tenantId_event_channel: { tenantId: pkg.tenantId, event: pkg.status, channel: channel as any } },
});
const active = tpl ? tpl.isActive : true;
const active = tpl ? tpl.isActive : (channel !== "PUSH"); // PUSH requires explicit template
if (!active) continue;
const subject = interpolate(
@@ -144,19 +153,19 @@ export class NotificationsService {
const result = await this.sendZeptomail(pkg.tenantId, subject, bodyText, fullUser);
notifStatus = result.ok ? "ENVIADO" : "FALLIDO";
errorMsg = result.error;
} else if (channel === "WHATSAPP") {
// wa.me link con mensaje pre-cargado (sin Business API)
const phone = fullUser?.phone ? normalizePhone(fullUser.phone) : null;
if (phone) {
finalBody = `https://wa.me/${phone}?text=${encodeURIComponent(bodyText)}`;
notifStatus = "ENVIADO";
} else {
// No phone — skip WhatsApp
notifStatus = "FALLIDO";
errorMsg = "Sin número de teléfono registrado";
}
const result = await this.sendWhatsApp(pkg.tenantId, bodyText, fullUser);
notifStatus = result.ok ? "ENVIADO" : "FALLIDO";
finalBody = result.finalBody ?? bodyText;
errorMsg = result.error;
} else if (channel === "SMS") {
const result = await this.sendSms(pkg.tenantId, bodyText, fullUser);
notifStatus = result.ok ? "ENVIADO" : "FALLIDO";
errorMsg = result.error;
} else if (channel === "PUSH") {
// PUSH no implementado — marcar FALLIDO silenciosamente
notifStatus = "FALLIDO";
errorMsg = "PUSH no configurado";
}
@@ -165,7 +174,7 @@ export class NotificationsService {
data: {
packageId: pkg.id,
userId: pkg.userId,
channel,
channel: channel as any,
status: "PENDIENTE",
subject,
body: finalBody,
@@ -180,6 +189,18 @@ export class NotificationsService {
error: errorMsg ?? null,
},
});
// ── Real-time WebSocket push (C-6) ─────────────────────────────────
if (channel === "PUSH" && this.gateway) {
try {
this.gateway.emitStatusChange(pkg.userId, {
id: pkg.id,
trackingId: pkg.trackingId,
status: pkg.status,
description: pkg.description,
});
} catch { /* non-blocking */ }
}
} catch (e: unknown) {
this.logger.error(`Notification ${channel} failed: ${(e as Error).message}`);
}
@@ -256,6 +277,125 @@ export class NotificationsService {
}
}
/**
* WhatsApp Business Cloud API (Meta) — C-4.
* Falls back to wa.me link if Business API credentials are not configured.
* Docs: https://developers.facebook.com/docs/whatsapp/cloud-api/messages
*/
private async sendWhatsApp(
tenantId: string,
bodyText: string,
toUser: any,
): Promise<{ ok: boolean; finalBody?: string; error?: string }> {
const phone = toUser?.phone ? toUser.phone : null;
if (!phone) return { ok: false, error: "Sin número de teléfono registrado" };
const apiToken = await this.integrations.getValue(tenantId, "whatsapp_api_token");
const phoneNumId = await this.integrations.getValue(tenantId, "whatsapp_phone_number_id");
// ── Meta WhatsApp Cloud API ────────────────────────────────────────────
if (apiToken && phoneNumId) {
try {
const toE164 = toE164Ecuador(phone);
const res = await fetch(
`https://graph.facebook.com/v19.0/${phoneNumId}/messages`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiToken}`,
},
body: JSON.stringify({
messaging_product: "whatsapp",
to: toE164.replace("+", ""),
type: "text",
text: { body: bodyText },
}),
signal: AbortSignal.timeout(10_000),
}
);
if (!res.ok) {
const errText = await res.text().catch(() => `HTTP ${res.status}`);
this.logger.error(`[WhatsApp Business] Send failed (${res.status}): ${errText}`);
return { ok: false, error: `WhatsApp API error ${res.status}` };
}
this.logger.log(`[WhatsApp Business] Message sent to ${toE164}`);
return { ok: true, finalBody: bodyText };
} catch (err: any) {
this.logger.error(`[WhatsApp Business] Exception: ${err.message}`);
// Fall through to wa.me
}
}
// ── wa.me fallback (no Business API credentials) ─────────────────────
const normalized = normalizePhone(phone);
const waLink = `https://wa.me/${normalized}?text=${encodeURIComponent(bodyText)}`;
this.logger.warn(`[WhatsApp] Using wa.me fallback for ${normalized} — configure whatsapp_api_token + whatsapp_phone_number_id en Integraciones`);
return { ok: true, finalBody: waLink };
}
/**
* Twilio SMS — C-3.
* Skips gracefully if credentials not configured.
* Docs: https://www.twilio.com/docs/messaging/api
*/
private async sendSms(
tenantId: string,
bodyText: string,
toUser: any,
): Promise<{ ok: boolean; error?: string }> {
const phone = toUser?.phone ? toUser.phone : null;
if (!phone) return { ok: false, error: "Sin número de teléfono registrado" };
const accountSid = await this.integrations.getValue(tenantId, "sms_account_sid");
const authToken = await this.integrations.getValue(tenantId, "sms_auth_token");
const fromNumber = await this.integrations.getValue(tenantId, "sms_from_number");
if (!accountSid || !authToken || !fromNumber) {
this.logger.warn(`[Twilio SMS] Credenciales no configuradas para tenant ${tenantId} — SMS omitido`);
return { ok: false, error: "Twilio no configurado" };
}
try {
const toE164 = toE164Ecuador(phone);
const basic = Buffer.from(`${accountSid}:${authToken}`).toString("base64");
const body = new URLSearchParams({
From: fromNumber,
To: toE164,
Body: bodyText.substring(0, 1600), // Twilio max length
});
const res = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": `Basic ${basic}`,
},
body,
signal: AbortSignal.timeout(10_000),
}
);
if (!res.ok) {
const errText = await res.text().catch(() => `HTTP ${res.status}`);
this.logger.error(`[Twilio] Send failed (${res.status}): ${errText}`);
return { ok: false, error: `Twilio error ${res.status}` };
}
const data = await res.json();
this.logger.log(`[Twilio] SMS sent to ${toE164} — SID: ${data.sid}`);
return { ok: true };
} catch (err: any) {
this.logger.error(`[Twilio] Exception: ${err.message}`);
return { ok: false, error: err.message };
}
}
async findByUser(userId: string, limit = 20) {
return this.prisma.client.notification.findMany({
where: { userId },
+11
View File
@@ -14,6 +14,17 @@ export class CreatePackageDto {
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() heightCm?: number;
}
/** Cliente registra su propia compra — el userId viene del JWT (doc §09 paso 5) */
export class RegisterPackageDto {
@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() @IsEnum(["REGIMEN_4X4","CATEGORIA_B","CATEGORIA_C","CATEGORIA_D"]) senaeCategory?: string;
}
export class UpdateStatusDto {
@IsEnum([
"REGISTRADO","EN_TRANSITO_BODEGA","RECIBIDO_BODEGA","EN_VERIFICACION",
+8 -1
View File
@@ -7,7 +7,7 @@ import { memoryStorage } from "multer";
import { extname } from "path";
import { PackagesService } from "./packages.service";
import { StorageService } from "../storage/storage.service";
import { CreatePackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
import { CreatePackageDto, RegisterPackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
@@ -45,6 +45,13 @@ export class PackagesController {
return this.svc.create(dto, user.id, user.tenantId);
}
/** Cliente registra su propia compra (doc §09 paso 5) */
@Post("register")
@Roles("CLIENTE")
selfRegister(@Body() dto: RegisterPackageDto, @CurrentUser() user: any): Promise<any> {
return this.svc.selfRegister(dto, user.id, user.tenantId);
}
@Patch(":id/status")
@Roles("OPERADOR_BODEGA", "AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN")
updateStatus(
+2 -1
View File
@@ -5,9 +5,10 @@ import { PrismaModule } from "../prisma/prisma.module";
import { ConfigModule } from "@nestjs/config";
import { NotificationsModule } from "../notifications/notifications.module";
import { StorageModule } from "../storage/storage.module";
import { SenaeModule } from "../senae/senae.module";
@Module({
imports: [PrismaModule, ConfigModule, NotificationsModule, StorageModule],
imports: [PrismaModule, ConfigModule, NotificationsModule, StorageModule, SenaeModule],
providers: [PackagesService],
controllers: [PackagesController],
exports: [PackagesService],
+61 -28
View File
@@ -1,14 +1,16 @@
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { NotificationsService } from "../notifications/notifications.service";
import { SenaeService } from "../senae/senae.service";
import { generateTrackingId } from "../common/utils/tracking-id.util";
import { CreatePackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
import { CreatePackageDto, RegisterPackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
@Injectable()
export class PackagesService {
constructor(
private prisma: PrismaService,
private notifications: NotificationsService,
private senae: SenaeService,
) {}
async findAll(user: any, filters?: { status?: string; search?: string }): Promise<any[]> {
@@ -87,6 +89,29 @@ export class PackagesService {
return pkg;
}
async updateStatus(id: string, dto: UpdateStatusDto, 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: { status: dto.status as any },
});
await this.prisma.client.packageStatusHistory.create({
data: {
packageId: id,
status: dto.status as any,
createdBy: operatorId,
note: dto.note,
},
});
this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {});
return updated;
}
/** Busca una pre-alerta PENDIENTE del mismo usuario que coincida por vendorTracking
* y la vincula automáticamente al paquete (status → VINCULADA, packageId set). */
private async tryLinkPreAlert(
@@ -115,28 +140,41 @@ export class PackagesService {
}
}
async updateStatus(id: string, dto: UpdateStatusDto, operatorId: string): Promise<any> {
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
/** Cliente registra su propia compra — doc §09 pasos 5-6 */
async selfRegister(dto: RegisterPackageDto, userId: string, tenantId: string): Promise<any> {
const trackingId = generateTrackingId();
const updated = await this.prisma.client.package.update({
where: { id },
data: { status: dto.status as any },
const pkg = await this.prisma.client.package.create({
data: {
trackingId,
tenantId,
userId,
description: dto.description,
store: dto.store,
vendorTracking: dto.vendorTracking,
productUrl: dto.productUrl,
declaredValue: dto.declaredValue ?? 0,
declaredWeight: dto.declaredWeightLb ?? null,
senaeCategory: dto.senaeCategory as any ?? null,
status: "REGISTRADO",
},
});
await this.prisma.client.packageStatusHistory.create({
data: {
packageId: id,
status: dto.status as any,
createdBy: operatorId,
note: dto.note,
packageId: pkg.id,
status: "REGISTRADO",
createdBy: userId,
note: "Compra registrada por el cliente",
},
});
// Notify user on status change
this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {});
await this.tryLinkPreAlert(pkg.id, userId, tenantId, dto.vendorTracking);
return updated;
// Notify the user of registration
this.notifications.notifyStatusChange(pkg, { id: userId }).catch(() => {});
return pkg;
}
/**
@@ -201,7 +239,7 @@ export class PackagesService {
}
/**
* SENAE declaration (doc §11): generate DSI stub, update status to DECLARACION_ADUANERA.
* SENAE declaration (doc §11): call SenaeService (real or 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 } });
@@ -210,10 +248,13 @@ export class PackagesService {
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}`;
// Call real SENAE service (falls back to stub if credentials not set — C-1)
const { authNumber, declarationId, message } = await this.senae.submitDSI(
pkg,
pkg.tenantId,
dto.category,
dto.agentNotes,
);
const updated = await this.prisma.client.package.update({
where: { id },
@@ -234,17 +275,9 @@ export class PackagesService {
},
});
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;
return { ...updated, declarationId, authNumber, message };
}
/**
+3 -9
View File
@@ -1,6 +1,7 @@
import { Controller, Post, Body, UseGuards } from "@nestjs/common";
import { ProductsService } from "./products.service";
import { JwtAuthGuard } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
@Controller("products")
@UseGuards(JwtAuthGuard)
@@ -8,14 +9,7 @@ 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);
scanUrl(@Body("url") url: string, @CurrentUser() user: any): Promise<any> {
return this.svc.scanUrl(url, user?.tenantId);
}
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { ProductsService } from "./products.service";
import { ProductsController } from "./products.controller";
import { IntegrationsModule } from "../integrations/integrations.module";
@Module({
imports: [IntegrationsModule],
providers: [ProductsService],
controllers: [ProductsController],
exports: [ProductsService],
+125 -9
View File
@@ -1,4 +1,5 @@
import { Injectable } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { IntegrationsService } from "../integrations/integrations.service";
interface ProductScanResult {
name: string;
@@ -7,22 +8,46 @@ interface ProductScanResult {
imageUrl: string;
store: string;
url: string;
asin?: string;
brand?: string;
isStub: boolean;
}
/**
* ProductsService — C-2
* Scans a product URL. If Amazon SP-API credentials are configured (amazon_client_id,
* amazon_client_secret, amazon_refresh_token), uses the Catalog Items API to get real data.
* Falls back to stub if not configured or on error.
*
* SP-API docs: https://developer-docs.amazon.com/sp-api/docs/catalog-items-api-v2022-04-01
*/
@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);
private readonly logger = new Logger(ProductsService.name);
// SP-API LWA token cache: { accessToken, expiresAt }
private lwaCache: { accessToken: string; expiresAt: number; tenantId: string } | null = null;
// Extract ASIN from Amazon URL if present
constructor(private integrations: IntegrationsService) {}
async scanUrl(url: string, tenantId?: string): Promise<ProductScanResult> {
const store = this.detectStore(url);
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
// ── Amazon SP-API call if configured ─────────────────────────────────────
if (asin && store === "Amazon" && tenantId) {
try {
const result = await this.callSpApi(asin, tenantId);
if (result) return { ...result, store, url, isStub: false };
} catch (err: any) {
this.logger.warn(`[SP-API] Failed for ASIN ${asin}: ${err.message}`);
}
}
// ── Stub fallback ─────────────────────────────────────────────────────────
if (asin) {
this.logger.warn(`[SP-API STUB] ASIN ${asin} — configure amazon_client_id, amazon_client_secret, amazon_refresh_token en Integraciones`);
}
return {
name: asin ? `Producto Amazon (ASIN: ${asin})` : `Producto de ${store}`,
price: 29.99,
@@ -30,6 +55,97 @@ export class ProductsService {
imageUrl: "https://placehold.co/200x200?text=Product",
store,
url,
asin: asin ?? undefined,
isStub: true,
};
}
/**
* Exchange LWA refresh token for a SP-API access token (cached 55 min).
*/
private async getLwaToken(clientId: string, clientSecret: string, refreshToken: string, tenantId: string): Promise<string> {
const now = Date.now();
if (this.lwaCache && this.lwaCache.tenantId === tenantId && this.lwaCache.expiresAt > now + 60_000) {
return this.lwaCache.accessToken;
}
const res = await fetch("https://api.amazon.com/auth/o2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
}),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const err = await res.text().catch(() => `HTTP ${res.status}`);
throw new Error(`LWA token error: ${err}`);
}
const data = await res.json();
this.lwaCache = {
tenantId,
accessToken: data.access_token,
expiresAt: now + (data.expires_in ?? 3600) * 1000,
};
return data.access_token;
}
/**
* Call SP-API Catalog Items v2022-04-01 for a single ASIN.
*/
private async callSpApi(asin: string, tenantId: string): Promise<Omit<ProductScanResult, "store" | "url" | "isStub"> | null> {
const clientId = await this.integrations.getValue(tenantId, "amazon_client_id");
const clientSecret = await this.integrations.getValue(tenantId, "amazon_client_secret");
const refreshToken = await this.integrations.getValue(tenantId, "amazon_refresh_token");
if (!clientId || !clientSecret || !refreshToken) return null;
const accessToken = await this.getLwaToken(clientId, clientSecret, refreshToken, tenantId);
// SP-API Catalog Items endpoint (NA marketplace)
const marketplaceId = "ATVPDKIKX0DER"; // US
const endpoint = `https://sellingpartnerapi-na.amazon.com/catalog/2022-04-01/items/${asin}?marketplaceIds=${marketplaceId}&includedData=summaries,images,dimensions`;
const res = await fetch(endpoint, {
headers: {
"x-amz-access-token": accessToken,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const err = await res.text().catch(() => `HTTP ${res.status}`);
throw new Error(`SP-API error: ${err}`);
}
const data = await res.json();
const summary = data?.summaries?.[0];
const image = data?.images?.[0]?.images?.[0];
const dims = data?.dimensions?.[0]?.package;
// Convert kg to lb if available
let weightLb = 1.5;
if (dims?.weight?.value && dims.weight.unit === "KILOGRAMS") {
weightLb = Number(dims.weight.value) * 2.20462;
} else if (dims?.weight?.value && dims.weight.unit === "POUNDS") {
weightLb = Number(dims.weight.value);
}
this.logger.log(`[SP-API] Fetched item: ASIN ${asin}${summary?.itemName ?? "unknown"}`);
return {
name: summary?.itemName ?? `Amazon ASIN ${asin}`,
price: 0, // Catalog Items API does not return price; use Pricing API separately
weightLb: Math.round(weightLb * 100) / 100,
imageUrl: image?.link ?? "https://placehold.co/200x200?text=Amazon",
asin,
brand: summary?.brand ?? undefined,
};
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { SenaeService } from "./senae.service";
import { IntegrationsModule } from "../integrations/integrations.module";
@Module({
imports: [IntegrationsModule],
providers: [SenaeService],
exports: [SenaeService],
})
export class SenaeModule {}
+93
View File
@@ -0,0 +1,93 @@
import { Injectable, Logger } from "@nestjs/common";
import { IntegrationsService } from "../integrations/integrations.service";
export interface SenaeDeclarationResult {
authNumber: string;
declarationId: string;
isStub: boolean;
message: string;
}
/**
* SenaeService — C-1
* Envía la Declaración Simplificada de Importación (DSI) a la SENAE.
* Si las credenciales de integración están configuradas, hace la llamada real.
* Si no, genera un número de stub para entorno de desarrollo.
*
* Docs: §11, §18 — SENAE WebService integration
*/
@Injectable()
export class SenaeService {
private readonly logger = new Logger(SenaeService.name);
constructor(private integrations: IntegrationsService) {}
async submitDSI(pkg: any, tenantId: string, category: string, agentNotes?: string): Promise<SenaeDeclarationResult> {
const endpoint = await this.integrations.getValue(tenantId, "senae_endpoint");
const apiKey = await this.integrations.getValue(tenantId, "senae_api_key");
const ruc = await this.integrations.getValue(tenantId, "senae_ruc");
const agentCode = await this.integrations.getValue(tenantId, "senae_agent_code");
const declarationId = `DSI-${pkg.trackingId}`;
// ── Real SENAE WebService call ────────────────────────────────────────────
if (endpoint && apiKey && ruc) {
try {
const payload = {
declaracion: {
tipo: "DSI",
rucDeclarante: ruc,
codigoAgente: agentCode ?? null,
trackingInterno: pkg.trackingId,
descripcion: pkg.description,
valorDeclarado: Number(pkg.declaredValue ?? 0),
pesoKg: Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0) * 0.453592,
categoria: category,
fechaEnvio: new Date().toISOString(),
notas: agentNotes ?? null,
},
};
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
"X-Agent-Code": agentCode ?? "",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
const errText = await res.text().catch(() => `HTTP ${res.status}`);
this.logger.error(`[SENAE] DSI submit failed (${res.status}): ${errText}`);
// Fall through to stub on error
} else {
const data = await res.json();
const authNumber = data?.autorizacion ?? data?.authNumber ?? data?.numeroAutorizacion;
if (authNumber) {
this.logger.log(`[SENAE] DSI aprobada — Auth: ${authNumber} — Pkg: ${pkg.trackingId}`);
return { authNumber, declarationId, isStub: false, message: `DSI aprobada por SENAE. Autorización: ${authNumber}` };
}
this.logger.warn(`[SENAE] Respuesta inesperada: ${JSON.stringify(data).slice(0, 200)}`);
}
} catch (err: any) {
this.logger.error(`[SENAE] Exception: ${err.message}`);
// Fall through to stub
}
} else {
this.logger.warn(`[SENAE] Credenciales no configuradas para tenant ${tenantId} — usando stub`);
}
// ── Stub fallback (dev / sin credenciales) ────────────────────────────────
const authNumber = `SENAE-DSI-${new Date().getFullYear()}-${Math.floor(100000 + Math.random() * 900000)}`;
this.logger.warn(`[SENAE STUB] Auth: ${authNumber} — configure senae_endpoint, senae_api_key, senae_ruc en Integraciones`);
return {
authNumber,
declarationId,
isStub: true,
message: `DSI generada (STUB — sin conexión real SENAE). Auth: ${authNumber}. Configure las claves SENAE en Admin → Integraciones.`,
};
}
}
+2 -1
View File
@@ -12,7 +12,8 @@
"js-cookie": "^3.0.8",
"next": "^15.3.2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
+129 -17
View File
@@ -1,8 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo } from "react";
import { api } from "@/lib/api";
// §08 — estados oficiales del ciclo de vida
const STATUS_COLORS: Record<string, string> = {
REGISTRADO: "#6B7280",
EN_TRANSITO_BODEGA: "#F59E0B",
@@ -17,24 +16,73 @@ const STATUS_COLORS: Record<string, string> = {
INCIDENCIA: "#EF4444",
};
/** Convert array of objects to CSV string */
function toCSV(rows: any[], columns: { key: string; label: string }[]): string {
const header = columns.map(c => `"${c.label}"`).join(",");
const body = rows.map(row =>
columns.map(c => {
const val = row[c.key] ?? "";
const str = String(val).replace(/"/g, '""');
return `"${str}"`;
}).join(",")
);
return [header, ...body].join("\r\n");
}
/** Trigger browser download of a CSV string */
function downloadCSV(csv: string, filename: string) {
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" }); // BOM for Excel
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// Default date range: last 30 days
function defaultFrom() {
const d = new Date();
d.setDate(d.getDate() - 30);
return d.toISOString().split("T")[0];
}
function defaultTo() {
return new Date().toISOString().split("T")[0];
}
export default function ReportesPage() {
const [users, setUsers] = useState<any[]>([]);
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [from, setFrom] = useState(defaultFrom());
const [to, setTo] = useState(defaultTo());
useEffect(() => {
const fetchData = () => {
setLoading(true);
Promise.all([api.users.list(), api.packages.list()])
.then(([u, p]) => { setUsers(u); setPackages(p); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
};
if (loading) return <div style={{ display:"flex", justifyContent:"center", padding:"4rem" }}><div className="spinner" /></div>;
useEffect(() => { fetchData(); }, []);
// Filter packages by date range
const filteredPackages = useMemo(() => {
const fromDate = from ? new Date(from + "T00:00:00") : null;
const toDate = to ? new Date(to + "T23:59:59") : null;
return packages.filter(p => {
const d = new Date(p.createdAt);
if (fromDate && d < fromDate) return false;
if (toDate && d > toDate) return false;
return true;
});
}, [packages, from, to]);
const byStatus: Record<string, number> = {};
packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; });
filteredPackages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; });
const totalDeclared = packages.reduce((a, p) => a + parseFloat(p.declaredValue ?? "0"), 0);
const totalDeclared = filteredPackages.reduce((a, p) => a + parseFloat(p.declaredValue ?? "0"), 0);
const inTransit = (byStatus["EN_TRANSITO_BODEGA"] ?? 0) + (byStatus["EN_TRANSITO_ECUADOR"] ?? 0);
const delivered = byStatus["ENTREGADO"] ?? 0;
const incidents = byStatus["INCIDENCIA"] ?? 0;
@@ -42,22 +90,81 @@ export default function ReportesPage() {
const byRole: Record<string, number> = {};
users.forEach(u => { byRole[u.role] = (byRole[u.role] ?? 0) + 1; });
// ── CSV Export handlers ────────────────────────────────────────────────
const exportPackagesCSV = () => {
const cols = [
{ key: "trackingId", label: "Tracking ID" },
{ key: "description", label: "Descripción" },
{ key: "store", label: "Tienda" },
{ key: "status", label: "Estado" },
{ key: "declaredValue", label: "Valor Declarado (USD)" },
{ key: "actualWeight", label: "Peso Real (lb)" },
{ key: "senaeCategory", label: "Categoría SENAE" },
{ key: "createdAt", label: "Fecha Registro" },
];
const rows = filteredPackages.map(p => ({
...p,
createdAt: new Date(p.createdAt).toLocaleDateString("es-EC"),
}));
downloadCSV(toCSV(rows, cols), `paquetes_${from}_${to}.csv`);
};
const exportUsersCSV = () => {
const cols = [
{ key: "firstName", label: "Nombre" },
{ key: "lastName", label: "Apellido" },
{ key: "email", label: "Email" },
{ key: "phone", label: "Teléfono" },
{ key: "role", label: "Rol" },
{ key: "isActive", label: "Activo" },
{ key: "createdAt", label: "Fecha Registro" },
];
const rows = users.map(u => ({
...u,
isActive: u.isActive ? "Sí" : "No",
createdAt: new Date(u.createdAt).toLocaleDateString("es-EC"),
}));
downloadCSV(toCSV(rows, cols), `usuarios_${new Date().toISOString().split("T")[0]}.csv`);
};
if (loading) return <div style={{ display:"flex", justifyContent:"center", padding:"4rem" }}><div className="spinner" /></div>;
return (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", marginBottom:"1.5rem", flexWrap:"wrap", gap:"1rem" }}>
<div>
<h1 className="dash-page-title">Reportes</h1>
<p className="dash-page-subtitle">Resumen operativo del sistema ingresos, volumen y estado de envíos.</p>
<p className="dash-page-subtitle">Resumen operativo ingresos, volumen y estado de envíos.</p>
</div>
{/* Date range + export */}
<div style={{ display:"flex", alignItems:"center", gap:".75rem", flexWrap:"wrap" }}>
<div style={{ display:"flex", alignItems:"center", gap:".5rem" }}>
<label style={{ fontSize:".8rem", color:"var(--gray-500)", whiteSpace:"nowrap" }}>Desde</label>
<input type="date" className="form-input" style={{ maxWidth:150 }} value={from} onChange={e => setFrom(e.target.value)} />
</div>
<div style={{ display:"flex", alignItems:"center", gap:".5rem" }}>
<label style={{ fontSize:".8rem", color:"var(--gray-500)", whiteSpace:"nowrap" }}>Hasta</label>
<input type="date" className="form-input" style={{ maxWidth:150 }} value={to} onChange={e => setTo(e.target.value)} />
</div>
<button className="btn btn-secondary btn-sm" onClick={exportPackagesCSV} title="Exportar paquetes a CSV">
Paquetes CSV
</button>
<button className="btn btn-secondary btn-sm" onClick={exportUsersCSV} title="Exportar usuarios a CSV">
Usuarios CSV
</button>
</div>
</div>
{/* KPIs */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Total paquetes", value: packages.length, color: "var(--primary)" },
{ label: "Paquetes en rango", value: filteredPackages.length, color: "var(--primary)" },
{ label: "Entregados", value: delivered, color: "var(--green)" },
{ label: "En tránsito", value: inTransit, color: "var(--yellow)" },
{ label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC")}`, color: "var(--accent)" },
{ label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC", { minimumFractionDigits:2, maximumFractionDigits:2 })}`, color: "var(--accent)" },
{ label: "Incidencias", value: incidents, color: "var(--red)" },
{ label: "Usuarios", value: users.length, color: "var(--primary)" },
{ label: "Usuarios totales", value: users.length, color: "var(--primary)" },
{ label: "Clientes", value: byRole["CLIENTE"] ?? 0, color: "#8B5CF6" },
{ label: "Pendiente aduana", value: byStatus["DECLARACION_ADUANERA"] ?? 0, color: "#0057FF" },
].map(s => (
@@ -71,11 +178,16 @@ export default function ReportesPage() {
<div className="grid-2" style={{ gap: "1.5rem" }}>
{/* Paquetes por estado */}
<div className="card">
<div className="card-header"><span className="font-semibold">Paquetes por estado (§08)</span></div>
<div className="card-header">
<span className="font-semibold">Paquetes por estado (§08)</span>
<span style={{ fontSize:".8rem", color:"var(--gray-400)" }}>
{from} {to}
</span>
</div>
<div className="card-body">
{Object.keys(STATUS_COLORS).map(status => {
const count = byStatus[status] ?? 0;
const pct = packages.length ? Math.round((count / packages.length) * 100) : 0;
const pct = filteredPackages.length ? Math.round((count / filteredPackages.length) * 100) : 0;
return (
<div key={status} style={{ marginBottom: ".75rem" }}>
<div style={{ display:"flex", justifyContent:"space-between", marginBottom:".2rem" }}>
@@ -91,7 +203,7 @@ export default function ReportesPage() {
</div>
);
})}
{packages.length === 0 && <p style={{ color:"var(--gray-500)", fontSize:".9rem" }}>Sin datos.</p>}
{filteredPackages.length === 0 && <p style={{ color:"var(--gray-500)", fontSize:".9rem" }}>Sin datos en el rango seleccionado.</p>}
</div>
</div>
@@ -115,8 +227,8 @@ export default function ReportesPage() {
<div className="card-header"><span className="font-semibold">Rendimiento</span></div>
<div className="card-body" style={{ display:"flex", flexDirection:"column", gap:".75rem" }}>
{[
["Tasa de entrega", packages.length ? `${Math.round((delivered/packages.length)*100)}%` : "—"],
["Tasa de incidencias", packages.length ? `${Math.round((incidents/packages.length)*100)}%` : "—"],
["Tasa de entrega", filteredPackages.length ? `${Math.round((delivered/filteredPackages.length)*100)}%` : "—"],
["Tasa de incidencias", filteredPackages.length ? `${Math.round((incidents/filteredPackages.length)*100)}%` : "—"],
["Pendiente declaración", byStatus["VERIFICADO"] ?? 0],
["En bodega NJ", (byStatus["RECIBIDO_BODEGA"] ?? 0) + (byStatus["EN_VERIFICACION"] ?? 0) + (byStatus["VERIFICADO"] ?? 0)],
].map(([k, v]) => (
+55 -3
View File
@@ -1,15 +1,18 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { getUser, clearAuth, getRefresh } from "@/lib/api";
import { getUser, clearAuth, getToken } from "@/lib/api";
import { api } from "@/lib/api";
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "http://localhost:3001";
const NAV = [
{ href: "/portal", icon: "◈", label: "Dashboard" },
{ href: "/portal/mi-casillero", icon: "📦", label: "Mi Casillero" },
{ href: "/portal/mis-paquetes", icon: "🚚", label: "Mis Paquetes" },
{ href: "/portal/pre-alerta", icon: "🔔", label: "Pre-Alerta" },
{ href: "/portal/registrar-compra", icon: "🛍️", label: "Registrar Compra" },
{ href: "/portal/consolidacion", icon: "🗃️", label: "Consolidar" },
{ href: "/portal/calculadora", icon: "🧮", label: "Calculadora" },
{ href: "/portal/perfil", icon: "👤", label: "Mi Perfil" },
@@ -20,12 +23,45 @@ export default function PortalLayout({ children }: { children: React.ReactNode }
const pathname = usePathname();
const [user, setUser] = useState<any>(null);
const [unread, setUnread] = useState(0);
const [toast, setToast] = useState<{ text: string; trackingId: string } | null>(null);
// ── Real-time WS notifications (C-6) ────────────────────────────────────
const connectWs = useCallback((token: string) => {
// Lazy-load socket.io-client only in browser
import("socket.io-client").then(({ io }) => {
const WS_NS = `${WS_URL}/ws`;
const socket = io(WS_NS, {
path: "/socket.io",
auth: { token },
transports: ["websocket", "polling"],
reconnectionAttempts: 5,
});
socket.on("package:status", (data: any) => {
setUnread(n => n + 1);
setToast({ text: `Paquete ${data.trackingId}${data.status.replace(/_/g, " ")}`, trackingId: data.trackingId });
// Auto-dismiss toast after 5 s
setTimeout(() => setToast(null), 5000);
});
socket.on("connect_error", (err: Error) => {
if (process.env.NODE_ENV === "development") {
console.debug("[WS] connect_error:", err.message);
}
});
return () => { socket.disconnect(); };
}).catch(() => {/* socket.io-client not available — SSR or CDN issue */});
}, []);
useEffect(() => {
const u = getUser();
if (!u) { router.replace("/login"); return; }
setUser(u);
}, [router]);
const token = getToken();
if (token) connectWs(token);
}, [router, connectWs]);
const handleLogout = async () => {
try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {}
@@ -37,6 +73,22 @@ export default function PortalLayout({ children }: { children: React.ReactNode }
return (
<div className="dash-layout">
{/* Toast (WS notification) */}
{toast && (
<div style={{
position: "fixed", bottom: 24, right: 24, zIndex: 9999,
background: "var(--primary)", color: "white", borderRadius: 10,
padding: "12px 20px", boxShadow: "0 4px 24px rgba(0,0,0,.25)",
maxWidth: 340, fontSize: ".875rem", fontWeight: 500,
display: "flex", alignItems: "center", gap: 12,
animation: "slideIn .25s ease",
}}>
<span>🚀</span>
<span>{toast.text}</span>
<button onClick={() => setToast(null)} style={{ background: "none", border: "none", color: "rgba(255,255,255,.7)", cursor: "pointer", fontSize: "1rem", marginLeft: "auto" }}>×</button>
</div>
)}
{/* Sidebar */}
<aside className="dash-sidebar">
<div className="dash-logo">Mora<span>world</span></div>
@@ -0,0 +1,177 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api";
const SENAE_OPTIONS = [
{ value: "REGIMEN_4X4", label: "Régimen 4×4 (hasta $400, exento aranceles)" },
{ value: "CATEGORIA_B", label: "Categoría B (electrónicos, 10%)" },
{ value: "CATEGORIA_C", label: "Categoría C (varios, 20%)" },
{ value: "CATEGORIA_D", label: "Categoría D (textiles/calzado, 10% base)" },
];
const FIELD_HINTS: Record<string, string> = {
store: "Ej: Amazon, eBay, Walmart",
vendorTracking: "Número de rastreo del vendedor/courier (UPS, FedEx, USPS…)",
productUrl: "URL del producto en la tienda (opcional, para referencia)",
declaredValue: "Valor en USD que declaras a aduana. Debe ser exacto.",
declaredWeight: "Peso aproximado en libras. La bodega pesará al recibir.",
};
export default function RegistrarCompraPage() {
const [form, setForm] = useState({
description: "",
store: "",
vendorTracking: "",
productUrl: "",
declaredValue: "",
declaredWeight: "",
senaeCategory: "REGIMEN_4X4",
});
const [loading, setSaving] = useState(false);
const [success, setSuccess] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const update = (field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm(f => ({ ...f, [field]: e.target.value }));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.description.trim()) { setError("La descripción del producto es obligatoria."); return; }
setSaving(true);
setError(null);
setSuccess(null);
try {
const body: any = {
description: form.description,
senaeCategory: form.senaeCategory,
};
if (form.store.trim()) body.store = form.store;
if (form.vendorTracking.trim()) body.vendorTracking = form.vendorTracking;
if (form.productUrl.trim()) body.productUrl = form.productUrl;
if (form.declaredValue.trim()) body.declaredValue = parseFloat(form.declaredValue);
if (form.declaredWeight.trim()) body.declaredWeightLb = parseFloat(form.declaredWeight);
const pkg = await api.packages.register(body);
setSuccess(pkg);
setForm({ description: "", store: "", vendorTracking: "", productUrl: "", declaredValue: "", declaredWeight: "", senaeCategory: "REGIMEN_4X4" });
} catch (err: any) {
setError(err.message ?? "Error al registrar el paquete.");
} finally {
setSaving(false);
}
};
return (
<div style={{ maxWidth: 680, margin: "0 auto" }}>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Registrar Compra</h1>
<p className="dash-page-subtitle">
Registra una compra realizada en EE.UU. antes de que llegue a nuestra bodega.
Te asignaremos un tracking ID de Moraworld para seguimiento completo (§09).
</p>
</div>
{success && (
<div className="alert alert-success" style={{ marginBottom: "1.5rem" }}>
<strong>Compra registrada exitosamente.</strong><br />
Tracking ID: <code style={{ fontWeight: 700, fontSize: "1rem", color: "var(--primary)" }}>{success.trackingId}</code><br />
<span style={{ fontSize: ".85rem", color: "var(--gray-600)" }}>
Guarda este código para rastrear tu paquete desde &quot;Mis Paquetes&quot;.
</span>
</div>
)}
{error && (
<div className="alert alert-error" style={{ marginBottom: "1rem" }}>{error}</div>
)}
<form onSubmit={handleSubmit} className="card">
<div className="card-header"><span className="font-semibold">Información del producto</span></div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* Descripción */}
<div className="form-group">
<label className="form-label">Descripción del producto *</label>
<input
className="form-input"
placeholder="Ej: Auriculares Bluetooth Sony WH-1000XM5 negro"
value={form.description}
onChange={update("description")}
required
/>
<small className="form-hint">Describe brevemente qué compraste. Esto ayuda al agente aduanero.</small>
</div>
{/* Tienda + Tracking del vendedor */}
<div className="grid-2" style={{ gap: "1rem" }}>
<div className="form-group">
<label className="form-label">Tienda</label>
<input className="form-input" placeholder={FIELD_HINTS.store} value={form.store} onChange={update("store")} />
</div>
<div className="form-group">
<label className="form-label">Tracking del vendedor</label>
<input className="form-input" placeholder={FIELD_HINTS.vendorTracking} value={form.vendorTracking} onChange={update("vendorTracking")} />
</div>
</div>
{/* URL del producto */}
<div className="form-group">
<label className="form-label">URL del producto</label>
<input className="form-input" type="url" placeholder={FIELD_HINTS.productUrl} value={form.productUrl} onChange={update("productUrl")} />
</div>
{/* Valor + Peso */}
<div className="grid-2" style={{ gap: "1rem" }}>
<div className="form-group">
<label className="form-label">Valor declarado (USD)</label>
<input className="form-input" type="number" min="0" step="0.01" placeholder="0.00" value={form.declaredValue} onChange={update("declaredValue")} />
<small className="form-hint">{FIELD_HINTS.declaredValue}</small>
</div>
<div className="form-group">
<label className="form-label">Peso aprox. (lb)</label>
<input className="form-input" type="number" min="0" step="0.1" placeholder="0.0" value={form.declaredWeight} onChange={update("declaredWeight")} />
<small className="form-hint">{FIELD_HINTS.declaredWeight}</small>
</div>
</div>
{/* Categoría SENAE */}
<div className="form-group">
<label className="form-label">Categoría SENAE</label>
<select className="form-input" value={form.senaeCategory} onChange={update("senaeCategory")}>
{SENAE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<small className="form-hint">
El agente aduanero puede cambiar la categoría al recibir el paquete. Para la mayoría de productos
personales el <strong>Régimen 4×4</strong> aplica si el valor es menor a $400.
</small>
</div>
<div style={{ paddingTop: ".5rem" }}>
<button type="submit" className="btn btn-primary" disabled={loading} style={{ width: "100%" }}>
{loading ? <span><span className="spinner" style={{ width:16, height:16, marginRight:8 }} />Registrando...</span> : "Registrar Compra"}
</button>
</div>
</div>
</form>
{/* Info box */}
<div className="card" style={{ marginTop: "1.5rem", background: "var(--primary-50, #eff6ff)", border: "1px solid var(--primary-100, #dbeafe)" }}>
<div className="card-body" style={{ fontSize: ".85rem", color: "var(--gray-700)" }}>
<p style={{ fontWeight: 600, marginBottom: ".5rem" }}>¿Cómo funciona el flujo?</p>
<ol style={{ margin: 0, paddingLeft: "1.25rem", lineHeight: 1.8 }}>
<li>Registras tu compra aquí obtienes un Tracking ID Moraworld.</li>
<li>El producto llega a nuestra bodega en New Jersey.</li>
<li>Lo verificamos, pesamos y fotografiamos.</li>
<li>Generamos la Declaración Simplificada (DSI) ante la SENAE.</li>
<li>El paquete viaja a Ecuador te notificamos en cada paso.</li>
<li>Pagas el cobro final y coordinamos la entrega.</li>
</ol>
</div>
</div>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: "Super Admin",
ADMIN_EMPRESA: "Admin Empresa",
OPERADOR_BODEGA: "Operador Bodega",
AGENTE_ADUANERO: "Agente Aduanero",
CLIENTE: "Cliente",
SOPORTE: "Soporte",
};
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: "#EF4444",
ADMIN_EMPRESA: "#F97316",
OPERADOR_BODEGA: "#3B82F6",
AGENTE_ADUANERO: "#8B5CF6",
CLIENTE: "#10B981",
SOPORTE: "#F59E0B",
};
export default function SoporteClientesPage() {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [role, setRole] = useState("");
useEffect(() => {
api.users.list(search || undefined)
.then(setUsers)
.catch(() => {})
.finally(() => setLoading(false));
}, [search]);
const filtered = users.filter(u => {
if (role && u.role !== role) return false;
return true;
});
const byRole: Record<string, number> = {};
users.forEach(u => { byRole[u.role] = (byRole[u.role] ?? 0) + 1; });
return (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Clientes y Usuarios Solo lectura</h1>
<p className="dash-page-subtitle">Consulta el directorio de usuarios del sistema.</p>
</div>
{/* Role stats */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{Object.entries(ROLE_LABELS).map(([r, label]) => (
<div key={r} className="stat-card" style={{ cursor:"pointer", border: role === r ? "2px solid var(--primary)" : undefined }}
onClick={() => setRole(role === r ? "" : r)}>
<div className="stat-value" style={{ color: ROLE_COLORS[r] ?? "var(--primary)", fontSize: "1.5rem" }}>{byRole[r] ?? 0}</div>
<div className="stat-label" style={{ fontSize:".75rem" }}>{label}</div>
</div>
))}
</div>
{/* Filters */}
<div style={{ display:"flex", gap:"1rem", marginBottom:"1.25rem", flexWrap:"wrap" }}>
<input
className="form-input"
style={{ maxWidth: 280 }}
placeholder="Buscar por nombre o email..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<select className="form-input" style={{ maxWidth: 200 }} value={role} onChange={e => setRole(e.target.value)}>
<option value="">Todos los roles</option>
{Object.entries(ROLE_LABELS).map(([r, label]) => (
<option key={r} value={r}>{label}</option>
))}
</select>
<span style={{ fontSize:".875rem", color:"var(--gray-500)", alignSelf:"center" }}>
{filtered.length} usuario{filtered.length !== 1 ? "s" : ""}
</span>
</div>
{loading ? (
<div style={{ display:"flex", justifyContent:"center", padding:"3rem" }}><div className="spinner" /></div>
) : (
<div className="card">
<div style={{ overflowX:"auto" }}>
<table className="table">
<thead>
<tr>
<th>Nombre</th>
<th>Email</th>
<th>Teléfono</th>
<th>Rol</th>
<th>Suite</th>
<th>MFA</th>
<th>Estado</th>
<th>Registro</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr><td colSpan={8} style={{ textAlign:"center", color:"var(--gray-400)", padding:"2rem" }}>Sin resultados</td></tr>
)}
{filtered.map(u => (
<tr key={u.id}>
<td style={{ fontWeight:500 }}>{u.firstName} {u.lastName}</td>
<td style={{ fontSize:".85rem", color:"var(--gray-600)" }}>{u.email}</td>
<td style={{ fontSize:".85rem" }}>{u.phone ?? "—"}</td>
<td>
<span style={{
display:"inline-block",
background: (ROLE_COLORS[u.role] ?? "#6B7280") + "20",
color: ROLE_COLORS[u.role] ?? "#6B7280",
borderRadius:999, padding:"2px 10px", fontSize:".75rem", fontWeight:600,
}}>
{ROLE_LABELS[u.role] ?? u.role}
</span>
</td>
<td style={{ fontSize:".85rem" }}>
{u.suite ? (
<code style={{ fontSize:".8rem" }}>{u.suite.code}</code>
) : <span style={{ color:"var(--gray-400)" }}></span>}
</td>
<td style={{ textAlign:"center" }}>
{u.mfaEnabled ? (
<span title="MFA activo" style={{ color:"var(--green)", fontWeight:700 }}></span>
) : (
<span style={{ color:"var(--gray-300)" }}></span>
)}
</td>
<td>
<span style={{
display:"inline-block",
background: u.isActive ? "var(--green-50, #f0fdf4)" : "var(--red-50, #fef2f2)",
color: u.isActive ? "var(--green)" : "var(--red)",
borderRadius:999, padding:"2px 10px", fontSize:".75rem", fontWeight:600,
}}>
{u.isActive ? "Activo" : "Inactivo"}
</span>
</td>
<td style={{ fontSize:".8rem", color:"var(--gray-500)", whiteSpace:"nowrap" }}>
{new Date(u.createdAt).toLocaleDateString("es-EC")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { getUser, clearAuth } from "@/lib/api";
import { api } from "@/lib/api";
const NAV = [
{ href: "/soporte", icon: "◈", label: "Dashboard" },
{ href: "/soporte/paquetes", icon: "📦", label: "Paquetes" },
{ href: "/soporte/clientes", icon: "👥", label: "Clientes" },
];
export default function SoporteLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<any>(null);
useEffect(() => {
const u = getUser();
if (!u) { router.replace("/login"); return; }
if (!["SOPORTE", "ADMIN_EMPRESA", "SUPER_ADMIN"].includes(u.role)) {
router.replace("/portal");
return;
}
setUser(u);
}, [router]);
const handleLogout = async () => {
try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {}
clearAuth();
router.push("/login");
};
if (!user) return <div className="loading-overlay"><div className="spinner" /></div>;
return (
<div className="dash-layout">
<aside className="dash-sidebar" style={{ background: "var(--gray-900)" }}>
<div className="dash-logo" style={{ color: "var(--yellow)" }}>
Soporte<span style={{ color: "white" }}>Portal</span>
</div>
<nav className="dash-nav">
{NAV.map(item => (
<Link key={item.href} href={item.href}
className={`dash-nav-item ${pathname === item.href || (item.href !== "/soporte" && pathname.startsWith(item.href)) ? "active" : ""}`}>
<span>{item.icon}</span>
<span>{item.label}</span>
</Link>
))}
</nav>
<div className="dash-user">
<div className="dash-user-name">{user.firstName} {user.lastName}</div>
<div className="dash-user-role" style={{ color: "var(--yellow)" }}>Soporte</div>
<button
className="btn btn-ghost btn-sm"
style={{ marginTop: ".5rem", color: "rgba(255,255,255,.5)", fontSize: ".8rem" }}
onClick={handleLogout}
>
Cerrar sesión
</button>
</div>
</aside>
<div className="dash-main">
<header className="dash-topbar">
<span style={{ fontSize: "1rem", fontWeight: 600 }}>Portal de Soporte</span>
<span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>
{user.email} <strong>Solo lectura</strong>
</span>
</header>
<main className="dash-content">{children}</main>
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
const STATUS_COLORS: Record<string, string> = {
REGISTRADO: "#6B7280",
EN_TRANSITO_BODEGA: "#F59E0B",
RECIBIDO_BODEGA: "#3B82F6",
EN_VERIFICACION: "#8B5CF6",
VERIFICADO: "#10B981",
DECLARACION_ADUANERA: "#0057FF",
EN_TRANSITO_ECUADOR: "#F97316",
EN_ADUANA_ECUADOR: "#EF4444",
LISTO_ENTREGA: "#84CC16",
ENTREGADO: "#10B981",
INCIDENCIA: "#EF4444",
};
export default function SoportePage() {
const [packages, setPackages] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([api.packages.list(), api.users.list()])
.then(([p, u]) => { setPackages(p); setUsers(u); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <div style={{ display:"flex", justifyContent:"center", padding:"4rem" }}><div className="spinner" /></div>;
const byStatus: Record<string, number> = {};
packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; });
const incidents = byStatus["INCIDENCIA"] ?? 0;
const inTransit = (byStatus["EN_TRANSITO_BODEGA"] ?? 0) + (byStatus["EN_TRANSITO_ECUADOR"] ?? 0);
const delivered = byStatus["ENTREGADO"] ?? 0;
return (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Dashboard de Soporte</h1>
<p className="dash-page-subtitle">Vista de solo lectura. Para modificar datos usa el panel de Admin.</p>
</div>
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Total paquetes", value: packages.length, color: "var(--primary)" },
{ label: "En tránsito", value: inTransit, color: "var(--yellow)" },
{ label: "Entregados", value: delivered, color: "var(--green)" },
{ label: "Incidencias", value: incidents, color: "var(--red)" },
{ label: "Total clientes", value: users.filter(u => u.role === "CLIENTE").length, color: "#8B5CF6" },
{ label: "Total usuarios", value: users.length, color: "var(--primary)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color, fontSize: "1.75rem" }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="card">
<div className="card-header"><span className="font-semibold">Distribución por estado (§08)</span></div>
<div className="card-body">
{Object.keys(STATUS_COLORS).map(status => {
const count = byStatus[status] ?? 0;
const pct = packages.length ? Math.round((count / packages.length) * 100) : 0;
return (
<div key={status} style={{ marginBottom: ".75rem" }}>
<div style={{ display:"flex", justifyContent:"space-between", marginBottom:".2rem" }}>
<span style={{ fontSize:".8rem", display:"flex", alignItems:"center", gap:".4rem" }}>
<span style={{ width:8, height:8, borderRadius:"50%", background: STATUS_COLORS[status], display:"inline-block" }} />
{status.replace(/_/g," ")}
</span>
<span style={{ fontWeight:700, fontSize:".85rem" }}>{count}</span>
</div>
<div style={{ height:4, background:"var(--gray-100)", borderRadius:999 }}>
<div style={{ height:4, borderRadius:999, background: STATUS_COLORS[status], width:`${pct}%`, transition:"width .4s" }} />
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
const STATUS_LABELS: Record<string, string> = {
REGISTRADO: "Registrado",
EN_TRANSITO_BODEGA: "En tránsito → NJ",
RECIBIDO_BODEGA: "En bodega NJ",
EN_VERIFICACION: "En verificación",
VERIFICADO: "Verificado",
DECLARACION_ADUANERA: "Declaración SENAE",
EN_TRANSITO_ECUADOR: "En tránsito → EC",
EN_ADUANA_ECUADOR: "En aduana EC",
LISTO_ENTREGA: "Listo para entrega",
ENTREGADO: "Entregado",
INCIDENCIA: "Incidencia",
};
const STATUS_COLORS: Record<string, string> = {
REGISTRADO: "#6B7280",
EN_TRANSITO_BODEGA: "#F59E0B",
RECIBIDO_BODEGA: "#3B82F6",
EN_VERIFICACION: "#8B5CF6",
VERIFICADO: "#10B981",
DECLARACION_ADUANERA: "#0057FF",
EN_TRANSITO_ECUADOR: "#F97316",
EN_ADUANA_ECUADOR: "#EF4444",
LISTO_ENTREGA: "#84CC16",
ENTREGADO: "#10B981",
INCIDENCIA: "#EF4444",
};
export default function SoportePaquetesPage() {
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState("");
useEffect(() => {
const params: Record<string, string> = {};
if (filter) params.status = filter;
if (search) params.search = search;
api.packages.list(Object.keys(params).length ? params : undefined)
.then(setPackages)
.catch(() => {})
.finally(() => setLoading(false));
}, [filter, search]);
const filtered = packages.filter(p => {
if (!search) return true;
const q = search.toLowerCase();
return (
(p.trackingId ?? "").toLowerCase().includes(q) ||
(p.description ?? "").toLowerCase().includes(q) ||
(p.user?.email ?? "").toLowerCase().includes(q)
);
});
return (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Paquetes Solo lectura</h1>
<p className="dash-page-subtitle">Consulta el estado de todos los paquetes del sistema.</p>
</div>
{/* Filters */}
<div style={{ display:"flex", gap:"1rem", marginBottom:"1.25rem", flexWrap:"wrap" }}>
<input
className="form-input"
style={{ maxWidth: 280 }}
placeholder="Buscar por tracking, descripción, email..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<select className="form-input" style={{ maxWidth: 240 }} value={filter} onChange={e => setFilter(e.target.value)}>
<option value="">Todos los estados</option>
{Object.keys(STATUS_LABELS).map(s => (
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
))}
</select>
<span style={{ fontSize:".875rem", color:"var(--gray-500)", alignSelf:"center" }}>
{filtered.length} paquete{filtered.length !== 1 ? "s" : ""}
</span>
</div>
{loading ? (
<div style={{ display:"flex", justifyContent:"center", padding:"3rem" }}><div className="spinner" /></div>
) : (
<div className="card">
<div style={{ overflowX: "auto" }}>
<table className="table">
<thead>
<tr>
<th>Tracking ID</th>
<th>Descripción</th>
<th>Cliente</th>
<th>Tienda</th>
<th>Peso real (lb)</th>
<th>Estado</th>
<th>Fecha</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr><td colSpan={7} style={{ textAlign:"center", color:"var(--gray-400)", padding:"2rem" }}>Sin resultados</td></tr>
)}
{filtered.map(pkg => (
<tr key={pkg.id}>
<td><code style={{ fontSize:".8rem", fontWeight:600 }}>{pkg.trackingId}</code></td>
<td style={{ maxWidth:220, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>
{pkg.description ?? "—"}
</td>
<td style={{ fontSize:".8rem" }}>
{pkg.user ? `${pkg.user.firstName} ${pkg.user.lastName}` : "—"}
{pkg.user?.email && <div style={{ color:"var(--gray-400)", fontSize:".75rem" }}>{pkg.user.email}</div>}
</td>
<td style={{ fontSize:".85rem" }}>{pkg.store ?? "—"}</td>
<td style={{ textAlign:"center" }}>
{pkg.actualWeight ? `${Number(pkg.actualWeight).toFixed(1)} lb` : "—"}
</td>
<td>
<span style={{
display:"inline-flex", alignItems:"center", gap:5,
background: (STATUS_COLORS[pkg.status] ?? "#6B7280") + "20",
color: STATUS_COLORS[pkg.status] ?? "#6B7280",
borderRadius:999, padding:"2px 10px", fontSize:".75rem", fontWeight:600, whiteSpace:"nowrap",
}}>
{STATUS_LABELS[pkg.status] ?? pkg.status}
</span>
</td>
<td style={{ fontSize:".8rem", color:"var(--gray-500)", whiteSpace:"nowrap" }}>
{new Date(pkg.createdAt).toLocaleDateString("es-EC")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+1
View File
@@ -110,6 +110,7 @@ export const api = {
}).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) }),
register: (body: any) => request<any>("/packages/register", { method: "POST", body: JSON.stringify(body) }),
},
preAlerts: {
list: () => request<any[]>("/pre-alerts"),
+225 -5
View File
@@ -37,7 +37,7 @@ importers:
version: 4.0.4(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
'@nestjs/core':
specifier: ^11.1.0
version: 11.1.21(@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/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 11.1.21(@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/platform-express@11.1.21)(@nestjs/websockets@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/jwt':
specifier: ^11.0.2
version: 11.0.2(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))
@@ -47,9 +47,15 @@ importers:
'@nestjs/platform-express':
specifier: ^11.1.0
version: 11.1.21(@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)
'@nestjs/platform-socket.io':
specifier: ^11.1.24
version: 11.1.24(@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/websockets@11.1.24)(rxjs@7.8.2)
'@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)
'@nestjs/websockets':
specifier: ^11.1.24
version: 11.1.24(@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)(@nestjs/platform-socket.io@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@types/multer':
specifier: ^2.1.0
version: 2.1.0
@@ -80,6 +86,9 @@ importers:
rxjs:
specifier: ^7.8.2
version: 7.8.2
socket.io:
specifier: ^4.8.3
version: 4.8.3
stripe:
specifier: ^22.2.0
version: 22.2.0(@types/node@22.19.19)
@@ -132,6 +141,9 @@ importers:
react-dom:
specifier: ^19.1.0
version: 19.2.6(react@19.2.6)
socket.io-client:
specifier: ^4.8.3
version: 4.8.3
devDependencies:
'@types/js-cookie':
specifier: ^3.0.6
@@ -1100,6 +1112,13 @@ packages:
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
'@nestjs/platform-socket.io@11.1.24':
resolution: {integrity: sha512-ImdR9G8W5Y2Hhcptdci+tNaG6JV/dzDguFTgtXOL5ie/gD9O9ARw8Cd9RzF2+oteyzQ+1sPK/+wgVOPOyYGVCA==}
peerDependencies:
'@nestjs/common': ^11.0.0
'@nestjs/websockets': ^11.0.0
rxjs: ^7.1.0
'@nestjs/schematics@11.1.0':
resolution: {integrity: sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==}
peerDependencies:
@@ -1129,6 +1148,18 @@ packages:
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
reflect-metadata: ^0.1.13 || ^0.2.0
'@nestjs/websockets@11.1.24':
resolution: {integrity: sha512-37Z/QYzZ4nPHcGyGGjhjoKVOcpSPMhmRQj5DS1l0RKlRYgq8S0cmgaZ6kQ8PI3259PdchLx41oQibXh22iEUiA==}
peerDependencies:
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
'@nestjs/platform-socket.io': ^11.0.0
reflect-metadata: ^0.1.12 || ^0.2.0
rxjs: ^7.1.0
peerDependenciesMeta:
'@nestjs/platform-socket.io':
optional: true
'@next/env@15.5.18':
resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==}
@@ -1288,6 +1319,9 @@ packages:
resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
engines: {node: '>=14.0.0'}
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -1352,6 +1386,9 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -1438,6 +1475,9 @@ packages:
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@@ -1495,6 +1535,10 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -1620,6 +1664,10 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
base64id@2.0.0:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
baseline-browser-mapping@2.10.31:
resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==}
engines: {node: '>=6.0.0'}
@@ -1958,6 +2006,17 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
engine.io-client@6.6.5:
resolution: {integrity: sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==}
engine.io-parser@5.2.3:
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
engines: {node: '>=10.0.0'}
engine.io@6.6.8:
resolution: {integrity: sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==}
engines: {node: '>=10.2.0'}
enhanced-resolve@5.21.5:
resolution: {integrity: sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==}
engines: {node: '>=10.13.0'}
@@ -2632,6 +2691,10 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
negotiator@0.6.3:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
@@ -2700,6 +2763,10 @@ packages:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-hash@3.0.0:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -3022,6 +3089,21 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
socket.io-adapter@2.5.7:
resolution: {integrity: sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==}
socket.io-client@4.8.3:
resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==}
engines: {node: '>=10.0.0'}
socket.io-parser@4.2.6:
resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==}
engines: {node: '>=10.0.0'}
socket.io@4.8.3:
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
engines: {node: '>=10.2.0'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -3387,10 +3469,26 @@ packages:
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
ws@8.20.1:
resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xml-naming@0.1.0:
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
engines: {node: '>=16.0.0'}
xmlhttprequest-ssl@2.1.2:
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
engines: {node: '>=0.4.0'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -4509,7 +4607,7 @@ snapshots:
lodash: 4.18.1
rxjs: 7.8.2
'@nestjs/core@11.1.21(@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/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
'@nestjs/core@11.1.21(@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/platform-express@11.1.21)(@nestjs/websockets@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nuxt/opencollective': 0.4.1
@@ -4522,6 +4620,7 @@ snapshots:
uid: 2.0.2
optionalDependencies:
'@nestjs/platform-express': 11.1.21(@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)
'@nestjs/websockets': 11.1.24(@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)(@nestjs/platform-socket.io@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/jwt@11.0.2(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
dependencies:
@@ -4537,7 +4636,7 @@ snapshots:
'@nestjs/platform-express@11.1.21(@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)':
dependencies:
'@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(@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/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.21(@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/platform-express@11.1.21)(@nestjs/websockets@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cors: 2.8.6
express: 5.2.1
multer: 2.1.1
@@ -4546,6 +4645,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@nestjs/platform-socket.io@11.1.24(@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/websockets@11.1.24)(rxjs@7.8.2)':
dependencies:
'@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/websockets': 11.1.24(@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)(@nestjs/platform-socket.io@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
rxjs: 7.8.2
socket.io: 4.8.3
tslib: 2.8.1
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@nestjs/schematics@11.1.0(chokidar@4.0.3)(typescript@5.9.3)':
dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
@@ -4560,7 +4671,7 @@ snapshots:
'@nestjs/testing@11.1.24(@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)(@nestjs/platform-express@11.1.21)':
dependencies:
'@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(@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/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.21(@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/platform-express@11.1.21)(@nestjs/websockets@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1
optionalDependencies:
'@nestjs/platform-express': 11.1.21(@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)
@@ -4568,9 +4679,21 @@ snapshots:
'@nestjs/throttler@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)':
dependencies:
'@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(@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/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.21(@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/platform-express@11.1.21)(@nestjs/websockets@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
'@nestjs/websockets@11.1.24(@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)(@nestjs/platform-socket.io@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@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(@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/platform-express@11.1.21)(@nestjs/websockets@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
iterare: 1.2.1
object-hash: 3.0.0
reflect-metadata: 0.2.2
rxjs: 7.8.2
tslib: 2.8.1
optionalDependencies:
'@nestjs/platform-socket.io': 11.1.24(@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/websockets@11.1.24)(rxjs@7.8.2)
'@next/env@15.5.18': {}
'@next/swc-darwin-arm64@15.5.18':
@@ -4727,6 +4850,8 @@ snapshots:
'@smithy/util-buffer-from': 2.2.0
tslib: 2.8.1
'@socket.io/component-emitter@3.1.2': {}
'@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.15':
@@ -4794,6 +4919,10 @@ snapshots:
dependencies:
'@types/node': 22.19.19
'@types/cors@2.8.19':
dependencies:
'@types/node': 22.19.19
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -4898,6 +5027,10 @@ snapshots:
'@types/validator@13.15.10': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.19.19
'@types/yargs-parser@21.0.3': {}
'@types/yargs@17.0.35':
@@ -4984,6 +5117,11 @@ snapshots:
'@xtuc/long@4.2.2': {}
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
negotiator: 0.6.3
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -5125,6 +5263,8 @@ snapshots:
base64-js@1.5.1: {}
base64id@2.0.0: {}
baseline-browser-mapping@2.10.31: {}
bcrypt@6.0.0:
@@ -5436,6 +5576,37 @@ snapshots:
encodeurl@2.0.0: {}
engine.io-client@6.6.5:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3
engine.io-parser: 5.2.3
ws: 8.20.1
xmlhttprequest-ssl: 2.1.2
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
engine.io-parser@5.2.3: {}
engine.io@6.6.8:
dependencies:
'@types/cors': 2.8.19
'@types/node': 22.19.19
'@types/ws': 8.18.1
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.7.2
cors: 2.8.6
debug: 4.4.3
engine.io-parser: 5.2.3
ws: 8.20.1
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
enhanced-resolve@5.21.5:
dependencies:
graceful-fs: 4.2.11
@@ -6326,6 +6497,8 @@ snapshots:
natural-compare@1.4.0: {}
negotiator@0.6.3: {}
negotiator@1.0.0: {}
neo-async@2.6.2: {}
@@ -6383,6 +6556,8 @@ snapshots:
object-assign@4.1.1: {}
object-hash@3.0.0: {}
object-inspect@1.13.4: {}
ohash@2.0.11: {}
@@ -6745,6 +6920,47 @@ snapshots:
slash@3.0.0: {}
socket.io-adapter@2.5.7:
dependencies:
debug: 4.4.3
ws: 8.20.1
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
socket.io-client@4.8.3:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3
engine.io-client: 6.6.5
socket.io-parser: 4.2.6
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
socket.io-parser@4.2.6:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
socket.io@4.8.3:
dependencies:
accepts: 1.3.8
base64id: 2.0.0
cors: 2.8.6
debug: 4.4.3
engine.io: 6.6.8
socket.io-adapter: 2.5.7
socket.io-parser: 4.2.6
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
source-map-js@1.2.1: {}
source-map-support@0.5.13:
@@ -7062,8 +7278,12 @@ snapshots:
imurmurhash: 0.1.4
signal-exit: 3.0.7
ws@8.20.1: {}
xml-naming@0.1.0: {}
xmlhttprequest-ssl@2.1.2: {}
y18n@5.0.8: {}
yallist@3.1.1: {}