From a047a8b032c73d8fffd728403a39b6a406440a50 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:55:30 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20StorageService=20S3/MinIO,=20POST=20/us?= =?UTF-8?q?ers,=20invoice=20upload,=20portal=20dashboard=20=C2=A708=20stat?= =?UTF-8?q?uses=20+=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/package.json | 3 + .../notifications/notifications.controller.ts | 13 + .../src/notifications/notifications.module.ts | 4 +- apps/api/src/packages/packages.controller.ts | 44 +- apps/api/src/packages/packages.module.ts | 3 +- apps/api/src/pre-alerts/dto/pre-alert.dto.ts | 4 + .../src/pre-alerts/pre-alerts.controller.ts | 41 +- apps/api/src/pre-alerts/pre-alerts.module.ts | 8 +- apps/api/src/pre-alerts/pre-alerts.service.ts | 26 +- apps/api/src/storage/storage.service.ts | 111 +++- apps/api/src/users/users.controller.ts | 20 +- apps/api/src/users/users.module.ts | 8 +- apps/api/src/users/users.service.ts | 50 +- apps/web/src/app/admin/usuarios/page.tsx | 128 +++- apps/web/src/app/portal/mis-paquetes/page.tsx | 34 +- apps/web/src/app/portal/page.tsx | 198 ++++-- apps/web/src/app/portal/pre-alerta/page.tsx | 25 +- apps/web/src/lib/api.ts | 15 + pnpm-lock.yaml | 565 ++++++++++++++++++ 19 files changed, 1166 insertions(+), 134 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 05254bc..cb8e3d1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,9 @@ "test:ci": "jest --ci --coverage --forceExit" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1058.0", + "@aws-sdk/lib-storage": "^3.1058.0", + "@aws-sdk/s3-request-presigner": "^3.1058.0", "@moraworld/database": "workspace:*", "@nestjs/common": "^11.1.0", "@nestjs/config": "^4.0.2", diff --git a/apps/api/src/notifications/notifications.controller.ts b/apps/api/src/notifications/notifications.controller.ts index adb26cc..bf07e95 100644 --- a/apps/api/src/notifications/notifications.controller.ts +++ b/apps/api/src/notifications/notifications.controller.ts @@ -5,6 +5,7 @@ import { Post, Body, Param, + Query, UseGuards, Request, } from "@nestjs/common"; @@ -44,3 +45,15 @@ export class NotificationsController { return this.svc.seedDefaultTemplates(req.user.tenantId); } } + +/** GET /notifications — bandeja de entrada del usuario */ +@Controller("notifications") +@UseGuards(JwtAuthGuard) +export class NotificationsUserController { + constructor(private readonly svc: NotificationsService) {} + + @Get() + list(@Request() req: any, @Query("limit") limit?: string) { + return this.svc.findByUser(req.user.id, limit ? Number(limit) : 20); + } +} diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts index 36ec9d8..fe16390 100644 --- a/apps/api/src/notifications/notifications.module.ts +++ b/apps/api/src/notifications/notifications.module.ts @@ -1,12 +1,12 @@ import { Module } from "@nestjs/common"; import { NotificationsService } from "./notifications.service"; -import { NotificationsController } from "./notifications.controller"; +import { NotificationsController, NotificationsUserController } from "./notifications.controller"; import { PrismaModule } from "../prisma/prisma.module"; import { IntegrationsModule } from "../integrations/integrations.module"; @Module({ imports: [PrismaModule, IntegrationsModule], - controllers: [NotificationsController], + controllers: [NotificationsController, NotificationsUserController], providers: [NotificationsService], exports: [NotificationsService], }) diff --git a/apps/api/src/packages/packages.controller.ts b/apps/api/src/packages/packages.controller.ts index a4c347b..80db8b4 100644 --- a/apps/api/src/packages/packages.controller.ts +++ b/apps/api/src/packages/packages.controller.ts @@ -3,27 +3,21 @@ import { UseGuards, UseInterceptors, UploadedFiles, } from "@nestjs/common"; import { FilesInterceptor } from "@nestjs/platform-express"; -import { diskStorage } from "multer"; -import { extname, join } from "path"; -import { existsSync, mkdirSync } from "fs"; -import { randomUUID } from "crypto"; +import { 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 { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard"; import { CurrentUser } from "../auth/decorators/current-user.decorator"; -import { ConfigService } from "@nestjs/config"; @Controller("packages") @UseGuards(JwtAuthGuard, RolesGuard) export class PackagesController { - private readonly apiUrl: string; - constructor( private svc: PackagesService, - private config: ConfigService, - ) { - this.apiUrl = this.config.get("API_URL", "http://localhost:3001"); - } + private storage: StorageService, + ) {} @Get() findAll( @@ -72,23 +66,13 @@ export class PackagesController { return this.svc.verifyPackage(id, dto, user.id); } - /** Bodega: upload photos via multipart form (doc §10 step 4) */ + /** Bodega: upload photos via multipart form — stored via StorageService (doc §10 step 4) */ @Post(":id/photos") @Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN") @UseInterceptors( FilesInterceptor("photos", 10, { - storage: diskStorage({ - destination: (req, file, cb) => { - const pkgId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const dir = join(process.cwd(), "uploads", "packages", pkgId); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - cb(null, dir); - }, - filename: (req, file, cb) => { - cb(null, `${randomUUID()}${extname(file.originalname)}`); - }, - }), - fileFilter: (req, file, cb) => { + storage: memoryStorage(), + fileFilter: (_req, file, cb) => { const allowed = /jpg|jpeg|png|gif|webp/; cb(null, allowed.test(extname(file.originalname).toLowerCase())); }, @@ -100,9 +84,15 @@ export class PackagesController { @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: any, ): Promise { - const baseUrl = this.apiUrl; - const photoUrls = (files || []).map( - f => `${baseUrl}/uploads/packages/${id}/${f.filename}`, + const photoUrls = await Promise.all( + (files || []).map(f => + this.storage.saveFile( + `packages/${id}`, + f.originalname, + f.buffer, + f.mimetype, + ), + ), ); return this.svc.addPhotos(id, photoUrls, user.id); } diff --git a/apps/api/src/packages/packages.module.ts b/apps/api/src/packages/packages.module.ts index e2d9449..8e42dec 100644 --- a/apps/api/src/packages/packages.module.ts +++ b/apps/api/src/packages/packages.module.ts @@ -4,9 +4,10 @@ import { PackagesController } from "./packages.controller"; import { PrismaModule } from "../prisma/prisma.module"; import { ConfigModule } from "@nestjs/config"; import { NotificationsModule } from "../notifications/notifications.module"; +import { StorageModule } from "../storage/storage.module"; @Module({ - imports: [PrismaModule, ConfigModule, NotificationsModule], + imports: [PrismaModule, ConfigModule, NotificationsModule, StorageModule], providers: [PackagesService], controllers: [PackagesController], exports: [PackagesService], diff --git a/apps/api/src/pre-alerts/dto/pre-alert.dto.ts b/apps/api/src/pre-alerts/dto/pre-alert.dto.ts index e0bdb25..acb2065 100644 --- a/apps/api/src/pre-alerts/dto/pre-alert.dto.ts +++ b/apps/api/src/pre-alerts/dto/pre-alert.dto.ts @@ -15,6 +15,10 @@ export class CreatePreAlertDto { @IsOptional() @IsString() vendorTracking?: string; + + @IsOptional() + @IsString() + estimatedArrival?: string; } export class UpdatePreAlertStatusDto { diff --git a/apps/api/src/pre-alerts/pre-alerts.controller.ts b/apps/api/src/pre-alerts/pre-alerts.controller.ts index 5b4b1c4..b5668c6 100644 --- a/apps/api/src/pre-alerts/pre-alerts.controller.ts +++ b/apps/api/src/pre-alerts/pre-alerts.controller.ts @@ -1,13 +1,22 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from "@nestjs/common"; +import { + Controller, Get, Post, Patch, Delete, Body, Param, UseGuards, + UseInterceptors, UploadedFile, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { memoryStorage } from "multer"; import { PreAlertsService } from "./pre-alerts.service"; import { CreatePreAlertDto, UpdatePreAlertStatusDto } from "./dto/pre-alert.dto"; import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard"; import { CurrentUser } from "../auth/decorators/current-user.decorator"; +import { StorageService } from "../storage/storage.service"; @Controller("pre-alerts") @UseGuards(JwtAuthGuard, RolesGuard) export class PreAlertsController { - constructor(private svc: PreAlertsService) {} + constructor( + private svc: PreAlertsService, + private storage: StorageService, + ) {} @Get() findAll(@CurrentUser() user: any) { @@ -20,6 +29,34 @@ export class PreAlertsController { return this.svc.create(dto, user); } + /** Upload invoice PDF/image for a pre-alert (doc §07 / §09) */ + @Post(":id/invoice") + @Roles("CLIENTE") + @UseInterceptors( + FileInterceptor("invoice", { + storage: memoryStorage(), + fileFilter: (_req, file, cb) => { + const allowed = /pdf|jpg|jpeg|png|webp/; + const ext = file.originalname.split(".").pop()?.toLowerCase() ?? ""; + cb(null, allowed.test(ext)); + }, + limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB + }), + ) + async uploadInvoice( + @Param("id") id: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: any, + ): Promise { + const url = await this.storage.saveFile( + "invoices", + file.originalname, + file.buffer, + file.mimetype, + ); + return this.svc.attachInvoice(id, url, user); + } + @Patch(":id/status") @Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN") updateStatus(@Param("id") id: string, @Body() dto: UpdatePreAlertStatusDto): Promise { diff --git a/apps/api/src/pre-alerts/pre-alerts.module.ts b/apps/api/src/pre-alerts/pre-alerts.module.ts index 1064596..7b82dfd 100644 --- a/apps/api/src/pre-alerts/pre-alerts.module.ts +++ b/apps/api/src/pre-alerts/pre-alerts.module.ts @@ -1,6 +1,12 @@ import { Module } from "@nestjs/common"; import { PreAlertsController } from "./pre-alerts.controller"; import { PreAlertsService } from "./pre-alerts.service"; +import { StorageModule } from "../storage/storage.module"; +import { PrismaModule } from "../prisma/prisma.module"; -@Module({ controllers: [PreAlertsController], providers: [PreAlertsService] }) +@Module({ + imports: [PrismaModule, StorageModule], + controllers: [PreAlertsController], + providers: [PreAlertsService], +}) export class PreAlertsModule {} diff --git a/apps/api/src/pre-alerts/pre-alerts.service.ts b/apps/api/src/pre-alerts/pre-alerts.service.ts index e28f848..9deb564 100644 --- a/apps/api/src/pre-alerts/pre-alerts.service.ts +++ b/apps/api/src/pre-alerts/pre-alerts.service.ts @@ -19,17 +19,29 @@ export class PreAlertsService { async create(dto: CreatePreAlertDto, user: any): Promise { return this.prisma.client.preAlert.create({ data: { - tenantId: user.tenantId, - userId: user.id, - store: dto.store, - description: dto.description, - declaredValue: dto.declaredValue ?? 0, - vendorTracking: dto.vendorTracking, - status: "PENDIENTE", + tenantId: user.tenantId, + userId: user.id, + store: dto.store, + description: dto.description, + declaredValue: dto.declaredValue ?? 0, + vendorTracking: dto.vendorTracking, + estimatedArrival: dto.estimatedArrival ? new Date(dto.estimatedArrival) : undefined, + status: "PENDIENTE", }, }); } + /** Attach invoice URL to a pre-alert (doc §07/§09) */ + async attachInvoice(id: string, invoiceKey: string, user: any): Promise { + const alert = await this.prisma.client.preAlert.findUnique({ where: { id } }); + if (!alert) throw new NotFoundException("Pre-alerta no encontrada."); + if (user.role === "CLIENTE" && alert.userId !== user.id) throw new ForbiddenException(); + return this.prisma.client.preAlert.update({ + where: { id }, + data: { invoiceKey }, + }); + } + async updateStatus(id: string, dto: UpdatePreAlertStatusDto): Promise { const alert = await this.prisma.client.preAlert.findUnique({ where: { id } }); if (!alert) throw new NotFoundException("Pre-alerta no encontrada."); diff --git a/apps/api/src/storage/storage.service.ts b/apps/api/src/storage/storage.service.ts index 20f2c19..326ef5c 100644 --- a/apps/api/src/storage/storage.service.ts +++ b/apps/api/src/storage/storage.service.ts @@ -3,38 +3,121 @@ import { ConfigService } from "@nestjs/config"; import * as fs from "fs"; import * as path from "path"; import { randomUUID } from "crypto"; +import { S3Client, DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { GetObjectCommand } from "@aws-sdk/client-s3"; +/** + * StorageService — dual mode: + * • If S3_ENDPOINT is set → MinIO / S3-compatible storage + * • Otherwise → local disk under ./uploads/ (development) + */ @Injectable() export class StorageService { private readonly logger = new Logger(StorageService.name); private readonly uploadDir: string; private readonly baseUrl: string; + private readonly s3?: S3Client; + private readonly bucket?: string; + private readonly s3Public?: string; constructor(private config: ConfigService) { this.uploadDir = path.join(process.cwd(), "uploads"); - this.baseUrl = this.config.get("API_URL", "http://localhost:3001"); - // ensure uploads dir exists - if (!fs.existsSync(this.uploadDir)) { - fs.mkdirSync(this.uploadDir, { recursive: true }); + this.baseUrl = this.config.get("API_URL", "http://localhost:3001"); + + const endpoint = this.config.get("S3_ENDPOINT"); + const accessKey = this.config.get("S3_ACCESS_KEY"); + const secretKey = this.config.get("S3_SECRET_KEY"); + const region = this.config.get("S3_REGION", "us-east-1"); + const forcePath = this.config.get("S3_FORCE_PATH_STYLE", "false") === "true"; + this.bucket = this.config.get("S3_BUCKET", "moraworld"); + this.s3Public = this.config.get("S3_PUBLIC_URL") ?? endpoint; + + if (endpoint && accessKey && secretKey) { + this.s3 = new S3Client({ + endpoint, + region, + forcePathStyle: forcePath, + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + }); + this.logger.log(`[STORAGE] S3/MinIO mode — endpoint: ${endpoint}, bucket: ${this.bucket}`); + } else { + // Local disk fallback + if (!fs.existsSync(this.uploadDir)) { + fs.mkdirSync(this.uploadDir, { recursive: true }); + } + this.logger.log("[STORAGE] Local disk mode (no S3_ENDPOINT configured)"); } } - async saveFile(subdir: string, originalName: string, buffer: Buffer): Promise { - const ext = path.extname(originalName); + get isS3Mode(): boolean { return !!this.s3; } + + /** + * Store a file. Returns its public URL. + * @param subdir e.g. "packages/pkg-123" or "invoices" + * @param originalName e.g. "photo.jpg" + * @param buffer file contents + * @param mimeType e.g. "image/jpeg" + */ + async saveFile( + subdir: string, + originalName: string, + buffer: Buffer, + mimeType = "application/octet-stream", + ): Promise { + const ext = path.extname(originalName); const filename = `${randomUUID()}${ext}`; + const key = `${subdir}/${filename}`; + + if (this.s3) { + await this.s3.send(new PutObjectCommand({ + Bucket: this.bucket!, + Key: key, + Body: buffer, + ContentType: mimeType, + // ACL not used — presigned URLs or public endpoint handle access + })); + // Return public path (either bucket/key or via configured public URL) + return `${this.s3Public}/${this.bucket}/${key}`; + } + + // Local disk const dir = path.join(this.uploadDir, subdir); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - const filepath = path.join(dir, filename); - fs.writeFileSync(filepath, buffer); - // Return URL relative to API base - return `${this.baseUrl}/uploads/${subdir}/${filename}`; + fs.writeFileSync(path.join(dir, filename), buffer); + return `${this.baseUrl}/uploads/${key}`; } - async deleteFile(url: string): Promise { + /** + * Generate a presigned GET URL for an S3 key (valid 1 hour). + * Falls through to a direct URL if not in S3 mode. + */ + async presign(keyOrUrl: string, expiresIn = 3600): Promise { + if (!this.s3) return keyOrUrl; + // If a full URL was passed, extract the key + const key = keyOrUrl.includes(`/${this.bucket}/`) + ? keyOrUrl.split(`/${this.bucket}/`)[1] + : keyOrUrl; + return getSignedUrl( + this.s3, + new GetObjectCommand({ Bucket: this.bucket!, Key: key }), + { expiresIn }, + ); + } + + /** Delete a file by its URL or S3 key */ + async deleteFile(urlOrKey: string): Promise { try { - const relative = url.replace(/^https?:\/\/[^/]+\/uploads\//, ""); - const filepath = path.join(this.uploadDir, relative); - if (fs.existsSync(filepath)) fs.unlinkSync(filepath); + if (this.s3) { + const key = urlOrKey.includes(`/${this.bucket}/`) + ? urlOrKey.split(`/${this.bucket}/`)[1] + : urlOrKey; + await this.s3.send(new DeleteObjectCommand({ Bucket: this.bucket!, Key: key })); + } else { + const relative = urlOrKey.replace(/^https?:\/\/[^/]+\/uploads\//, ""); + const filepath = path.join(this.uploadDir, relative); + if (fs.existsSync(filepath)) fs.unlinkSync(filepath); + } } catch (e: unknown) { this.logger.warn(`deleteFile failed: ${(e as Error).message}`); } diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 0c5dc84..5a1be29 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -1,8 +1,8 @@ -import { Controller, Get, Patch, Body, Param, Query, UseGuards } from "@nestjs/common"; +import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from "@nestjs/common"; import { UsersService } from "./users.service"; import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard"; import { CurrentUser } from "../auth/decorators/current-user.decorator"; -import { IsEnum, IsBoolean } from "class-validator"; +import { IsEnum, IsBoolean, IsEmail, IsString, MinLength, IsOptional } from "class-validator"; class UpdateRoleDto { @IsEnum(["CLIENTE","OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN","SOPORTE"]) @@ -11,6 +11,15 @@ class UpdateRoleDto { class SetActiveDto { @IsBoolean() isActive!: boolean; } +class CreateUserDto { + @IsEmail() email!: string; + @IsString() @MinLength(8) password!: string; + @IsString() firstName!: string; + @IsString() lastName!: string; + @IsOptional() @IsString() phone?: string; + @IsEnum(["CLIENTE","OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN","SOPORTE"]) + role!: string; +} @Controller("users") @UseGuards(JwtAuthGuard, RolesGuard) @@ -29,6 +38,13 @@ export class UsersController { return this.svc.findOne(id); } + /** Admin creates a user directly (doc §12) */ + @Post() + @Roles("ADMIN_EMPRESA", "SUPER_ADMIN") + create(@Body() dto: CreateUserDto, @CurrentUser() user: any): Promise { + return this.svc.create(dto, user.tenantId); + } + @Patch(":id/role") @Roles("ADMIN_EMPRESA", "SUPER_ADMIN") updateRole(@Param("id") id: string, @Body() dto: UpdateRoleDto): Promise { diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts index 974df2b..951ed91 100644 --- a/apps/api/src/users/users.module.ts +++ b/apps/api/src/users/users.module.ts @@ -1,6 +1,12 @@ import { Module } from "@nestjs/common"; import { UsersController } from "./users.controller"; import { UsersService } from "./users.service"; +import { PrismaModule } from "../prisma/prisma.module"; -@Module({ controllers: [UsersController], providers: [UsersService] }) +@Module({ + imports: [PrismaModule], + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) export class UsersModule {} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 2d08e23..f11ad0a 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -1,5 +1,7 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { Injectable, NotFoundException, ConflictException } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; +import * as bcrypt from "bcrypt"; +import { generateSuiteCode } from "../common/utils/suite-code.util"; @Injectable() export class UsersService { @@ -39,6 +41,52 @@ export class UsersService { return user; } + /** Admin creates a user directly (no self-registration) */ + async create(dto: { + email: string; + password: string; + firstName: string; + lastName: string; + phone?: string; + role: string; + }, tenantId: string): Promise { + const exists = await this.prisma.client.user.findFirst({ + where: { email: dto.email, tenantId }, + }); + if (exists) throw new ConflictException("Ya existe un usuario con ese email."); + + const hash = await bcrypt.hash(dto.password, 12); + + // Generate suite code for CLIENTE role + let suiteCode: string | undefined; + if (dto.role === "CLIENTE") { + const count = await this.prisma.client.user.count({ where: { tenantId } }); + suiteCode = generateSuiteCode(count + 1); + } + + const user = await this.prisma.client.user.create({ + data: { + tenantId, + email: dto.email, + passwordHash: hash, + firstName: dto.firstName, + lastName: dto.lastName, + phone: dto.phone, + role: dto.role as any, + isActive: true, + }, + }); + + if (suiteCode) { + await this.prisma.client.suite.create({ + data: { tenantId, userId: user.id, code: suiteCode }, + }); + } + + const { passwordHash: _, ...safe } = user as any; + return safe; + } + async updateRole(id: string, role: string): Promise { return this.prisma.client.user.update({ where: { id }, data: { role: role as any } }); } diff --git a/apps/web/src/app/admin/usuarios/page.tsx b/apps/web/src/app/admin/usuarios/page.tsx index 3d86ca3..9ad4aca 100644 --- a/apps/web/src/app/admin/usuarios/page.tsx +++ b/apps/web/src/app/admin/usuarios/page.tsx @@ -4,13 +4,24 @@ import { api } from "@/lib/api"; const ROLES = ["CLIENTE","OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN","SOPORTE"]; +const BLANK_FORM = { email: "", password: "", firstName: "", lastName: "", phone: "", role: "CLIENTE" }; + export default function UsuariosPage() { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [search, setSearch] = useState(""); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); const [updating, setUpdating] = useState(null); - const load = (s?: string) => { setLoading(true); api.users.list(s).then(setUsers).catch(()=>{}).finally(()=>setLoading(false)); }; + // Create modal + const [showModal, setShowModal] = useState(false); + const [form, setForm] = useState({ ...BLANK_FORM }); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(""); + + const load = (s?: string) => { + setLoading(true); + api.users.list(s).then(setUsers).catch(() => {}).finally(() => setLoading(false)); + }; useEffect(() => { load(); }, []); const handleRoleChange = async (id: string, role: string) => { @@ -25,23 +36,62 @@ export default function UsuariosPage() { finally { setUpdating(null); } }; + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + setCreating(true); setCreateError(""); + try { + await api.users.create({ + email: form.email, + password: form.password, + firstName: form.firstName, + lastName: form.lastName, + phone: form.phone || undefined, + role: form.role, + }); + setShowModal(false); + setForm({ ...BLANK_FORM }); + load(); + } catch (err: any) { + setCreateError(err.message ?? "Error al crear usuario"); + } finally { setCreating(false); } + }; + + const setF = (k: string) => (e: React.ChangeEvent) => + setForm(f => ({ ...f, [k]: e.target.value })); + return (
-

Usuarios

Gestión de cuentas y roles.

+
+

Usuarios

+

Gestión de cuentas y roles.

+
- setSearch(e.target.value)} onKeyDown={e => e.key === "Enter" && load(search)} /> - + +
- {loading ?
: ( + {loading ? ( +
+ ) : users.length === 0 ? ( +
+ No se encontraron usuarios. +
+ ) : ( - + + + + + {users.map(u => ( @@ -55,7 +105,11 @@ export default function UsuariosPage() { {ROLES.map(r => )} - +
NombreEmailCasilleroRolEstadoAcciones
NombreEmailCasilleroRolEstadoAcciones
{u.isActive ? "Activo" : "Inactivo"} + + {u.isActive ? "Activo" : "Inactivo"} + + + +
+ {createError &&
{createError}
} +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + )} ); } diff --git a/apps/web/src/app/portal/mis-paquetes/page.tsx b/apps/web/src/app/portal/mis-paquetes/page.tsx index 011bef1..0ba4e53 100644 --- a/apps/web/src/app/portal/mis-paquetes/page.tsx +++ b/apps/web/src/app/portal/mis-paquetes/page.tsx @@ -37,10 +37,12 @@ const STATUS_BADGE: Record = { const PAYABLE_STATUSES = ["VERIFICADO", "DECLARACION_ADUANERA"]; export default function MisPaquetesPage() { - const [packages, setPackages] = useState([]); - const [loading, setLoading] = useState(true); - const [filter, setFilter] = useState(""); - const [selected, setSelected] = useState(null); + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState(""); + const [selected, setSelected] = useState(null); + const [detail, setDetail] = useState>({}); + const [loadingId, setLoadingId] = useState(null); useEffect(() => { api.packages.list() @@ -49,6 +51,20 @@ export default function MisPaquetesPage() { .finally(() => setLoading(false)); }, []); + // Expand: fetch full package (includes complete statusHistory) on first open + const handleExpand = async (p: any) => { + if (selected === p.id) { setSelected(null); return; } + setSelected(p.id); + if (detail[p.id]) return; // already loaded + setLoadingId(p.id); + try { + const full = await api.packages.get(p.id); + setDetail(d => ({ ...d, [p.id]: full })); + } catch { + setDetail(d => ({ ...d, [p.id]: p })); // fallback to list data + } finally { setLoadingId(null); } + }; + const filtered = packages.filter(p => !filter || p.trackingId?.toLowerCase().includes(filter.toLowerCase()) || @@ -87,7 +103,7 @@ export default function MisPaquetesPage() { ) : (
{filtered.map(p => ( -
setSelected(p === selected ? null : p)}> +
handleExpand(p)}>
{p.trackingId}
@@ -122,12 +138,14 @@ export default function MisPaquetesPage() {
{/* Detalle expandido */} - {selected?.id === p.id && ( + {selected === p.id && (
Historial de estados
- {p.statusHistory?.length ? ( + {loadingId === p.id ? ( +
+ ) : (detail[p.id]?.statusHistory ?? p.statusHistory)?.length ? (
- {p.statusHistory.map((h: any, i: number) => ( + {(detail[p.id]?.statusHistory ?? p.statusHistory).map((h: any, i: number) => (
diff --git a/apps/web/src/app/portal/page.tsx b/apps/web/src/app/portal/page.tsx index 4438a59..9148cbf 100644 --- a/apps/web/src/app/portal/page.tsx +++ b/apps/web/src/app/portal/page.tsx @@ -2,65 +2,96 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { api, getUser } from "@/lib/api"; +import { Timestamp } from "@/app/_components/timestamp"; +// §08 — 11 estados oficiales del ciclo de vida const STATUS_LABEL: Record = { - RECIBIDO_EN_NJ: "Recibido en NJ", - EN_PROCESO: "En proceso", - EN_CAMINO_A_ECUADOR: "En camino a Ecuador", - EN_ADUANA: "En aduana", - EN_BODEGA_EC: "En bodega EC", - LISTO_PARA_RETIRO: "Listo para retiro", + REGISTRADO: "Registrado", + EN_TRANSITO_BODEGA: "En tránsito a NJ", + RECIBIDO_BODEGA: "Recibido en NJ", + EN_VERIFICACION: "En verificación", + VERIFICADO: "Verificado", + DECLARACION_ADUANERA: "Declaración aduanera", + EN_TRANSITO_ECUADOR: "En tránsito a Ecuador", + EN_ADUANA_ECUADOR: "En aduana Ecuador", + LISTO_ENTREGA: "Listo para entrega", ENTREGADO: "Entregado", - RETENIDO_ADUANA: "Retenido en aduana", - DEVUELTO: "Devuelto", - PERDIDO: "Perdido", - CANCELADO: "Cancelado", + INCIDENCIA: "Incidencia", }; + const STATUS_BADGE: Record = { - RECIBIDO_EN_NJ: "badge-blue", EN_PROCESO: "badge-yellow", - EN_CAMINO_A_ECUADOR: "badge-orange", EN_ADUANA: "badge-yellow", - EN_BODEGA_EC: "badge-blue", LISTO_PARA_RETIRO: "badge-green", - ENTREGADO: "badge-green", RETENIDO_ADUANA: "badge-red", - DEVUELTO: "badge-red", PERDIDO: "badge-red", CANCELADO: "badge-gray", + REGISTRADO: "badge-gray", + EN_TRANSITO_BODEGA: "badge-yellow", + RECIBIDO_BODEGA: "badge-blue", + EN_VERIFICACION: "badge-yellow", + VERIFICADO: "badge-green", + DECLARACION_ADUANERA: "badge-blue", + EN_TRANSITO_ECUADOR: "badge-orange", + EN_ADUANA_ECUADOR: "badge-red", + LISTO_ENTREGA: "badge-green", + ENTREGADO: "badge-green", + INCIDENCIA: "badge-red", }; +const STATUS_COLOR: Record = { + 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", +}; + +// §08 ordered pipeline for progress bar +const STATUS_ORDER = [ + "REGISTRADO","EN_TRANSITO_BODEGA","RECIBIDO_BODEGA","EN_VERIFICACION", + "VERIFICADO","DECLARACION_ADUANERA","EN_TRANSITO_ECUADOR","EN_ADUANA_ECUADOR", + "LISTO_ENTREGA","ENTREGADO", +]; + export default function PortalDashboard() { const user = getUser(); - const [packages, setPackages] = useState([]); + const [packages, setPackages] = useState([]); const [notifications, setNotifications] = useState([]); - const [suite, setSuite] = useState(null); - const [loading, setLoading] = useState(true); + const [suite, setSuite] = useState(null); + const [preAlerts, setPreAlerts] = useState([]); + const [loading, setLoading] = useState(true); useEffect(() => { Promise.all([ api.packages.list(), api.auth.me(), - ]).then(([pkgs, me]) => { - setPackages(pkgs.slice(0, 5)); - setNotifications([]); + api.preAlerts.list().catch(() => []), + api.notifications.list(10).catch(() => []), + ]).then(([pkgs, me, alerts, notifs]) => { + setPackages(pkgs); setSuite(me.suite); + setPreAlerts(alerts); + setNotifications(notifs); }).catch(() => {}).finally(() => setLoading(false)); }, []); if (loading) return
; - const active = packages.filter(p => !["ENTREGADO","CANCELADO","DEVUELTO"].includes(p.status)).length; + const active = packages.filter(p => !["ENTREGADO","INCIDENCIA"].includes(p.status)).length; const delivered = packages.filter(p => p.status === "ENTREGADO").length; + const pending = preAlerts.filter(a => a.status === "PENDIENTE").length; + + // Last 3 active packages + const recent = packages.slice(0, 5); return (

Bienvenido, {user?.firstName} 👋

-

Gestiona tus envíos y tu casillero en NJ.

+

Gestiona tus envíos desde New Jersey hasta Ecuador.

- {/* Stats */} + {/* KPIs */}
{[ - { label: "Paquetes activos", value: active, color: "var(--primary)" }, - { label: "Entregados", value: delivered, color: "var(--green)" }, - { label: "Pre-alertas", value: "—", color: "var(--yellow)" }, - { label: "Mi casillero", value: suite?.code ?? "—", color: "var(--accent)" }, + { label: "Paquetes activos", value: active, color: "var(--primary)" }, + { label: "Entregados", value: delivered, color: "var(--green)" }, + { label: "Pre-alertas", value: pending, color: "var(--yellow)" }, + { label: "Mi casillero", value: suite?.code ?? "—", color: "var(--accent)" }, ].map(s => (
{s.value}
@@ -69,29 +100,63 @@ export default function PortalDashboard() { ))}
-
- {/* Últimos paquetes */} + {/* Suite address callout */} + {suite && ( +
+
📦
+
+
Tu dirección de envío en NJ
+
+ 150 N Day St, {suite.code}, City of Orange, NJ 07050, EE.UU. +
+
+ Ver casillero → +
+ )} + +
+ {/* Paquetes recientes con mini-barra de progreso */}
-
+
Paquetes recientes Ver todos →
- {packages.length === 0 ? ( -

Aún no tienes paquetes.

+ {recent.length === 0 ? ( +
+
📭
+

Aún no tienes paquetes.

+ + Registrar primera compra + +
) : ( -
- - - {packages.map(p => ( - - - - - - ))} - -
{p.trackingId}{p.description ?? "—"}{STATUS_LABEL[p.status] ?? p.status}
+
+ {recent.map((p, i) => { + const step = STATUS_ORDER.indexOf(p.status); + const pct = step >= 0 ? Math.round(((step + 1) / STATUS_ORDER.length) * 100) : 0; + return ( +
+
+
+ + {p.trackingId} + +
+ {p.description ?? "Sin descripción"} +
+
+ + {STATUS_LABEL[p.status] ?? p.status} + +
+ {/* Progress bar */} +
+
+
+
+ ); + })}
)}
@@ -99,19 +164,30 @@ export default function PortalDashboard() { {/* Notificaciones */}
-
- Notificaciones - {notifications.length} +
+ Notificaciones recientes + {notifications.length > 0 && {notifications.length}}
{notifications.length === 0 ? ( -

Sin notificaciones nuevas.

+
+ Sin notificaciones nuevas. +
) : (
- {notifications.map(n => ( -
-
{n.title}
-
{n.body}
+ {notifications.map((n, i) => ( +
+
+
+ {n.subject || n.body?.slice(0, 100)} +
+ + + +
+
+ {n.channel === "EMAIL" ? "✉️" : n.channel === "WHATSAPP" ? "💬" : "🔔"} {n.channel} +
))}
@@ -119,6 +195,22 @@ export default function PortalDashboard() {
+ + {/* Accesos rápidos */} +
+ {[ + { href: "/portal/pre-alerta", icon: "📄", label: "Pre-alerta", desc: "Avisa qué paquete esperas" }, + { href: "/portal/calculadora", icon: "💰", label: "Calculadora", desc: "Estima el costo de envío" }, + { href: "/portal/consolidacion", icon: "📦", label: "Consolidar", desc: "Agrupar paquetes" }, + { href: "/portal/perfil", icon: "👤", label: "Mi perfil", desc: "Datos y seguridad" }, + ].map(a => ( + +
{a.icon}
+
{a.label}
+
{a.desc}
+ + ))} +
); } diff --git a/apps/web/src/app/portal/pre-alerta/page.tsx b/apps/web/src/app/portal/pre-alerta/page.tsx index 6963f46..977eabb 100644 --- a/apps/web/src/app/portal/pre-alerta/page.tsx +++ b/apps/web/src/app/portal/pre-alerta/page.tsx @@ -63,16 +63,31 @@ export default function PreAlertaPage() { e.preventDefault(); setSubmitting(true); setError(""); setSuccess(""); try { - await api.preAlerts.create({ + const created = await api.preAlerts.create({ store: form.store, vendorTracking: form.vendorTracking || undefined, description: form.description, declaredValue: parseFloat(form.declaredValue), estimatedArrival: form.estimatedArrival || undefined, }); - setSuccess("✅ Pre-alerta registrada exitosamente."); + // Upload invoice file if the user selected one + if (invoice && created?.id) { + try { + await api.preAlerts.uploadInvoice(created.id, invoice); + } catch { + // Non-fatal: alert was created, just notify about the upload failure + setSuccess("✅ Pre-alerta registrada. No se pudo subir la factura, inténtalo de nuevo."); + setForm({ store: "", vendorTracking: "", description: "", declaredValue: "", estimatedArrival: "" }); + setUrlInput(""); setInvoice(null); + if (invoiceRef.current) invoiceRef.current.value = ""; + load(); + return; + } + } + setSuccess("✅ Pre-alerta registrada exitosamente." + (invoice ? " Factura adjuntada." : "")); setForm({ store: "", vendorTracking: "", description: "", declaredValue: "", estimatedArrival: "" }); setUrlInput(""); setInvoice(null); + if (invoiceRef.current) invoiceRef.current.value = ""; load(); } catch (err: any) { setError(err.message ?? "Error al registrar"); } finally { setSubmitting(false); } @@ -168,10 +183,10 @@ export default function PreAlertaPage() { onChange={e => setInvoice(e.target.files?.[0] ?? null)} style={{ fontSize: ".85rem" }} /> {invoice && ( -

📎 {invoice.name}

- )} +

📎 {invoice.name}

+ )}

- Próximamente: la factura se enviará automáticamente a la bodega. + Máx. 10 MB — PDF, JPG, PNG o WEBP.