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:
@@ -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": {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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]) => (
|
||||
|
||||
@@ -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 "Mis Paquetes".
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
Generated
+225
-5
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user