diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 61a3d913c..33fb69a89 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -13,6 +13,11 @@ import { SubscriptionsModule } from './modules/subscriptions/subscriptions.modul import { MessagingModule } from './modules/messaging/messaging.module'; import { BillingModule } from './modules/billing/billing.module'; import { IntegrationsModule } from './modules/integrations/integrations.module'; +import { ReferralsModule } from './modules/referrals/referrals.module'; +import { CodiSpeiModule } from './modules/codi-spei/codi-spei.module'; +import { WidgetsModule } from './modules/widgets/widgets.module'; +import { InvoicesModule } from './modules/invoices/invoices.module'; +import { MarketplaceModule } from './modules/marketplace/marketplace.module'; @Module({ imports: [ @@ -54,6 +59,11 @@ import { IntegrationsModule } from './modules/integrations/integrations.module'; MessagingModule, BillingModule, IntegrationsModule, + ReferralsModule, + CodiSpeiModule, + WidgetsModule, + InvoicesModule, + MarketplaceModule, ], }) export class AppModule {} diff --git a/apps/backend/src/modules/codi-spei/codi-spei.controller.ts b/apps/backend/src/modules/codi-spei/codi-spei.controller.ts new file mode 100644 index 000000000..d1a1211f5 --- /dev/null +++ b/apps/backend/src/modules/codi-spei/codi-spei.controller.ts @@ -0,0 +1,111 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CodiSpeiService } from './codi-spei.service'; +import { GenerateQrDto } from './dto/generate-qr.dto'; + +@ApiTags('codi-spei') +@Controller('v1') +export class CodiSpeiController { + constructor(private readonly codiSpeiService: CodiSpeiService) {} + + // ==================== CODI ==================== + + @Post('codi/generate-qr') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Generar QR CoDi para cobro' }) + generateQr(@Request() req, @Body() dto: GenerateQrDto) { + return this.codiSpeiService.generateQr(req.user.tenantId, dto); + } + + @Get('codi/status/:id') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Obtener estado de transaccion CoDi' }) + getCodiStatus(@Param('id') id: string) { + return this.codiSpeiService.getCodiStatus(id); + } + + @Get('codi/transactions') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Listar transacciones CoDi' }) + @ApiQuery({ name: 'limit', required: false }) + getCodiTransactions(@Request() req, @Query('limit') limit?: number) { + return this.codiSpeiService.getCodiTransactions(req.user.tenantId, limit); + } + + @Post('codi/webhook') + @ApiOperation({ summary: 'Webhook para confirmacion CoDi' }) + async codiWebhook(@Body() payload: any) { + await this.codiSpeiService.handleCodiWebhook(payload); + return { success: true }; + } + + // ==================== SPEI ==================== + + @Get('spei/clabe') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Obtener CLABE virtual del tenant' }) + async getClabe(@Request() req) { + const account = await this.codiSpeiService.getVirtualAccount(req.user.tenantId); + if (!account) { + return { clabe: null, message: 'No tiene CLABE virtual configurada' }; + } + return { + clabe: account.clabe, + beneficiaryName: account.beneficiaryName, + status: account.status, + }; + } + + @Post('spei/create-clabe') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Crear CLABE virtual para el tenant' }) + createClabe(@Request() req, @Body() body: { beneficiaryName: string }) { + return this.codiSpeiService.createVirtualAccount( + req.user.tenantId, + body.beneficiaryName, + ); + } + + @Get('spei/transactions') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Listar transacciones SPEI recibidas' }) + @ApiQuery({ name: 'limit', required: false }) + getSpeiTransactions(@Request() req, @Query('limit') limit?: number) { + return this.codiSpeiService.getSpeiTransactions(req.user.tenantId, limit); + } + + @Post('spei/webhook') + @ApiOperation({ summary: 'Webhook para notificacion SPEI' }) + async speiWebhook(@Body() payload: any) { + await this.codiSpeiService.handleSpeiWebhook(payload.clabe, payload); + return { success: true }; + } + + // ==================== SUMMARY ==================== + + @Get('payments/summary') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Resumen de pagos CoDi/SPEI del dia' }) + @ApiQuery({ name: 'date', required: false }) + async getSummary(@Request() req, @Query('date') date?: string) { + const targetDate = date ? new Date(date) : undefined; + return this.codiSpeiService.getSummary(req.user.tenantId, targetDate); + } +} diff --git a/apps/backend/src/modules/codi-spei/codi-spei.module.ts b/apps/backend/src/modules/codi-spei/codi-spei.module.ts new file mode 100644 index 000000000..a301490e8 --- /dev/null +++ b/apps/backend/src/modules/codi-spei/codi-spei.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CodiSpeiController } from './codi-spei.controller'; +import { CodiSpeiService } from './codi-spei.service'; +import { VirtualAccount } from './entities/virtual-account.entity'; +import { CodiTransaction } from './entities/codi-transaction.entity'; +import { SpeiTransaction } from './entities/spei-transaction.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([VirtualAccount, CodiTransaction, SpeiTransaction]), + ], + controllers: [CodiSpeiController], + providers: [CodiSpeiService], + exports: [CodiSpeiService], +}) +export class CodiSpeiModule {} diff --git a/apps/backend/src/modules/codi-spei/codi-spei.service.ts b/apps/backend/src/modules/codi-spei/codi-spei.service.ts new file mode 100644 index 000000000..4c8f570a8 --- /dev/null +++ b/apps/backend/src/modules/codi-spei/codi-spei.service.ts @@ -0,0 +1,263 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource, LessThan } from 'typeorm'; +import { VirtualAccount, VirtualAccountStatus } from './entities/virtual-account.entity'; +import { CodiTransaction, CodiTransactionStatus } from './entities/codi-transaction.entity'; +import { SpeiTransaction, SpeiTransactionStatus } from './entities/spei-transaction.entity'; +import { GenerateQrDto } from './dto/generate-qr.dto'; + +@Injectable() +export class CodiSpeiService { + constructor( + @InjectRepository(VirtualAccount) + private readonly virtualAccountRepo: Repository, + @InjectRepository(CodiTransaction) + private readonly codiRepo: Repository, + @InjectRepository(SpeiTransaction) + private readonly speiRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + // ==================== VIRTUAL ACCOUNTS (CLABE) ==================== + + async getVirtualAccount(tenantId: string): Promise { + return this.virtualAccountRepo.findOne({ + where: { tenantId, status: VirtualAccountStatus.ACTIVE }, + }); + } + + async createVirtualAccount( + tenantId: string, + beneficiaryName: string, + provider: string = 'stp', + ): Promise { + // Check if already has one + const existing = await this.getVirtualAccount(tenantId); + if (existing) { + return existing; + } + + // In production, this would call the provider API to create a CLABE + // For now, generate a mock CLABE + const mockClabe = `646180${Math.floor(Math.random() * 1000000000000).toString().padStart(12, '0')}`; + + const account = this.virtualAccountRepo.create({ + tenantId, + provider, + clabe: mockClabe, + beneficiaryName, + status: VirtualAccountStatus.ACTIVE, + }); + + return this.virtualAccountRepo.save(account); + } + + // ==================== CODI ==================== + + async generateQr(tenantId: string, dto: GenerateQrDto): Promise { + // Generate unique reference + const result = await this.dataSource.query( + `SELECT generate_codi_reference($1) as reference`, + [tenantId], + ); + const reference = result[0].reference; + + // Set expiry (5 minutes) + const expiresAt = new Date(); + expiresAt.setMinutes(expiresAt.getMinutes() + 5); + + // In production, this would call Banxico/PAC API to generate real CoDi QR + // For now, generate mock QR data + const qrData = JSON.stringify({ + type: 'codi', + amount: dto.amount, + reference, + merchant: tenantId, + expires: expiresAt.toISOString(), + }); + + const transaction = this.codiRepo.create({ + tenantId, + saleId: dto.saleId, + qrData, + amount: dto.amount, + reference, + description: dto.description || `Cobro ${reference}`, + status: CodiTransactionStatus.PENDING, + expiresAt, + }); + + return this.codiRepo.save(transaction); + } + + async getCodiStatus(id: string): Promise { + const transaction = await this.codiRepo.findOne({ where: { id } }); + if (!transaction) { + throw new NotFoundException('Transaccion CoDi no encontrada'); + } + + // Check if expired + if ( + transaction.status === CodiTransactionStatus.PENDING && + new Date() > transaction.expiresAt + ) { + transaction.status = CodiTransactionStatus.EXPIRED; + await this.codiRepo.save(transaction); + } + + return transaction; + } + + async confirmCodi(id: string, providerData: any): Promise { + const transaction = await this.getCodiStatus(id); + + if (transaction.status !== CodiTransactionStatus.PENDING) { + throw new BadRequestException(`Transaccion no esta pendiente: ${transaction.status}`); + } + + transaction.status = CodiTransactionStatus.CONFIRMED; + transaction.confirmedAt = new Date(); + transaction.providerResponse = providerData; + + return this.codiRepo.save(transaction); + } + + async getCodiTransactions( + tenantId: string, + limit = 50, + ): Promise { + return this.codiRepo.find({ + where: { tenantId }, + order: { createdAt: 'DESC' }, + take: limit, + }); + } + + // ==================== SPEI ==================== + + async getSpeiTransactions( + tenantId: string, + limit = 50, + ): Promise { + return this.speiRepo.find({ + where: { tenantId }, + order: { receivedAt: 'DESC' }, + take: limit, + }); + } + + async receiveSpei( + tenantId: string, + data: { + amount: number; + senderClabe?: string; + senderName?: string; + senderRfc?: string; + senderBank?: string; + reference?: string; + trackingKey?: string; + providerData?: any; + }, + ): Promise { + const account = await this.getVirtualAccount(tenantId); + + const transaction = this.speiRepo.create({ + tenantId, + virtualAccountId: account?.id, + amount: data.amount, + senderClabe: data.senderClabe, + senderName: data.senderName, + senderRfc: data.senderRfc, + senderBank: data.senderBank, + reference: data.reference, + trackingKey: data.trackingKey, + status: SpeiTransactionStatus.RECEIVED, + receivedAt: new Date(), + providerData: data.providerData, + }); + + return this.speiRepo.save(transaction); + } + + async reconcileSpei(id: string, saleId: string): Promise { + const transaction = await this.speiRepo.findOne({ where: { id } }); + if (!transaction) { + throw new NotFoundException('Transaccion SPEI no encontrada'); + } + + transaction.saleId = saleId; + transaction.status = SpeiTransactionStatus.RECONCILED; + transaction.reconciledAt = new Date(); + + return this.speiRepo.save(transaction); + } + + // ==================== STATS ==================== + + async getSummary(tenantId: string, date?: Date) { + const targetDate = date || new Date(); + const dateStr = targetDate.toISOString().split('T')[0]; + + const result = await this.dataSource.query( + `SELECT * FROM get_codi_spei_summary($1, $2::date)`, + [tenantId, dateStr], + ); + + return result[0] || { + codi_count: 0, + codi_total: 0, + spei_count: 0, + spei_total: 0, + }; + } + + // ==================== WEBHOOKS ==================== + + async handleCodiWebhook(payload: any): Promise { + // In production, validate webhook signature + // Find transaction by reference and confirm + const { reference, status, transactionId } = payload; + + const transaction = await this.codiRepo.findOne({ + where: { reference }, + }); + + if (!transaction) { + throw new NotFoundException('Transaccion no encontrada'); + } + + if (status === 'confirmed') { + await this.confirmCodi(transaction.id, { + providerTransactionId: transactionId, + ...payload, + }); + } + } + + async handleSpeiWebhook(clabe: string, payload: any): Promise { + // Find virtual account by CLABE + const account = await this.virtualAccountRepo.findOne({ + where: { clabe }, + }); + + if (!account) { + throw new NotFoundException('Cuenta virtual no encontrada'); + } + + // Record incoming SPEI + await this.receiveSpei(account.tenantId, { + amount: payload.amount, + senderClabe: payload.senderClabe, + senderName: payload.senderName, + senderRfc: payload.senderRfc, + senderBank: payload.senderBank, + reference: payload.reference, + trackingKey: payload.trackingKey, + providerData: payload, + }); + } +} diff --git a/apps/backend/src/modules/codi-spei/dto/generate-qr.dto.ts b/apps/backend/src/modules/codi-spei/dto/generate-qr.dto.ts new file mode 100644 index 000000000..5c1d1b61f --- /dev/null +++ b/apps/backend/src/modules/codi-spei/dto/generate-qr.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsString, IsOptional, Min, Max } from 'class-validator'; + +export class GenerateQrDto { + @ApiProperty({ + example: 150.5, + description: 'Monto a cobrar', + }) + @IsNumber() + @Min(1) + @Max(8000) // CoDi max limit + amount: number; + + @ApiProperty({ + example: 'Venta #123', + description: 'Descripcion del cobro', + required: false, + }) + @IsString() + @IsOptional() + description?: string; + + @ApiProperty({ + example: 'sale-uuid', + description: 'ID de la venta asociada', + required: false, + }) + @IsString() + @IsOptional() + saleId?: string; +} diff --git a/apps/backend/src/modules/codi-spei/entities/codi-transaction.entity.ts b/apps/backend/src/modules/codi-spei/entities/codi-transaction.entity.ts new file mode 100644 index 000000000..62b65ddf7 --- /dev/null +++ b/apps/backend/src/modules/codi-spei/entities/codi-transaction.entity.ts @@ -0,0 +1,66 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum CodiTransactionStatus { + PENDING = 'pending', + CONFIRMED = 'confirmed', + EXPIRED = 'expired', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'sales', name: 'codi_transactions' }) +export class CodiTransaction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id' }) + tenantId: string; + + @Column({ name: 'sale_id', nullable: true }) + saleId: string; + + @Column({ name: 'qr_data', type: 'text' }) + qrData: string; + + @Column({ name: 'qr_image_url', type: 'text', nullable: true }) + qrImageUrl: string; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + amount: number; + + @Column({ length: 50, nullable: true }) + reference: string; + + @Column({ length: 200, nullable: true }) + description: string; + + @Column({ + type: 'varchar', + length: 20, + default: CodiTransactionStatus.PENDING, + }) + status: CodiTransactionStatus; + + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt: Date; + + @Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true }) + confirmedAt: Date; + + @Column({ name: 'provider_transaction_id', length: 100, nullable: true }) + providerTransactionId: string; + + @Column({ name: 'provider_response', type: 'jsonb', nullable: true }) + providerResponse: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/modules/codi-spei/entities/spei-transaction.entity.ts b/apps/backend/src/modules/codi-spei/entities/spei-transaction.entity.ts new file mode 100644 index 000000000..bf01856f2 --- /dev/null +++ b/apps/backend/src/modules/codi-spei/entities/spei-transaction.entity.ts @@ -0,0 +1,82 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { VirtualAccount } from './virtual-account.entity'; + +export enum SpeiTransactionStatus { + RECEIVED = 'received', + RECONCILED = 'reconciled', + DISPUTED = 'disputed', +} + +@Entity({ schema: 'sales', name: 'spei_transactions' }) +export class SpeiTransaction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id' }) + tenantId: string; + + @Column({ name: 'virtual_account_id', nullable: true }) + virtualAccountId: string; + + @Column({ name: 'sale_id', nullable: true }) + saleId: string; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + amount: number; + + @Column({ name: 'sender_clabe', length: 18, nullable: true }) + senderClabe: string; + + @Column({ name: 'sender_name', length: 100, nullable: true }) + senderName: string; + + @Column({ name: 'sender_rfc', length: 13, nullable: true }) + senderRfc: string; + + @Column({ name: 'sender_bank', length: 50, nullable: true }) + senderBank: string; + + @Column({ length: 50, nullable: true }) + reference: string; + + @Column({ length: 200, nullable: true }) + description: string; + + @Column({ name: 'tracking_key', length: 50, nullable: true }) + trackingKey: string; + + @Column({ + type: 'varchar', + length: 20, + default: SpeiTransactionStatus.RECEIVED, + }) + status: SpeiTransactionStatus; + + @Column({ name: 'received_at', type: 'timestamptz' }) + receivedAt: Date; + + @Column({ name: 'reconciled_at', type: 'timestamptz', nullable: true }) + reconciledAt: Date; + + @Column({ name: 'provider_data', type: 'jsonb', nullable: true }) + providerData: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => VirtualAccount) + @JoinColumn({ name: 'virtual_account_id' }) + virtualAccount: VirtualAccount; +} diff --git a/apps/backend/src/modules/codi-spei/entities/virtual-account.entity.ts b/apps/backend/src/modules/codi-spei/entities/virtual-account.entity.ts new file mode 100644 index 000000000..4b215aff5 --- /dev/null +++ b/apps/backend/src/modules/codi-spei/entities/virtual-account.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum VirtualAccountStatus { + ACTIVE = 'active', + SUSPENDED = 'suspended', + CLOSED = 'closed', +} + +@Entity({ schema: 'sales', name: 'virtual_accounts' }) +export class VirtualAccount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id' }) + tenantId: string; + + @Column({ length: 20, default: 'stp' }) + provider: string; + + @Column({ length: 18, unique: true }) + clabe: string; + + @Column({ name: 'beneficiary_name', length: 100, nullable: true }) + beneficiaryName: string; + + @Column({ + type: 'varchar', + length: 20, + default: VirtualAccountStatus.ACTIVE, + }) + status: VirtualAccountStatus; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/modules/invoices/dto/create-invoice.dto.ts b/apps/backend/src/modules/invoices/dto/create-invoice.dto.ts new file mode 100644 index 000000000..3c01de0ef --- /dev/null +++ b/apps/backend/src/modules/invoices/dto/create-invoice.dto.ts @@ -0,0 +1,120 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsNumber, + IsOptional, + IsArray, + ValidateNested, + Length, + IsEmail, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class InvoiceItemDto { + @ApiProperty({ example: '50202301', description: 'Clave producto/servicio SAT' }) + @IsString() + @Length(8, 8) + claveProdServ: string; + + @ApiProperty({ example: 'Coca-Cola 600ml', description: 'Descripcion del producto' }) + @IsString() + descripcion: string; + + @ApiProperty({ example: 2, description: 'Cantidad' }) + @IsNumber() + @Min(0.000001) + cantidad: number; + + @ApiProperty({ example: 'H87', description: 'Clave unidad SAT' }) + @IsString() + @Length(2, 3) + claveUnidad: string; + + @ApiProperty({ example: 'Pieza', description: 'Descripcion de la unidad', required: false }) + @IsString() + @IsOptional() + unidad?: string; + + @ApiProperty({ example: 18.00, description: 'Valor unitario' }) + @IsNumber() + @Min(0) + valorUnitario: number; + + @ApiProperty({ example: 0, description: 'Descuento', required: false }) + @IsNumber() + @IsOptional() + descuento?: number; + + @ApiProperty({ example: 'product-uuid', description: 'ID del producto', required: false }) + @IsString() + @IsOptional() + productId?: string; +} + +export class CreateInvoiceDto { + @ApiProperty({ example: 'sale-uuid', description: 'ID de la venta asociada', required: false }) + @IsString() + @IsOptional() + saleId?: string; + + // Receptor + @ApiProperty({ example: 'XAXX010101000', description: 'RFC del receptor' }) + @IsString() + @Length(12, 13) + receptorRfc: string; + + @ApiProperty({ example: 'Juan Perez', description: 'Nombre o razon social' }) + @IsString() + receptorNombre: string; + + @ApiProperty({ example: '601', description: 'Regimen fiscal del receptor', required: false }) + @IsString() + @IsOptional() + @Length(3, 3) + receptorRegimenFiscal?: string; + + @ApiProperty({ example: '06600', description: 'Codigo postal del receptor' }) + @IsString() + @Length(5, 5) + receptorCodigoPostal: string; + + @ApiProperty({ example: 'G03', description: 'Uso del CFDI' }) + @IsString() + @Length(3, 4) + receptorUsoCfdi: string; + + @ApiProperty({ example: 'cliente@email.com', description: 'Email para envio', required: false }) + @IsEmail() + @IsOptional() + receptorEmail?: string; + + // Pago + @ApiProperty({ example: '01', description: 'Forma de pago SAT (01=Efectivo, 04=Tarjeta)' }) + @IsString() + @Length(2, 2) + formaPago: string; + + @ApiProperty({ example: 'PUE', description: 'Metodo de pago (PUE=Una sola exhibicion)' }) + @IsString() + @Length(3, 3) + metodoPago: string; + + @ApiProperty({ example: 'Contado', description: 'Condiciones de pago', required: false }) + @IsString() + @IsOptional() + condicionesPago?: string; + + // Items + @ApiProperty({ type: [InvoiceItemDto], description: 'Conceptos de la factura' }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => InvoiceItemDto) + items: InvoiceItemDto[]; + + // Opcional + @ApiProperty({ description: 'Notas adicionales', required: false }) + @IsString() + @IsOptional() + notes?: string; +} diff --git a/apps/backend/src/modules/invoices/entities/invoice-item.entity.ts b/apps/backend/src/modules/invoices/entities/invoice-item.entity.ts new file mode 100644 index 000000000..f5de32c6d --- /dev/null +++ b/apps/backend/src/modules/invoices/entities/invoice-item.entity.ts @@ -0,0 +1,64 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Invoice } from './invoice.entity'; + +@Entity({ schema: 'billing', name: 'invoice_items' }) +export class InvoiceItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'invoice_id' }) + invoiceId: string; + + @Column({ name: 'product_id', nullable: true }) + productId: string; + + // Clave SAT + @Column({ name: 'clave_prod_serv', length: 8 }) + claveProdServ: string; + + @Column({ name: 'no_identificacion', length: 100, nullable: true }) + noIdentificacion: string; + + // Descripcion + @Column({ length: 1000 }) + descripcion: string; + + // Cantidad + @Column({ type: 'decimal', precision: 12, scale: 6 }) + cantidad: number; + + @Column({ name: 'clave_unidad', length: 3 }) + claveUnidad: string; + + @Column({ length: 20, nullable: true }) + unidad: string; + + // Precios + @Column({ name: 'valor_unitario', type: 'decimal', precision: 12, scale: 6 }) + valorUnitario: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + descuento: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + importe: number; + + // Objeto de impuesto + @Column({ name: 'objeto_imp', length: 2, default: '02' }) + objetoImp: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Invoice, (invoice) => invoice.items) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; +} diff --git a/apps/backend/src/modules/invoices/entities/invoice.entity.ts b/apps/backend/src/modules/invoices/entities/invoice.entity.ts new file mode 100644 index 000000000..7b06a5fd6 --- /dev/null +++ b/apps/backend/src/modules/invoices/entities/invoice.entity.ts @@ -0,0 +1,152 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; +import { InvoiceItem } from './invoice-item.entity'; + +export enum InvoiceType { + INGRESO = 'I', + EGRESO = 'E', + TRASLADO = 'T', + PAGO = 'P', + NOMINA = 'N', +} + +export enum InvoiceStatus { + DRAFT = 'draft', + PENDING = 'pending', + STAMPED = 'stamped', + SENT = 'sent', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'billing', name: 'invoices' }) +export class Invoice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id' }) + tenantId: string; + + @Column({ name: 'sale_id', nullable: true }) + saleId: string; + + // Tipo + @Column({ name: 'tipo_comprobante', length: 1, default: InvoiceType.INGRESO }) + tipoComprobante: InvoiceType; + + // Folio fiscal + @Column({ length: 36, unique: true, nullable: true }) + uuid: string; + + @Column({ length: 10, nullable: true }) + serie: string; + + @Column({ nullable: true }) + folio: number; + + // Receptor + @Column({ name: 'receptor_rfc', length: 13 }) + receptorRfc: string; + + @Column({ name: 'receptor_nombre', length: 200 }) + receptorNombre: string; + + @Column({ name: 'receptor_regimen_fiscal', length: 3, nullable: true }) + receptorRegimenFiscal: string; + + @Column({ name: 'receptor_codigo_postal', length: 5 }) + receptorCodigoPostal: string; + + @Column({ name: 'receptor_uso_cfdi', length: 4 }) + receptorUsoCfdi: string; + + @Column({ name: 'receptor_email', length: 200, nullable: true }) + receptorEmail: string; + + // Montos + @Column({ type: 'decimal', precision: 12, scale: 2 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + descuento: number; + + @Column({ name: 'total_impuestos_trasladados', type: 'decimal', precision: 12, scale: 2, default: 0 }) + totalImpuestosTrasladados: number; + + @Column({ name: 'total_impuestos_retenidos', type: 'decimal', precision: 12, scale: 2, default: 0 }) + totalImpuestosRetenidos: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + total: number; + + // Pago + @Column({ name: 'forma_pago', length: 2 }) + formaPago: string; + + @Column({ name: 'metodo_pago', length: 3 }) + metodoPago: string; + + @Column({ name: 'condiciones_pago', length: 100, nullable: true }) + condicionesPago: string; + + // Moneda + @Column({ length: 3, default: 'MXN' }) + moneda: string; + + @Column({ name: 'tipo_cambio', type: 'decimal', precision: 10, scale: 6, default: 1 }) + tipoCambio: number; + + // Archivos + @Column({ name: 'xml_url', type: 'text', nullable: true }) + xmlUrl: string; + + @Column({ name: 'pdf_url', type: 'text', nullable: true }) + pdfUrl: string; + + @Column({ name: 'qr_url', type: 'text', nullable: true }) + qrUrl: string; + + // Estado + @Column({ + type: 'varchar', + length: 20, + default: InvoiceStatus.DRAFT, + }) + status: InvoiceStatus; + + // Cancelacion + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt: Date; + + @Column({ name: 'cancel_reason', length: 2, nullable: true }) + cancelReason: string; + + @Column({ name: 'cancel_uuid_replacement', length: 36, nullable: true }) + cancelUuidReplacement: string; + + // Timbrado + @Column({ name: 'stamped_at', type: 'timestamptz', nullable: true }) + stampedAt: Date; + + @Column({ name: 'pac_response', type: 'jsonb', nullable: true }) + pacResponse: Record; + + // Metadata + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @OneToMany(() => InvoiceItem, (item) => item.invoice) + items: InvoiceItem[]; +} diff --git a/apps/backend/src/modules/invoices/entities/tax-config.entity.ts b/apps/backend/src/modules/invoices/entities/tax-config.entity.ts new file mode 100644 index 000000000..1cba89751 --- /dev/null +++ b/apps/backend/src/modules/invoices/entities/tax-config.entity.ts @@ -0,0 +1,87 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum TaxConfigStatus { + PENDING = 'pending', + ACTIVE = 'active', + SUSPENDED = 'suspended', +} + +@Entity({ schema: 'billing', name: 'tax_configs' }) +export class TaxConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', unique: true }) + tenantId: string; + + // Datos fiscales + @Column({ length: 13 }) + rfc: string; + + @Column({ name: 'razon_social', length: 200 }) + razonSocial: string; + + @Column({ name: 'regimen_fiscal', length: 3 }) + regimenFiscal: string; + + @Column({ name: 'codigo_postal', length: 5 }) + codigoPostal: string; + + // CSD (encrypted fields) + @Column({ name: 'csd_certificate', type: 'text', nullable: true }) + csdCertificate: string; + + @Column({ name: 'csd_private_key_encrypted', type: 'text', nullable: true }) + csdPrivateKeyEncrypted: string; + + @Column({ name: 'csd_password_encrypted', type: 'text', nullable: true }) + csdPasswordEncrypted: string; + + @Column({ name: 'csd_valid_from', type: 'timestamptz', nullable: true }) + csdValidFrom: Date; + + @Column({ name: 'csd_valid_to', type: 'timestamptz', nullable: true }) + csdValidTo: Date; + + // PAC + @Column({ name: 'pac_provider', length: 20, default: 'facturapi' }) + pacProvider: string; + + @Column({ name: 'pac_api_key_encrypted', type: 'text', nullable: true }) + pacApiKeyEncrypted: string; + + @Column({ name: 'pac_sandbox', default: true }) + pacSandbox: boolean; + + // Configuracion + @Column({ length: 10, default: 'A' }) + serie: string; + + @Column({ name: 'folio_actual', default: 1 }) + folioActual: number; + + @Column({ name: 'auto_send_email', default: true }) + autoSendEmail: boolean; + + @Column({ name: 'logo_url', type: 'text', nullable: true }) + logoUrl: string; + + @Column({ + type: 'varchar', + length: 20, + default: TaxConfigStatus.PENDING, + }) + status: TaxConfigStatus; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/modules/invoices/invoices.controller.ts b/apps/backend/src/modules/invoices/invoices.controller.ts new file mode 100644 index 000000000..370e2967b --- /dev/null +++ b/apps/backend/src/modules/invoices/invoices.controller.ts @@ -0,0 +1,117 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + Request, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { InvoicesService } from './invoices.service'; +import { CreateInvoiceDto } from './dto/create-invoice.dto'; +import { TaxConfig } from './entities/tax-config.entity'; +import { InvoiceStatus } from './entities/invoice.entity'; + +@ApiTags('Invoices') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('invoices') +export class InvoicesController { + constructor(private readonly invoicesService: InvoicesService) {} + + // ==================== TAX CONFIG ==================== + + @Get('tax-config') + @ApiOperation({ summary: 'Obtener configuracion fiscal del tenant' }) + async getTaxConfig(@Request() req): Promise { + return this.invoicesService.getTaxConfig(req.user.tenantId); + } + + @Post('tax-config') + @ApiOperation({ summary: 'Guardar/actualizar configuracion fiscal' }) + async saveTaxConfig( + @Request() req, + @Body() data: Partial, + ): Promise { + return this.invoicesService.saveTaxConfig(req.user.tenantId, data); + } + + // ==================== INVOICES ==================== + + @Post() + @ApiOperation({ summary: 'Crear nueva factura' }) + async createInvoice( + @Request() req, + @Body() dto: CreateInvoiceDto, + ) { + return this.invoicesService.createInvoice(req.user.tenantId, dto); + } + + @Get() + @ApiOperation({ summary: 'Listar facturas del tenant' }) + @ApiQuery({ name: 'status', required: false, enum: InvoiceStatus }) + @ApiQuery({ name: 'from', required: false, type: String }) + @ApiQuery({ name: 'to', required: false, type: String }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getInvoices( + @Request() req, + @Query('status') status?: InvoiceStatus, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('limit') limit?: number, + ) { + return this.invoicesService.getInvoices(req.user.tenantId, { + status, + from: from ? new Date(from) : undefined, + to: to ? new Date(to) : undefined, + limit: limit ? Number(limit) : undefined, + }); + } + + @Get('summary') + @ApiOperation({ summary: 'Obtener resumen de facturacion del mes' }) + @ApiQuery({ name: 'month', required: false, type: String, description: 'YYYY-MM-DD' }) + async getSummary( + @Request() req, + @Query('month') month?: string, + ) { + return this.invoicesService.getSummary( + req.user.tenantId, + month ? new Date(month) : undefined, + ); + } + + @Get(':id') + @ApiOperation({ summary: 'Obtener factura por ID' }) + async getInvoice(@Param('id', ParseUUIDPipe) id: string) { + return this.invoicesService.getInvoice(id); + } + + @Post(':id/stamp') + @ApiOperation({ summary: 'Timbrar factura (enviar al SAT)' }) + async stampInvoice(@Param('id', ParseUUIDPipe) id: string) { + return this.invoicesService.stampInvoice(id); + } + + @Post(':id/cancel') + @ApiOperation({ summary: 'Cancelar factura' }) + async cancelInvoice( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { reason: string; uuidReplacement?: string }, + ) { + return this.invoicesService.cancelInvoice(id, body.reason, body.uuidReplacement); + } + + @Post(':id/send') + @ApiOperation({ summary: 'Enviar factura por email' }) + async sendInvoice( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { email?: string }, + ) { + return this.invoicesService.sendInvoice(id, body.email); + } +} diff --git a/apps/backend/src/modules/invoices/invoices.module.ts b/apps/backend/src/modules/invoices/invoices.module.ts new file mode 100644 index 000000000..448571a01 --- /dev/null +++ b/apps/backend/src/modules/invoices/invoices.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TaxConfig } from './entities/tax-config.entity'; +import { Invoice } from './entities/invoice.entity'; +import { InvoiceItem } from './entities/invoice-item.entity'; +import { InvoicesService } from './invoices.service'; +import { InvoicesController } from './invoices.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([TaxConfig, Invoice, InvoiceItem]), + ], + controllers: [InvoicesController], + providers: [InvoicesService], + exports: [InvoicesService], +}) +export class InvoicesModule {} diff --git a/apps/backend/src/modules/invoices/invoices.service.ts b/apps/backend/src/modules/invoices/invoices.service.ts new file mode 100644 index 000000000..4a38b0550 --- /dev/null +++ b/apps/backend/src/modules/invoices/invoices.service.ts @@ -0,0 +1,252 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { TaxConfig, TaxConfigStatus } from './entities/tax-config.entity'; +import { Invoice, InvoiceStatus, InvoiceType } from './entities/invoice.entity'; +import { InvoiceItem } from './entities/invoice-item.entity'; +import { CreateInvoiceDto } from './dto/create-invoice.dto'; + +@Injectable() +export class InvoicesService { + constructor( + @InjectRepository(TaxConfig) + private readonly taxConfigRepo: Repository, + @InjectRepository(Invoice) + private readonly invoiceRepo: Repository, + @InjectRepository(InvoiceItem) + private readonly itemRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + // ==================== TAX CONFIG ==================== + + async getTaxConfig(tenantId: string): Promise { + return this.taxConfigRepo.findOne({ where: { tenantId } }); + } + + async saveTaxConfig( + tenantId: string, + data: Partial, + ): Promise { + let config = await this.getTaxConfig(tenantId); + + if (config) { + Object.assign(config, data); + } else { + config = this.taxConfigRepo.create({ tenantId, ...data }); + } + + return this.taxConfigRepo.save(config); + } + + // ==================== INVOICES ==================== + + async createInvoice(tenantId: string, dto: CreateInvoiceDto): Promise { + const taxConfig = await this.getTaxConfig(tenantId); + if (!taxConfig || taxConfig.status !== TaxConfigStatus.ACTIVE) { + throw new BadRequestException('Configuracion fiscal no activa'); + } + + // Calculate totals + let subtotal = 0; + let totalIva = 0; + + for (const item of dto.items) { + const importe = item.cantidad * item.valorUnitario - (item.descuento || 0); + subtotal += importe; + // IVA 16% + totalIva += importe * 0.16; + } + + const total = subtotal + totalIva; + + // Get next folio + const folioResult = await this.dataSource.query( + `SELECT get_next_invoice_folio($1, $2) as folio`, + [tenantId, taxConfig.serie], + ); + const folio = folioResult[0].folio; + + // Create invoice + const invoice = this.invoiceRepo.create({ + tenantId, + saleId: dto.saleId, + tipoComprobante: InvoiceType.INGRESO, + serie: taxConfig.serie, + folio, + receptorRfc: dto.receptorRfc.toUpperCase(), + receptorNombre: dto.receptorNombre, + receptorRegimenFiscal: dto.receptorRegimenFiscal, + receptorCodigoPostal: dto.receptorCodigoPostal, + receptorUsoCfdi: dto.receptorUsoCfdi, + receptorEmail: dto.receptorEmail, + subtotal, + totalImpuestosTrasladados: totalIva, + total, + formaPago: dto.formaPago, + metodoPago: dto.metodoPago, + condicionesPago: dto.condicionesPago, + status: InvoiceStatus.DRAFT, + notes: dto.notes, + }); + + await this.invoiceRepo.save(invoice); + + // Create items + for (const itemDto of dto.items) { + const importe = itemDto.cantidad * itemDto.valorUnitario - (itemDto.descuento || 0); + + const item = this.itemRepo.create({ + invoiceId: invoice.id, + productId: itemDto.productId, + claveProdServ: itemDto.claveProdServ, + descripcion: itemDto.descripcion, + cantidad: itemDto.cantidad, + claveUnidad: itemDto.claveUnidad, + unidad: itemDto.unidad, + valorUnitario: itemDto.valorUnitario, + descuento: itemDto.descuento || 0, + importe, + }); + + await this.itemRepo.save(item); + } + + return this.getInvoice(invoice.id); + } + + async getInvoice(id: string): Promise { + const invoice = await this.invoiceRepo.findOne({ + where: { id }, + relations: ['items'], + }); + + if (!invoice) { + throw new NotFoundException('Factura no encontrada'); + } + + return invoice; + } + + async getInvoices( + tenantId: string, + options?: { + status?: InvoiceStatus; + from?: Date; + to?: Date; + limit?: number; + }, + ): Promise { + const query = this.invoiceRepo.createQueryBuilder('invoice') + .where('invoice.tenant_id = :tenantId', { tenantId }) + .leftJoinAndSelect('invoice.items', 'items') + .orderBy('invoice.created_at', 'DESC'); + + if (options?.status) { + query.andWhere('invoice.status = :status', { status: options.status }); + } + + if (options?.from) { + query.andWhere('invoice.created_at >= :from', { from: options.from }); + } + + if (options?.to) { + query.andWhere('invoice.created_at <= :to', { to: options.to }); + } + + if (options?.limit) { + query.limit(options.limit); + } + + return query.getMany(); + } + + async stampInvoice(id: string): Promise { + const invoice = await this.getInvoice(id); + + if (invoice.status !== InvoiceStatus.DRAFT && invoice.status !== InvoiceStatus.PENDING) { + throw new BadRequestException(`No se puede timbrar factura con estado: ${invoice.status}`); + } + + const taxConfig = await this.getTaxConfig(invoice.tenantId); + if (!taxConfig) { + throw new BadRequestException('Configuracion fiscal no encontrada'); + } + + // In production, this would call the PAC API (Facturapi, etc.) + // For now, generate mock UUID + const mockUuid = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`.toUpperCase(); + + invoice.uuid = mockUuid; + invoice.status = InvoiceStatus.STAMPED; + invoice.stampedAt = new Date(); + invoice.pacResponse = { + provider: taxConfig.pacProvider, + sandbox: taxConfig.pacSandbox, + timestamp: new Date().toISOString(), + }; + + return this.invoiceRepo.save(invoice); + } + + async cancelInvoice( + id: string, + reason: string, + uuidReplacement?: string, + ): Promise { + const invoice = await this.getInvoice(id); + + if (invoice.status !== InvoiceStatus.STAMPED && invoice.status !== InvoiceStatus.SENT) { + throw new BadRequestException(`No se puede cancelar factura con estado: ${invoice.status}`); + } + + // In production, this would call the PAC API to cancel + invoice.status = InvoiceStatus.CANCELLED; + invoice.cancelledAt = new Date(); + invoice.cancelReason = reason; + invoice.cancelUuidReplacement = uuidReplacement; + + return this.invoiceRepo.save(invoice); + } + + async sendInvoice(id: string, email?: string): Promise { + const invoice = await this.getInvoice(id); + + if (invoice.status !== InvoiceStatus.STAMPED) { + throw new BadRequestException('Solo se pueden enviar facturas timbradas'); + } + + const targetEmail = email || invoice.receptorEmail; + if (!targetEmail) { + throw new BadRequestException('No hay email de destino'); + } + + // In production, this would send the email with PDF and XML + invoice.status = InvoiceStatus.SENT; + + return this.invoiceRepo.save(invoice); + } + + // ==================== SUMMARY ==================== + + async getSummary(tenantId: string, month?: Date) { + const targetMonth = month || new Date(); + const monthStr = targetMonth.toISOString().split('T')[0]; + + const result = await this.dataSource.query( + `SELECT * FROM get_invoice_summary($1, $2::date)`, + [tenantId, monthStr], + ); + + return result[0] || { + total_invoices: 0, + total_amount: 0, + total_cancelled: 0, + by_status: {}, + }; + } +} diff --git a/apps/backend/src/modules/marketplace/dto/create-supplier-order.dto.ts b/apps/backend/src/modules/marketplace/dto/create-supplier-order.dto.ts new file mode 100644 index 000000000..88534181e --- /dev/null +++ b/apps/backend/src/modules/marketplace/dto/create-supplier-order.dto.ts @@ -0,0 +1,74 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsNumber, + IsOptional, + IsArray, + ValidateNested, + IsUUID, + Min, + IsDateString, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class SupplierOrderItemDto { + @ApiProperty({ description: 'ID del producto del proveedor' }) + @IsUUID() + productId: string; + + @ApiProperty({ example: 10, description: 'Cantidad a ordenar' }) + @IsNumber() + @Min(1) + quantity: number; + + @ApiProperty({ description: 'Notas para este item', required: false }) + @IsString() + @IsOptional() + notes?: string; +} + +export class CreateSupplierOrderDto { + @ApiProperty({ description: 'ID del proveedor' }) + @IsUUID() + supplierId: string; + + @ApiProperty({ type: [SupplierOrderItemDto], description: 'Items del pedido' }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SupplierOrderItemDto) + items: SupplierOrderItemDto[]; + + @ApiProperty({ description: 'Direccion de entrega' }) + @IsString() + deliveryAddress: string; + + @ApiProperty({ description: 'Ciudad', required: false }) + @IsString() + @IsOptional() + deliveryCity?: string; + + @ApiProperty({ description: 'Codigo postal', required: false }) + @IsString() + @IsOptional() + deliveryZip?: string; + + @ApiProperty({ description: 'Telefono de contacto', required: false }) + @IsString() + @IsOptional() + deliveryPhone?: string; + + @ApiProperty({ description: 'Nombre de contacto', required: false }) + @IsString() + @IsOptional() + deliveryContact?: string; + + @ApiProperty({ description: 'Fecha solicitada de entrega (YYYY-MM-DD)', required: false }) + @IsDateString() + @IsOptional() + requestedDate?: string; + + @ApiProperty({ description: 'Notas adicionales', required: false }) + @IsString() + @IsOptional() + notes?: string; +} diff --git a/apps/backend/src/modules/marketplace/dto/create-supplier-review.dto.ts b/apps/backend/src/modules/marketplace/dto/create-supplier-review.dto.ts new file mode 100644 index 000000000..3c594b856 --- /dev/null +++ b/apps/backend/src/modules/marketplace/dto/create-supplier-review.dto.ts @@ -0,0 +1,57 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsNumber, + IsOptional, + IsUUID, + Min, + Max, +} from 'class-validator'; + +export class CreateSupplierReviewDto { + @ApiProperty({ description: 'ID del proveedor' }) + @IsUUID() + supplierId: string; + + @ApiProperty({ description: 'ID de la orden (opcional)', required: false }) + @IsUUID() + @IsOptional() + orderId?: string; + + @ApiProperty({ example: 5, description: 'Rating general (1-5)' }) + @IsNumber() + @Min(1) + @Max(5) + rating: number; + + @ApiProperty({ description: 'Titulo de la resena', required: false }) + @IsString() + @IsOptional() + title?: string; + + @ApiProperty({ description: 'Comentario', required: false }) + @IsString() + @IsOptional() + comment?: string; + + @ApiProperty({ description: 'Rating de calidad (1-5)', required: false }) + @IsNumber() + @Min(1) + @Max(5) + @IsOptional() + ratingQuality?: number; + + @ApiProperty({ description: 'Rating de entrega (1-5)', required: false }) + @IsNumber() + @Min(1) + @Max(5) + @IsOptional() + ratingDelivery?: number; + + @ApiProperty({ description: 'Rating de precio (1-5)', required: false }) + @IsNumber() + @Min(1) + @Max(5) + @IsOptional() + ratingPrice?: number; +} diff --git a/apps/backend/src/modules/marketplace/entities/supplier-order-item.entity.ts b/apps/backend/src/modules/marketplace/entities/supplier-order-item.entity.ts new file mode 100644 index 000000000..1976e9518 --- /dev/null +++ b/apps/backend/src/modules/marketplace/entities/supplier-order-item.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { SupplierOrder } from './supplier-order.entity'; +import { SupplierProduct } from './supplier-product.entity'; + +@Entity({ schema: 'marketplace', name: 'supplier_order_items' }) +export class SupplierOrderItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'order_id' }) + orderId: string; + + @Column({ name: 'product_id' }) + productId: string; + + @Column({ name: 'product_name', length: 300 }) + productName: string; + + @Column({ name: 'product_sku', length: 100, nullable: true }) + productSku: string; + + @Column({ type: 'int' }) + quantity: number; + + @Column({ name: 'unit_price', type: 'decimal', precision: 10, scale: 2 }) + unitPrice: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + total: number; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => SupplierOrder, (order) => order.items) + @JoinColumn({ name: 'order_id' }) + order: SupplierOrder; + + @ManyToOne(() => SupplierProduct) + @JoinColumn({ name: 'product_id' }) + product: SupplierProduct; +} diff --git a/apps/backend/src/modules/marketplace/entities/supplier-order.entity.ts b/apps/backend/src/modules/marketplace/entities/supplier-order.entity.ts new file mode 100644 index 000000000..0c6db7b4a --- /dev/null +++ b/apps/backend/src/modules/marketplace/entities/supplier-order.entity.ts @@ -0,0 +1,112 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Supplier } from './supplier.entity'; +import { SupplierOrderItem } from './supplier-order-item.entity'; + +export enum SupplierOrderStatus { + PENDING = 'pending', + CONFIRMED = 'confirmed', + PREPARING = 'preparing', + SHIPPED = 'shipped', + DELIVERED = 'delivered', + CANCELLED = 'cancelled', + REJECTED = 'rejected', +} + +@Entity({ schema: 'marketplace', name: 'supplier_orders' }) +export class SupplierOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id' }) + tenantId: string; + + @Column({ name: 'supplier_id' }) + supplierId: string; + + @Column({ name: 'order_number', type: 'int', generated: 'increment' }) + orderNumber: number; + + @Column({ + type: 'varchar', + length: 30, + default: SupplierOrderStatus.PENDING, + }) + status: SupplierOrderStatus; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + subtotal: number; + + @Column({ name: 'delivery_fee', type: 'decimal', precision: 10, scale: 2, default: 0 }) + deliveryFee: number; + + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + discount: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + total: number; + + @Column({ name: 'delivery_address', type: 'text' }) + deliveryAddress: string; + + @Column({ name: 'delivery_city', length: 100, nullable: true }) + deliveryCity: string; + + @Column({ name: 'delivery_zip', length: 10, nullable: true }) + deliveryZip: string; + + @Column({ name: 'delivery_phone', length: 20, nullable: true }) + deliveryPhone: string; + + @Column({ name: 'delivery_contact', length: 200, nullable: true }) + deliveryContact: string; + + @Column({ name: 'requested_date', type: 'date', nullable: true }) + requestedDate: Date; + + @Column({ name: 'confirmed_date', type: 'date', nullable: true }) + confirmedDate: Date; + + @Column({ name: 'estimated_delivery', type: 'timestamptz', nullable: true }) + estimatedDelivery: Date; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'supplier_notes', type: 'text', nullable: true }) + supplierNotes: string; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt: Date; + + @Column({ name: 'cancel_reason', type: 'text', nullable: true }) + cancelReason: string; + + @Column({ name: 'cancelled_by', length: 20, nullable: true }) + cancelledBy: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Supplier, (supplier) => supplier.orders) + @JoinColumn({ name: 'supplier_id' }) + supplier: Supplier; + + @OneToMany(() => SupplierOrderItem, (item) => item.order) + items: SupplierOrderItem[]; +} diff --git a/apps/backend/src/modules/marketplace/entities/supplier-product.entity.ts b/apps/backend/src/modules/marketplace/entities/supplier-product.entity.ts new file mode 100644 index 000000000..f6e156cd2 --- /dev/null +++ b/apps/backend/src/modules/marketplace/entities/supplier-product.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Supplier } from './supplier.entity'; + +@Entity({ schema: 'marketplace', name: 'supplier_products' }) +export class SupplierProduct { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'supplier_id' }) + supplierId: string; + + @Column({ length: 300 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ length: 100, nullable: true }) + sku: string; + + @Column({ length: 50, nullable: true }) + barcode: string; + + @Column({ length: 100, nullable: true }) + category: string; + + @Column({ length: 100, nullable: true }) + subcategory: string; + + @Column({ name: 'image_url', type: 'text', nullable: true }) + imageUrl: string; + + @Column({ name: 'unit_price', type: 'decimal', precision: 10, scale: 2 }) + unitPrice: number; + + @Column({ name: 'unit_type', length: 50, default: 'pieza' }) + unitType: string; + + @Column({ name: 'min_quantity', default: 1 }) + minQuantity: number; + + @Column({ name: 'tiered_pricing', type: 'jsonb', default: '[]' }) + tieredPricing: { min: number; price: number }[]; + + @Column({ name: 'in_stock', default: true }) + inStock: boolean; + + @Column({ name: 'stock_quantity', nullable: true }) + stockQuantity: number; + + @Column({ default: true }) + active: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Supplier, (supplier) => supplier.products) + @JoinColumn({ name: 'supplier_id' }) + supplier: Supplier; +} diff --git a/apps/backend/src/modules/marketplace/entities/supplier-review.entity.ts b/apps/backend/src/modules/marketplace/entities/supplier-review.entity.ts new file mode 100644 index 000000000..d6fced8c7 --- /dev/null +++ b/apps/backend/src/modules/marketplace/entities/supplier-review.entity.ts @@ -0,0 +1,71 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Supplier } from './supplier.entity'; +import { SupplierOrder } from './supplier-order.entity'; + +@Entity({ schema: 'marketplace', name: 'supplier_reviews' }) +export class SupplierReview { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id' }) + tenantId: string; + + @Column({ name: 'supplier_id' }) + supplierId: string; + + @Column({ name: 'order_id', nullable: true }) + orderId: string; + + @Column({ type: 'int' }) + rating: number; + + @Column({ length: 200, nullable: true }) + title: string; + + @Column({ type: 'text', nullable: true }) + comment: string; + + @Column({ name: 'rating_quality', type: 'int', nullable: true }) + ratingQuality: number; + + @Column({ name: 'rating_delivery', type: 'int', nullable: true }) + ratingDelivery: number; + + @Column({ name: 'rating_price', type: 'int', nullable: true }) + ratingPrice: number; + + @Column({ name: 'supplier_response', type: 'text', nullable: true }) + supplierResponse: string; + + @Column({ name: 'responded_at', type: 'timestamptz', nullable: true }) + respondedAt: Date; + + @Column({ default: false }) + verified: boolean; + + @Column({ length: 20, default: 'active' }) + status: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Supplier, (supplier) => supplier.reviews) + @JoinColumn({ name: 'supplier_id' }) + supplier: Supplier; + + @ManyToOne(() => SupplierOrder, { nullable: true }) + @JoinColumn({ name: 'order_id' }) + order: SupplierOrder; +} diff --git a/apps/backend/src/modules/marketplace/entities/supplier.entity.ts b/apps/backend/src/modules/marketplace/entities/supplier.entity.ts new file mode 100644 index 000000000..ecc3bbc3e --- /dev/null +++ b/apps/backend/src/modules/marketplace/entities/supplier.entity.ts @@ -0,0 +1,124 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; +import { SupplierProduct } from './supplier-product.entity'; +import { SupplierOrder } from './supplier-order.entity'; +import { SupplierReview } from './supplier-review.entity'; + +export enum SupplierStatus { + PENDING = 'pending', + ACTIVE = 'active', + SUSPENDED = 'suspended', + INACTIVE = 'inactive', +} + +@Entity({ schema: 'marketplace', name: 'suppliers' }) +export class Supplier { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 200 }) + name: string; + + @Column({ name: 'legal_name', length: 300, nullable: true }) + legalName: string; + + @Column({ length: 13, nullable: true }) + rfc: string; + + @Column({ type: 'text', array: true, default: '{}' }) + categories: string[]; + + @Column({ name: 'coverage_zones', type: 'text', array: true, default: '{}' }) + coverageZones: string[]; + + @Column({ name: 'contact_name', length: 200, nullable: true }) + contactName: string; + + @Column({ name: 'contact_phone', length: 20, nullable: true }) + contactPhone: string; + + @Column({ name: 'contact_email', length: 200, nullable: true }) + contactEmail: string; + + @Column({ name: 'contact_whatsapp', length: 20, nullable: true }) + contactWhatsapp: string; + + @Column({ type: 'text', nullable: true }) + address: string; + + @Column({ length: 100, nullable: true }) + city: string; + + @Column({ length: 100, nullable: true }) + state: string; + + @Column({ name: 'zip_code', length: 10, nullable: true }) + zipCode: string; + + @Column({ name: 'logo_url', type: 'text', nullable: true }) + logoUrl: string; + + @Column({ name: 'banner_url', type: 'text', nullable: true }) + bannerUrl: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ name: 'min_order_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + minOrderAmount: number; + + @Column({ name: 'delivery_fee', type: 'decimal', precision: 10, scale: 2, default: 0 }) + deliveryFee: number; + + @Column({ name: 'free_delivery_min', type: 'decimal', precision: 10, scale: 2, nullable: true }) + freeDeliveryMin: number; + + @Column({ name: 'delivery_days', type: 'text', array: true, default: '{}' }) + deliveryDays: string[]; + + @Column({ name: 'lead_time_days', default: 1 }) + leadTimeDays: number; + + @Column({ default: false }) + verified: boolean; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ type: 'decimal', precision: 2, scale: 1, default: 0 }) + rating: number; + + @Column({ name: 'total_reviews', default: 0 }) + totalReviews: number; + + @Column({ name: 'total_orders', default: 0 }) + totalOrders: number; + + @Column({ type: 'varchar', length: 20, default: SupplierStatus.PENDING }) + status: SupplierStatus; + + @Column({ name: 'user_id', nullable: true }) + userId: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @OneToMany(() => SupplierProduct, (product) => product.supplier) + products: SupplierProduct[]; + + @OneToMany(() => SupplierOrder, (order) => order.supplier) + orders: SupplierOrder[]; + + @OneToMany(() => SupplierReview, (review) => review.supplier) + reviews: SupplierReview[]; +} diff --git a/apps/backend/src/modules/marketplace/marketplace.controller.ts b/apps/backend/src/modules/marketplace/marketplace.controller.ts new file mode 100644 index 000000000..bf200adf9 --- /dev/null +++ b/apps/backend/src/modules/marketplace/marketplace.controller.ts @@ -0,0 +1,180 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + Request, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { MarketplaceService } from './marketplace.service'; +import { CreateSupplierOrderDto } from './dto/create-supplier-order.dto'; +import { CreateSupplierReviewDto } from './dto/create-supplier-review.dto'; +import { SupplierOrderStatus } from './entities/supplier-order.entity'; + +@ApiTags('Marketplace') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('marketplace') +export class MarketplaceController { + constructor(private readonly marketplaceService: MarketplaceService) {} + + // ==================== SUPPLIERS ==================== + + @Get('suppliers') + @ApiOperation({ summary: 'Listar proveedores' }) + @ApiQuery({ name: 'category', required: false }) + @ApiQuery({ name: 'zipCode', required: false }) + @ApiQuery({ name: 'search', required: false }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async findSuppliers( + @Query('category') category?: string, + @Query('zipCode') zipCode?: string, + @Query('search') search?: string, + @Query('limit') limit?: number, + ) { + return this.marketplaceService.findSuppliers({ + category, + zipCode, + search, + limit: limit ? Number(limit) : undefined, + }); + } + + @Get('suppliers/:id') + @ApiOperation({ summary: 'Obtener detalle de proveedor' }) + async getSupplier(@Param('id', ParseUUIDPipe) id: string) { + return this.marketplaceService.getSupplier(id); + } + + @Get('suppliers/:id/products') + @ApiOperation({ summary: 'Obtener productos de un proveedor' }) + @ApiQuery({ name: 'category', required: false }) + @ApiQuery({ name: 'search', required: false }) + @ApiQuery({ name: 'inStock', required: false, type: Boolean }) + async getSupplierProducts( + @Param('id', ParseUUIDPipe) id: string, + @Query('category') category?: string, + @Query('search') search?: string, + @Query('inStock') inStock?: boolean, + ) { + return this.marketplaceService.getSupplierProducts(id, { + category, + search, + inStock, + }); + } + + @Get('suppliers/:id/reviews') + @ApiOperation({ summary: 'Obtener resenas de un proveedor' }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'offset', required: false, type: Number }) + async getSupplierReviews( + @Param('id', ParseUUIDPipe) id: string, + @Query('limit') limit?: number, + @Query('offset') offset?: number, + ) { + return this.marketplaceService.getReviews(id, { + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + }); + } + + // ==================== ORDERS ==================== + + @Post('orders') + @ApiOperation({ summary: 'Crear pedido a proveedor' }) + async createOrder( + @Request() req, + @Body() dto: CreateSupplierOrderDto, + ) { + return this.marketplaceService.createOrder(req.user.tenantId, dto); + } + + @Get('orders') + @ApiOperation({ summary: 'Listar mis pedidos' }) + @ApiQuery({ name: 'status', required: false, enum: SupplierOrderStatus }) + @ApiQuery({ name: 'supplierId', required: false }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getOrders( + @Request() req, + @Query('status') status?: SupplierOrderStatus, + @Query('supplierId') supplierId?: string, + @Query('limit') limit?: number, + ) { + return this.marketplaceService.getOrders(req.user.tenantId, { + status, + supplierId, + limit: limit ? Number(limit) : undefined, + }); + } + + @Get('orders/:id') + @ApiOperation({ summary: 'Obtener detalle de pedido' }) + async getOrder(@Param('id', ParseUUIDPipe) id: string) { + return this.marketplaceService.getOrder(id); + } + + @Put('orders/:id/cancel') + @ApiOperation({ summary: 'Cancelar pedido' }) + async cancelOrder( + @Request() req, + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { reason: string }, + ) { + return this.marketplaceService.cancelOrder(id, req.user.tenantId, body.reason); + } + + // ==================== REVIEWS ==================== + + @Post('reviews') + @ApiOperation({ summary: 'Crear resena de proveedor' }) + async createReview( + @Request() req, + @Body() dto: CreateSupplierReviewDto, + ) { + return this.marketplaceService.createReview(req.user.tenantId, dto); + } + + // ==================== FAVORITES ==================== + + @Get('favorites') + @ApiOperation({ summary: 'Obtener proveedores favoritos' }) + async getFavorites(@Request() req) { + return this.marketplaceService.getFavorites(req.user.tenantId); + } + + @Post('favorites/:supplierId') + @ApiOperation({ summary: 'Agregar proveedor a favoritos' }) + async addFavorite( + @Request() req, + @Param('supplierId', ParseUUIDPipe) supplierId: string, + ) { + await this.marketplaceService.addFavorite(req.user.tenantId, supplierId); + return { message: 'Agregado a favoritos' }; + } + + @Delete('favorites/:supplierId') + @ApiOperation({ summary: 'Quitar proveedor de favoritos' }) + async removeFavorite( + @Request() req, + @Param('supplierId', ParseUUIDPipe) supplierId: string, + ) { + await this.marketplaceService.removeFavorite(req.user.tenantId, supplierId); + return { message: 'Eliminado de favoritos' }; + } + + // ==================== STATS ==================== + + @Get('stats') + @ApiOperation({ summary: 'Obtener estadisticas del marketplace' }) + async getStats() { + return this.marketplaceService.getMarketplaceStats(); + } +} diff --git a/apps/backend/src/modules/marketplace/marketplace.module.ts b/apps/backend/src/modules/marketplace/marketplace.module.ts new file mode 100644 index 000000000..de2c78bd7 --- /dev/null +++ b/apps/backend/src/modules/marketplace/marketplace.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Supplier } from './entities/supplier.entity'; +import { SupplierProduct } from './entities/supplier-product.entity'; +import { SupplierOrder } from './entities/supplier-order.entity'; +import { SupplierOrderItem } from './entities/supplier-order-item.entity'; +import { SupplierReview } from './entities/supplier-review.entity'; +import { MarketplaceService } from './marketplace.service'; +import { MarketplaceController } from './marketplace.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Supplier, + SupplierProduct, + SupplierOrder, + SupplierOrderItem, + SupplierReview, + ]), + ], + controllers: [MarketplaceController], + providers: [MarketplaceService], + exports: [MarketplaceService], +}) +export class MarketplaceModule {} diff --git a/apps/backend/src/modules/marketplace/marketplace.service.ts b/apps/backend/src/modules/marketplace/marketplace.service.ts new file mode 100644 index 000000000..a5e271d5d --- /dev/null +++ b/apps/backend/src/modules/marketplace/marketplace.service.ts @@ -0,0 +1,455 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { Supplier, SupplierStatus } from './entities/supplier.entity'; +import { SupplierProduct } from './entities/supplier-product.entity'; +import { SupplierOrder, SupplierOrderStatus } from './entities/supplier-order.entity'; +import { SupplierOrderItem } from './entities/supplier-order-item.entity'; +import { SupplierReview } from './entities/supplier-review.entity'; +import { CreateSupplierOrderDto } from './dto/create-supplier-order.dto'; +import { CreateSupplierReviewDto } from './dto/create-supplier-review.dto'; + +@Injectable() +export class MarketplaceService { + constructor( + @InjectRepository(Supplier) + private readonly supplierRepo: Repository, + @InjectRepository(SupplierProduct) + private readonly productRepo: Repository, + @InjectRepository(SupplierOrder) + private readonly orderRepo: Repository, + @InjectRepository(SupplierOrderItem) + private readonly orderItemRepo: Repository, + @InjectRepository(SupplierReview) + private readonly reviewRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + // ==================== SUPPLIERS ==================== + + async findSuppliers(options?: { + category?: string; + zipCode?: string; + search?: string; + limit?: number; + }): Promise { + const query = this.supplierRepo.createQueryBuilder('supplier') + .where('supplier.status = :status', { status: SupplierStatus.ACTIVE }) + .orderBy('supplier.rating', 'DESC') + .addOrderBy('supplier.total_orders', 'DESC'); + + if (options?.category) { + query.andWhere(':category = ANY(supplier.categories)', { + category: options.category, + }); + } + + if (options?.zipCode) { + query.andWhere( + '(supplier.coverage_zones = \'{}\' OR :zipCode = ANY(supplier.coverage_zones))', + { zipCode: options.zipCode }, + ); + } + + if (options?.search) { + query.andWhere( + '(supplier.name ILIKE :search OR supplier.description ILIKE :search)', + { search: `%${options.search}%` }, + ); + } + + if (options?.limit) { + query.limit(options.limit); + } + + return query.getMany(); + } + + async getSupplier(id: string): Promise { + const supplier = await this.supplierRepo.findOne({ + where: { id }, + relations: ['products', 'reviews'], + }); + + if (!supplier) { + throw new NotFoundException('Proveedor no encontrado'); + } + + return supplier; + } + + async getSupplierProducts( + supplierId: string, + options?: { + category?: string; + search?: string; + inStock?: boolean; + }, + ): Promise { + const query = this.productRepo.createQueryBuilder('product') + .where('product.supplier_id = :supplierId', { supplierId }) + .andWhere('product.active = true') + .orderBy('product.category', 'ASC') + .addOrderBy('product.name', 'ASC'); + + if (options?.category) { + query.andWhere('product.category = :category', { category: options.category }); + } + + if (options?.search) { + query.andWhere( + '(product.name ILIKE :search OR product.description ILIKE :search)', + { search: `%${options.search}%` }, + ); + } + + if (options?.inStock !== undefined) { + query.andWhere('product.in_stock = :inStock', { inStock: options.inStock }); + } + + return query.getMany(); + } + + // ==================== ORDERS ==================== + + async createOrder( + tenantId: string, + dto: CreateSupplierOrderDto, + ): Promise { + const supplier = await this.supplierRepo.findOne({ + where: { id: dto.supplierId, status: SupplierStatus.ACTIVE }, + }); + + if (!supplier) { + throw new NotFoundException('Proveedor no encontrado o no activo'); + } + + // Get products and calculate totals + const productIds = dto.items.map((item) => item.productId); + const products = await this.productRepo.findByIds(productIds); + + if (products.length !== productIds.length) { + throw new BadRequestException('Algunos productos no fueron encontrados'); + } + + // Create product map for easy lookup + const productMap = new Map(products.map((p) => [p.id, p])); + + // Validate min quantities and calculate subtotal + let subtotal = 0; + for (const item of dto.items) { + const product = productMap.get(item.productId); + if (!product) { + throw new BadRequestException(`Producto ${item.productId} no encontrado`); + } + + if (item.quantity < product.minQuantity) { + throw new BadRequestException( + `Cantidad minima para ${product.name} es ${product.minQuantity}`, + ); + } + + if (!product.inStock) { + throw new BadRequestException(`${product.name} no esta disponible`); + } + + // Calculate price based on tiered pricing + let unitPrice = Number(product.unitPrice); + if (product.tieredPricing && product.tieredPricing.length > 0) { + for (const tier of product.tieredPricing.sort((a, b) => b.min - a.min)) { + if (item.quantity >= tier.min) { + unitPrice = tier.price; + break; + } + } + } + + subtotal += unitPrice * item.quantity; + } + + // Calculate delivery fee + let deliveryFee = Number(supplier.deliveryFee); + if (supplier.freeDeliveryMin && subtotal >= Number(supplier.freeDeliveryMin)) { + deliveryFee = 0; + } + + // Check minimum order + if (subtotal < Number(supplier.minOrderAmount)) { + throw new BadRequestException( + `Pedido minimo es $${supplier.minOrderAmount}`, + ); + } + + const total = subtotal + deliveryFee; + + // Create order + const order = this.orderRepo.create({ + tenantId, + supplierId: dto.supplierId, + status: SupplierOrderStatus.PENDING, + subtotal, + deliveryFee, + total, + deliveryAddress: dto.deliveryAddress, + deliveryCity: dto.deliveryCity, + deliveryZip: dto.deliveryZip, + deliveryPhone: dto.deliveryPhone, + deliveryContact: dto.deliveryContact, + requestedDate: dto.requestedDate ? new Date(dto.requestedDate) : null, + notes: dto.notes, + }); + + await this.orderRepo.save(order); + + // Create order items + for (const item of dto.items) { + const product = productMap.get(item.productId); + + let unitPrice = Number(product.unitPrice); + if (product.tieredPricing && product.tieredPricing.length > 0) { + for (const tier of product.tieredPricing.sort((a, b) => b.min - a.min)) { + if (item.quantity >= tier.min) { + unitPrice = tier.price; + break; + } + } + } + + const orderItem = this.orderItemRepo.create({ + orderId: order.id, + productId: item.productId, + productName: product.name, + productSku: product.sku, + quantity: item.quantity, + unitPrice, + total: unitPrice * item.quantity, + notes: item.notes, + }); + + await this.orderItemRepo.save(orderItem); + } + + return this.getOrder(order.id); + } + + async getOrder(id: string): Promise { + const order = await this.orderRepo.findOne({ + where: { id }, + relations: ['items', 'supplier'], + }); + + if (!order) { + throw new NotFoundException('Pedido no encontrado'); + } + + return order; + } + + async getOrders( + tenantId: string, + options?: { + status?: SupplierOrderStatus; + supplierId?: string; + limit?: number; + }, + ): Promise { + const query = this.orderRepo.createQueryBuilder('order') + .where('order.tenant_id = :tenantId', { tenantId }) + .leftJoinAndSelect('order.supplier', 'supplier') + .leftJoinAndSelect('order.items', 'items') + .orderBy('order.created_at', 'DESC'); + + if (options?.status) { + query.andWhere('order.status = :status', { status: options.status }); + } + + if (options?.supplierId) { + query.andWhere('order.supplier_id = :supplierId', { supplierId: options.supplierId }); + } + + if (options?.limit) { + query.limit(options.limit); + } + + return query.getMany(); + } + + async updateOrderStatus( + id: string, + status: SupplierOrderStatus, + notes?: string, + ): Promise { + const order = await this.getOrder(id); + + // Validate status transitions + const validTransitions: Record = { + [SupplierOrderStatus.PENDING]: [SupplierOrderStatus.CONFIRMED, SupplierOrderStatus.CANCELLED, SupplierOrderStatus.REJECTED], + [SupplierOrderStatus.CONFIRMED]: [SupplierOrderStatus.PREPARING, SupplierOrderStatus.CANCELLED], + [SupplierOrderStatus.PREPARING]: [SupplierOrderStatus.SHIPPED, SupplierOrderStatus.CANCELLED], + [SupplierOrderStatus.SHIPPED]: [SupplierOrderStatus.DELIVERED, SupplierOrderStatus.CANCELLED], + [SupplierOrderStatus.DELIVERED]: [], + [SupplierOrderStatus.CANCELLED]: [], + [SupplierOrderStatus.REJECTED]: [], + }; + + if (!validTransitions[order.status].includes(status)) { + throw new BadRequestException( + `No se puede cambiar estado de ${order.status} a ${status}`, + ); + } + + order.status = status; + + if (status === SupplierOrderStatus.CONFIRMED) { + order.confirmedDate = new Date(); + } + + if (status === SupplierOrderStatus.DELIVERED) { + order.deliveredAt = new Date(); + } + + if (status === SupplierOrderStatus.CANCELLED || status === SupplierOrderStatus.REJECTED) { + order.cancelledAt = new Date(); + order.cancelReason = notes; + } + + if (notes) { + order.supplierNotes = notes; + } + + return this.orderRepo.save(order); + } + + async cancelOrder( + id: string, + tenantId: string, + reason: string, + ): Promise { + const order = await this.getOrder(id); + + if (order.tenantId !== tenantId) { + throw new BadRequestException('No autorizado'); + } + + if (![SupplierOrderStatus.PENDING, SupplierOrderStatus.CONFIRMED].includes(order.status)) { + throw new BadRequestException('No se puede cancelar el pedido en este estado'); + } + + order.status = SupplierOrderStatus.CANCELLED; + order.cancelledAt = new Date(); + order.cancelReason = reason; + order.cancelledBy = 'tenant'; + + return this.orderRepo.save(order); + } + + // ==================== REVIEWS ==================== + + async createReview( + tenantId: string, + dto: CreateSupplierReviewDto, + ): Promise { + const supplier = await this.supplierRepo.findOne({ + where: { id: dto.supplierId }, + }); + + if (!supplier) { + throw new NotFoundException('Proveedor no encontrado'); + } + + // Check if order exists and belongs to tenant + let verified = false; + if (dto.orderId) { + const order = await this.orderRepo.findOne({ + where: { id: dto.orderId, tenantId, supplierId: dto.supplierId }, + }); + + if (!order) { + throw new BadRequestException('Orden no encontrada'); + } + + if (order.status === SupplierOrderStatus.DELIVERED) { + verified = true; + } + } + + const review = this.reviewRepo.create({ + tenantId, + supplierId: dto.supplierId, + orderId: dto.orderId, + rating: dto.rating, + title: dto.title, + comment: dto.comment, + ratingQuality: dto.ratingQuality, + ratingDelivery: dto.ratingDelivery, + ratingPrice: dto.ratingPrice, + verified, + }); + + return this.reviewRepo.save(review); + } + + async getReviews( + supplierId: string, + options?: { + limit?: number; + offset?: number; + }, + ): Promise { + return this.reviewRepo.find({ + where: { supplierId, status: 'active' }, + order: { createdAt: 'DESC' }, + take: options?.limit || 20, + skip: options?.offset || 0, + }); + } + + // ==================== FAVORITES ==================== + + async addFavorite(tenantId: string, supplierId: string): Promise { + await this.dataSource.query( + `INSERT INTO marketplace.supplier_favorites (tenant_id, supplier_id) + VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [tenantId, supplierId], + ); + } + + async removeFavorite(tenantId: string, supplierId: string): Promise { + await this.dataSource.query( + `DELETE FROM marketplace.supplier_favorites WHERE tenant_id = $1 AND supplier_id = $2`, + [tenantId, supplierId], + ); + } + + async getFavorites(tenantId: string): Promise { + const result = await this.dataSource.query( + `SELECT s.* FROM marketplace.suppliers s + JOIN marketplace.supplier_favorites f ON s.id = f.supplier_id + WHERE f.tenant_id = $1`, + [tenantId], + ); + + return result; + } + + // ==================== STATS ==================== + + async getMarketplaceStats() { + const result = await this.dataSource.query( + `SELECT * FROM marketplace.get_marketplace_stats()`, + ); + + return result[0] || { + total_suppliers: 0, + active_suppliers: 0, + total_products: 0, + total_orders: 0, + total_gmv: 0, + avg_rating: 0, + }; + } +} diff --git a/apps/backend/src/modules/referrals/dto/apply-code.dto.ts b/apps/backend/src/modules/referrals/dto/apply-code.dto.ts new file mode 100644 index 000000000..9fb91ac45 --- /dev/null +++ b/apps/backend/src/modules/referrals/dto/apply-code.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, Length, Matches } from 'class-validator'; + +export class ApplyCodeDto { + @ApiProperty({ + example: 'MCH-ABC123', + description: 'Codigo de referido a aplicar', + }) + @IsString() + @Length(3, 20) + @Matches(/^[A-Z0-9-]+$/, { + message: 'El codigo solo puede contener letras mayusculas, numeros y guiones', + }) + code: string; +} diff --git a/apps/backend/src/modules/referrals/entities/referral-code.entity.ts b/apps/backend/src/modules/referrals/entities/referral-code.entity.ts new file mode 100644 index 000000000..6823685c5 --- /dev/null +++ b/apps/backend/src/modules/referrals/entities/referral-code.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity({ schema: 'subscriptions', name: 'referral_codes' }) +export class ReferralCode { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id' }) + tenantId: string; + + @Column({ length: 20, unique: true }) + code: string; + + @Column({ default: true }) + active: boolean; + + @Column({ name: 'uses_count', default: 0 }) + usesCount: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/modules/referrals/entities/referral-reward.entity.ts b/apps/backend/src/modules/referrals/entities/referral-reward.entity.ts new file mode 100644 index 000000000..ca36aa144 --- /dev/null +++ b/apps/backend/src/modules/referrals/entities/referral-reward.entity.ts @@ -0,0 +1,70 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Referral } from './referral.entity'; + +export enum RewardType { + FREE_MONTH = 'free_month', + DISCOUNT = 'discount', +} + +export enum RewardStatus { + AVAILABLE = 'available', + USED = 'used', + EXPIRED = 'expired', +} + +@Entity({ schema: 'subscriptions', name: 'referral_rewards' }) +export class ReferralReward { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id' }) + tenantId: string; + + @Column({ name: 'referral_id' }) + referralId: string; + + @Column({ + type: 'varchar', + length: 20, + default: RewardType.FREE_MONTH, + }) + type: RewardType; + + @Column({ name: 'months_earned', default: 0 }) + monthsEarned: number; + + @Column({ name: 'months_used', default: 0 }) + monthsUsed: number; + + @Column({ name: 'discount_percent', default: 0 }) + discountPercent: number; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @Column({ + type: 'varchar', + length: 20, + default: RewardStatus.AVAILABLE, + }) + status: RewardStatus; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Referral) + @JoinColumn({ name: 'referral_id' }) + referral: Referral; +} diff --git a/apps/backend/src/modules/referrals/entities/referral.entity.ts b/apps/backend/src/modules/referrals/entities/referral.entity.ts new file mode 100644 index 000000000..c71c8984c --- /dev/null +++ b/apps/backend/src/modules/referrals/entities/referral.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum ReferralStatus { + PENDING = 'pending', + CONVERTED = 'converted', + REWARDED = 'rewarded', + EXPIRED = 'expired', +} + +@Entity({ schema: 'subscriptions', name: 'referrals' }) +export class Referral { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'referrer_tenant_id' }) + referrerTenantId: string; + + @Column({ name: 'referred_tenant_id', unique: true }) + referredTenantId: string; + + @Column({ name: 'code_used', length: 20 }) + codeUsed: string; + + @Column({ + type: 'varchar', + length: 20, + default: ReferralStatus.PENDING, + }) + status: ReferralStatus; + + @Column({ name: 'referred_discount_applied', default: false }) + referredDiscountApplied: boolean; + + @Column({ name: 'referrer_reward_applied', default: false }) + referrerRewardApplied: boolean; + + @Column({ name: 'converted_at', type: 'timestamptz', nullable: true }) + convertedAt: Date; + + @Column({ name: 'reward_applied_at', type: 'timestamptz', nullable: true }) + rewardAppliedAt: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/modules/referrals/referrals.controller.ts b/apps/backend/src/modules/referrals/referrals.controller.ts new file mode 100644 index 000000000..cef4ca63b --- /dev/null +++ b/apps/backend/src/modules/referrals/referrals.controller.ts @@ -0,0 +1,85 @@ +import { + Controller, + Get, + Post, + Body, + Param, + UseGuards, + Request, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { ReferralsService } from './referrals.service'; +import { ApplyCodeDto } from './dto/apply-code.dto'; + +@ApiTags('referrals') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('v1/referrals') +export class ReferralsController { + constructor(private readonly referralsService: ReferralsService) {} + + // ==================== CODES ==================== + + @Get('my-code') + @ApiOperation({ summary: 'Obtener mi codigo de referido' }) + getMyCode(@Request() req) { + return this.referralsService.getMyCode(req.user.tenantId); + } + + @Post('generate-code') + @ApiOperation({ summary: 'Generar nuevo codigo de referido' }) + generateCode(@Request() req) { + return this.referralsService.generateCode(req.user.tenantId); + } + + @Get('validate/:code') + @ApiOperation({ summary: 'Validar un codigo de referido' }) + @ApiParam({ name: 'code', description: 'Codigo a validar' }) + validateCode(@Param('code') code: string) { + return this.referralsService.validateCode(code); + } + + // ==================== REFERRALS ==================== + + @Post('apply-code') + @ApiOperation({ summary: 'Aplicar codigo de referido (al registrarse)' }) + applyCode(@Request() req, @Body() dto: ApplyCodeDto) { + return this.referralsService.applyCode(req.user.tenantId, dto.code); + } + + @Get('list') + @ApiOperation({ summary: 'Listar mis referidos' }) + getMyReferrals(@Request() req) { + return this.referralsService.getMyReferrals(req.user.tenantId); + } + + @Get('stats') + @ApiOperation({ summary: 'Estadisticas de referidos' }) + getStats(@Request() req) { + return this.referralsService.getStats(req.user.tenantId); + } + + // ==================== REWARDS ==================== + + @Get('rewards') + @ApiOperation({ summary: 'Mis recompensas de referidos' }) + getRewards(@Request() req) { + return this.referralsService.getMyRewards(req.user.tenantId); + } + + @Get('rewards/available-months') + @ApiOperation({ summary: 'Meses gratis disponibles' }) + getAvailableMonths(@Request() req) { + return this.referralsService.getAvailableMonths(req.user.tenantId); + } + + // ==================== DISCOUNT ==================== + + @Get('discount') + @ApiOperation({ summary: 'Descuento disponible como referido' }) + async getDiscount(@Request() req) { + const discount = await this.referralsService.getReferredDiscount(req.user.tenantId); + return { discountPercent: discount }; + } +} diff --git a/apps/backend/src/modules/referrals/referrals.module.ts b/apps/backend/src/modules/referrals/referrals.module.ts new file mode 100644 index 000000000..c29f8d5ae --- /dev/null +++ b/apps/backend/src/modules/referrals/referrals.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ReferralsController } from './referrals.controller'; +import { ReferralsService } from './referrals.service'; +import { ReferralCode } from './entities/referral-code.entity'; +import { Referral } from './entities/referral.entity'; +import { ReferralReward } from './entities/referral-reward.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([ReferralCode, Referral, ReferralReward])], + controllers: [ReferralsController], + providers: [ReferralsService], + exports: [ReferralsService], +}) +export class ReferralsModule {} diff --git a/apps/backend/src/modules/referrals/referrals.service.ts b/apps/backend/src/modules/referrals/referrals.service.ts new file mode 100644 index 000000000..c905da40f --- /dev/null +++ b/apps/backend/src/modules/referrals/referrals.service.ts @@ -0,0 +1,266 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + ConflictException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { ReferralCode } from './entities/referral-code.entity'; +import { Referral, ReferralStatus } from './entities/referral.entity'; +import { ReferralReward, RewardType, RewardStatus } from './entities/referral-reward.entity'; + +@Injectable() +export class ReferralsService { + constructor( + @InjectRepository(ReferralCode) + private readonly codeRepo: Repository, + @InjectRepository(Referral) + private readonly referralRepo: Repository, + @InjectRepository(ReferralReward) + private readonly rewardRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + // ==================== CODES ==================== + + async getMyCode(tenantId: string): Promise { + let code = await this.codeRepo.findOne({ where: { tenantId } }); + + if (!code) { + code = await this.generateCode(tenantId); + } + + return code; + } + + async generateCode(tenantId: string): Promise { + // Check if already has a code + const existing = await this.codeRepo.findOne({ where: { tenantId } }); + if (existing) { + return existing; + } + + // Generate unique code using database function + const result = await this.dataSource.query( + `SELECT generate_referral_code('MCH') as code`, + ); + const newCode = result[0].code; + + const referralCode = this.codeRepo.create({ + tenantId, + code: newCode, + active: true, + }); + + return this.codeRepo.save(referralCode); + } + + async validateCode(code: string): Promise { + const referralCode = await this.codeRepo.findOne({ + where: { code: code.toUpperCase(), active: true }, + }); + + if (!referralCode) { + throw new NotFoundException('Codigo de referido no valido o inactivo'); + } + + return referralCode; + } + + // ==================== REFERRALS ==================== + + async applyCode(referredTenantId: string, code: string): Promise { + // Validate code exists + const referralCode = await this.validateCode(code); + + // Cannot refer yourself + if (referralCode.tenantId === referredTenantId) { + throw new BadRequestException('No puedes usar tu propio codigo de referido'); + } + + // Check if already referred + const existingReferral = await this.referralRepo.findOne({ + where: { referredTenantId }, + }); + + if (existingReferral) { + throw new ConflictException('Ya tienes un codigo de referido aplicado'); + } + + // Create referral with 30 day expiry + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 30); + + const referral = this.referralRepo.create({ + referrerTenantId: referralCode.tenantId, + referredTenantId, + codeUsed: referralCode.code, + status: ReferralStatus.PENDING, + expiresAt, + }); + + await this.referralRepo.save(referral); + + // Increment uses count + referralCode.usesCount += 1; + await this.codeRepo.save(referralCode); + + return referral; + } + + async getReferralByReferred(referredTenantId: string): Promise { + return this.referralRepo.findOne({ where: { referredTenantId } }); + } + + async getMyReferrals(tenantId: string): Promise { + return this.referralRepo.find({ + where: { referrerTenantId: tenantId }, + order: { createdAt: 'DESC' }, + }); + } + + async convertReferral(referredTenantId: string): Promise { + const referral = await this.referralRepo.findOne({ + where: { referredTenantId, status: ReferralStatus.PENDING }, + }); + + if (!referral) { + throw new NotFoundException('No se encontro referido pendiente'); + } + + // Check if expired + if (referral.expiresAt && new Date() > referral.expiresAt) { + referral.status = ReferralStatus.EXPIRED; + await this.referralRepo.save(referral); + throw new BadRequestException('El periodo de conversion ha expirado'); + } + + // Mark as converted + referral.status = ReferralStatus.CONVERTED; + referral.convertedAt = new Date(); + await this.referralRepo.save(referral); + + // Create reward for referrer (1 month free) + const expiresAt = new Date(); + expiresAt.setFullYear(expiresAt.getFullYear() + 1); // 1 year to use + + const reward = this.rewardRepo.create({ + tenantId: referral.referrerTenantId, + referralId: referral.id, + type: RewardType.FREE_MONTH, + monthsEarned: 1, + monthsUsed: 0, + expiresAt, + status: RewardStatus.AVAILABLE, + }); + + await this.rewardRepo.save(reward); + + // Update referral as rewarded + referral.status = ReferralStatus.REWARDED; + referral.referrerRewardApplied = true; + referral.rewardAppliedAt = new Date(); + await this.referralRepo.save(referral); + + return referral; + } + + // ==================== REWARDS ==================== + + async getMyRewards(tenantId: string): Promise { + return this.rewardRepo.find({ + where: { tenantId }, + order: { createdAt: 'DESC' }, + }); + } + + async getAvailableMonths(tenantId: string): Promise { + const rewards = await this.rewardRepo.find({ + where: { tenantId, status: RewardStatus.AVAILABLE, type: RewardType.FREE_MONTH }, + }); + + return rewards.reduce((sum, r) => sum + (r.monthsEarned - r.monthsUsed), 0); + } + + async useReferralMonth(tenantId: string): Promise { + // Find first available reward + const reward = await this.rewardRepo.findOne({ + where: { tenantId, status: RewardStatus.AVAILABLE, type: RewardType.FREE_MONTH }, + order: { createdAt: 'ASC' }, + }); + + if (!reward || reward.monthsEarned <= reward.monthsUsed) { + return false; + } + + reward.monthsUsed += 1; + + if (reward.monthsUsed >= reward.monthsEarned) { + reward.status = RewardStatus.USED; + } + + await this.rewardRepo.save(reward); + return true; + } + + // ==================== STATS ==================== + + async getStats(tenantId: string) { + const result = await this.dataSource.query( + `SELECT * FROM get_referral_stats($1)`, + [tenantId], + ); + + const stats = result[0] || { + total_invited: 0, + total_converted: 0, + total_pending: 0, + total_expired: 0, + months_earned: 0, + months_available: 0, + }; + + const code = await this.getMyCode(tenantId); + + return { + code: code.code, + totalInvited: stats.total_invited, + totalConverted: stats.total_converted, + totalPending: stats.total_pending, + totalExpired: stats.total_expired, + monthsEarned: stats.months_earned, + monthsAvailable: stats.months_available, + }; + } + + // ==================== DISCOUNT FOR REFERRED ==================== + + async getReferredDiscount(tenantId: string): Promise { + const referral = await this.referralRepo.findOne({ + where: { + referredTenantId: tenantId, + referredDiscountApplied: false, + status: ReferralStatus.PENDING, + }, + }); + + if (!referral) { + return 0; + } + + // 50% discount for first month + return 50; + } + + async markDiscountApplied(tenantId: string): Promise { + const referral = await this.referralRepo.findOne({ + where: { referredTenantId: tenantId }, + }); + + if (referral) { + referral.referredDiscountApplied = true; + await this.referralRepo.save(referral); + } + } +} diff --git a/apps/backend/src/modules/widgets/widgets.controller.ts b/apps/backend/src/modules/widgets/widgets.controller.ts new file mode 100644 index 000000000..43bd948da --- /dev/null +++ b/apps/backend/src/modules/widgets/widgets.controller.ts @@ -0,0 +1,51 @@ +import { + Controller, + Get, + UseGuards, + Request, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { WidgetsService } from './widgets.service'; + +@ApiTags('widgets') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('v1/widget') +export class WidgetsController { + constructor(private readonly widgetsService: WidgetsService) {} + + @Get('summary') + @ApiOperation({ summary: 'Obtener resumen para widget (optimizado)' }) + getSummary(@Request() req) { + return this.widgetsService.getSummary(req.user.tenantId); + } + + @Get('alerts') + @ApiOperation({ summary: 'Obtener alertas para widget' }) + getAlerts(@Request() req) { + return this.widgetsService.getAlerts(req.user.tenantId); + } + + @Get('quick-actions') + @ApiOperation({ summary: 'Obtener acciones rapidas para widget' }) + getQuickActions() { + return this.widgetsService.getQuickActions(); + } + + @Get('full') + @ApiOperation({ summary: 'Obtener todos los datos del widget en una llamada' }) + async getFullWidgetData(@Request() req) { + const [summary, alerts, quickActions] = await Promise.all([ + this.widgetsService.getSummary(req.user.tenantId), + this.widgetsService.getAlerts(req.user.tenantId), + this.widgetsService.getQuickActions(), + ]); + + return { + summary, + alerts, + quickActions, + }; + } +} diff --git a/apps/backend/src/modules/widgets/widgets.module.ts b/apps/backend/src/modules/widgets/widgets.module.ts new file mode 100644 index 000000000..ba81a0e08 --- /dev/null +++ b/apps/backend/src/modules/widgets/widgets.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { WidgetsController } from './widgets.controller'; +import { WidgetsService } from './widgets.service'; + +@Module({ + controllers: [WidgetsController], + providers: [WidgetsService], + exports: [WidgetsService], +}) +export class WidgetsModule {} diff --git a/apps/backend/src/modules/widgets/widgets.service.ts b/apps/backend/src/modules/widgets/widgets.service.ts new file mode 100644 index 000000000..031f74c1f --- /dev/null +++ b/apps/backend/src/modules/widgets/widgets.service.ts @@ -0,0 +1,192 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +export interface WidgetSummary { + salesToday: number; + transactionsCount: number; + pendingOrders: number; + lowStockCount: number; + pendingCredits: number; + updatedAt: string; +} + +export interface WidgetAlert { + type: 'low_stock' | 'pending_order' | 'overdue_credit' | 'new_message'; + title: string; + message: string; + count: number; + priority: 'low' | 'medium' | 'high'; +} + +@Injectable() +export class WidgetsService { + constructor(private readonly dataSource: DataSource) {} + + /** + * Get lightweight summary data for widgets + * Optimized for quick loading on mobile widgets + */ + async getSummary(tenantId: string): Promise { + const today = new Date().toISOString().split('T')[0]; + + // Sales today + const salesResult = await this.dataSource.query( + `SELECT + COALESCE(SUM(total), 0) as total, + COUNT(*) as count + FROM sales.sales + WHERE tenant_id = $1 + AND DATE(created_at) = $2 + AND status = 'completed'`, + [tenantId, today], + ); + + // Pending orders + const ordersResult = await this.dataSource.query( + `SELECT COUNT(*) as count + FROM orders.orders + WHERE tenant_id = $1 + AND status IN ('pending', 'confirmed', 'preparing')`, + [tenantId], + ); + + // Low stock products + const stockResult = await this.dataSource.query( + `SELECT COUNT(*) as count + FROM catalog.products + WHERE tenant_id = $1 + AND stock <= min_stock + AND status = 'active'`, + [tenantId], + ); + + // Pending credits (fiado) + const creditsResult = await this.dataSource.query( + `SELECT COUNT(DISTINCT customer_id) as count + FROM customers.customer_credits + WHERE tenant_id = $1 + AND status = 'active' + AND balance > 0`, + [tenantId], + ); + + return { + salesToday: parseFloat(salesResult[0]?.total || '0'), + transactionsCount: parseInt(salesResult[0]?.count || '0'), + pendingOrders: parseInt(ordersResult[0]?.count || '0'), + lowStockCount: parseInt(stockResult[0]?.count || '0'), + pendingCredits: parseInt(creditsResult[0]?.count || '0'), + updatedAt: new Date().toISOString(), + }; + } + + /** + * Get alerts for widget display + */ + async getAlerts(tenantId: string): Promise { + const alerts: WidgetAlert[] = []; + + // Low stock alerts + const lowStock = await this.dataSource.query( + `SELECT name, stock, min_stock + FROM catalog.products + WHERE tenant_id = $1 + AND stock <= min_stock + AND status = 'active' + ORDER BY stock ASC + LIMIT 5`, + [tenantId], + ); + + if (lowStock.length > 0) { + alerts.push({ + type: 'low_stock', + title: 'Stock bajo', + message: lowStock.length === 1 + ? `${lowStock[0].name}: ${lowStock[0].stock} unidades` + : `${lowStock.length} productos con stock bajo`, + count: lowStock.length, + priority: lowStock.some((p: any) => p.stock === 0) ? 'high' : 'medium', + }); + } + + // Pending orders + const pendingOrders = await this.dataSource.query( + `SELECT COUNT(*) as count + FROM orders.orders + WHERE tenant_id = $1 + AND status = 'pending'`, + [tenantId], + ); + + if (parseInt(pendingOrders[0]?.count || '0') > 0) { + const count = parseInt(pendingOrders[0].count); + alerts.push({ + type: 'pending_order', + title: 'Pedidos pendientes', + message: count === 1 + ? '1 pedido por confirmar' + : `${count} pedidos por confirmar`, + count, + priority: count > 3 ? 'high' : 'medium', + }); + } + + // Overdue credits + const overdueCredits = await this.dataSource.query( + `SELECT COUNT(DISTINCT cc.customer_id) as count + FROM customers.customer_credits cc + WHERE cc.tenant_id = $1 + AND cc.status = 'active' + AND cc.due_date < CURRENT_DATE`, + [tenantId], + ); + + if (parseInt(overdueCredits[0]?.count || '0') > 0) { + const count = parseInt(overdueCredits[0].count); + alerts.push({ + type: 'overdue_credit', + title: 'Fiados vencidos', + message: count === 1 + ? '1 cliente con fiado vencido' + : `${count} clientes con fiado vencido`, + count, + priority: 'high', + }); + } + + return alerts; + } + + /** + * Get quick actions configuration for the widget + */ + getQuickActions() { + return [ + { + id: 'new_sale', + title: 'Nueva Venta', + icon: 'cart', + deepLink: 'michangarrito://pos/new', + }, + { + id: 'scan_product', + title: 'Escanear Producto', + icon: 'barcode', + deepLink: 'michangarrito://scan', + }, + { + id: 'view_orders', + title: 'Ver Pedidos', + icon: 'clipboard', + deepLink: 'michangarrito://orders', + }, + { + id: 'view_inventory', + title: 'Ver Inventario', + icon: 'box', + deepLink: 'michangarrito://inventory', + }, + ]; + } +} diff --git a/apps/frontend/e2e/auth.spec.ts b/apps/frontend/e2e/auth.spec.ts new file mode 100644 index 000000000..0c7f85cca --- /dev/null +++ b/apps/frontend/e2e/auth.spec.ts @@ -0,0 +1,144 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E Tests - Autenticacion + * MiChangarrito Frontend + */ + +test.describe('Autenticacion', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login'); + }); + + test('debe mostrar la pagina de login', async ({ page }) => { + // Verificar titulo + await expect(page.locator('h1')).toContainText('MiChangarrito'); + await expect(page.locator('h2')).toContainText('Iniciar sesion'); + + // Verificar campos del formulario + await expect(page.locator('input[name="phone"]')).toBeVisible(); + await expect(page.locator('input[name="pin"]')).toBeVisible(); + await expect(page.locator('button[type="submit"]')).toBeVisible(); + + // Verificar link a registro + await expect(page.locator('a[href="/register"]')).toBeVisible(); + }); + + test('debe validar campos requeridos', async ({ page }) => { + // Intentar enviar formulario vacio + await page.locator('button[type="submit"]').click(); + + // Los campos tienen required, el navegador debe validar + const phoneInput = page.locator('input[name="phone"]'); + await expect(phoneInput).toHaveAttribute('required', ''); + }); + + test('debe aceptar solo numeros en telefono', async ({ page }) => { + const phoneInput = page.locator('input[name="phone"]'); + + // Escribir texto con letras + await phoneInput.fill('abc123def456'); + + // Debe filtrar solo numeros + await expect(phoneInput).toHaveValue('123456'); + }); + + test('debe limitar telefono a 10 digitos', async ({ page }) => { + const phoneInput = page.locator('input[name="phone"]'); + + await phoneInput.fill('12345678901234'); + + // maxLength=10 + await expect(phoneInput).toHaveAttribute('maxLength', '10'); + }); + + test('debe aceptar solo numeros en PIN', async ({ page }) => { + const pinInput = page.locator('input[name="pin"]'); + + await pinInput.fill('abc1234'); + + // Debe filtrar solo numeros + await expect(pinInput).toHaveValue('1234'); + }); + + test('debe mostrar error con credenciales invalidas', async ({ page }) => { + // Llenar formulario con datos invalidos + await page.locator('input[name="phone"]').fill('5500000000'); + await page.locator('input[name="pin"]').fill('0000'); + + // Enviar + await page.locator('button[type="submit"]').click(); + + // Esperar respuesta del servidor + await page.waitForTimeout(1000); + + // Debe mostrar mensaje de error (si el backend esta corriendo) + // Si no hay backend, verificamos que el boton vuelve a estar habilitado + const submitButton = page.locator('button[type="submit"]'); + await expect(submitButton).not.toBeDisabled(); + }); + + test('debe mostrar estado de carga al enviar', async ({ page }) => { + // Llenar formulario + await page.locator('input[name="phone"]').fill('5512345678'); + await page.locator('input[name="pin"]').fill('1234'); + + // Enviar y verificar estado de carga + const submitButton = page.locator('button[type="submit"]'); + await submitButton.click(); + + // Verificar que muestra "Ingresando..." mientras carga + // Nota: Esto es rapido, puede no capturarse siempre + await expect(submitButton).toContainText(/Ingresar|Ingresando/); + }); + + test('debe navegar a registro', async ({ page }) => { + await page.locator('a[href="/register"]').click(); + + await expect(page).toHaveURL('/register'); + }); + + test('debe redirigir a login si no esta autenticado', async ({ page }) => { + // Intentar acceder a ruta protegida + await page.goto('/dashboard'); + + // Debe redirigir a login + await expect(page).toHaveURL('/login'); + }); + + test('debe redirigir a login desde rutas protegidas', async ({ page }) => { + const protectedRoutes = [ + '/dashboard', + '/products', + '/orders', + '/customers', + '/fiado', + '/inventory', + '/settings', + ]; + + for (const route of protectedRoutes) { + await page.goto(route); + await expect(page).toHaveURL('/login'); + } + }); +}); + +test.describe('Registro', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/register'); + }); + + test('debe mostrar la pagina de registro', async ({ page }) => { + await expect(page.locator('h1')).toContainText('MiChangarrito'); + + // Verificar link a login + await expect(page.locator('a[href="/login"]')).toBeVisible(); + }); + + test('debe navegar a login desde registro', async ({ page }) => { + await page.locator('a[href="/login"]').click(); + + await expect(page).toHaveURL('/login'); + }); +}); diff --git a/apps/frontend/e2e/fixtures/test-data.ts b/apps/frontend/e2e/fixtures/test-data.ts new file mode 100644 index 000000000..dd5aad627 --- /dev/null +++ b/apps/frontend/e2e/fixtures/test-data.ts @@ -0,0 +1,48 @@ +/** + * Test Data Fixtures - MiChangarrito E2E Tests + */ + +export const TEST_USER = { + phone: '5512345678', + pin: '1234', + name: 'Usuario Test', + businessName: 'Tienda Test', +}; + +export const TEST_PRODUCT = { + name: 'Coca-Cola 600ml', + sku: 'CC600', + barcode: '7501055300000', + price: 18.00, + cost: 12.00, + stock: 50, + category: 'Bebidas', +}; + +export const TEST_CUSTOMER = { + name: 'Juan Perez', + phone: '5598765432', + email: 'juan@test.com', + creditLimit: 500, +}; + +export const TEST_ORDER = { + items: [ + { productId: '1', quantity: 2, price: 18.00 }, + { productId: '2', quantity: 1, price: 25.00 }, + ], + total: 61.00, + paymentMethod: 'cash', +}; + +export const ROUTES = { + login: '/login', + register: '/register', + dashboard: '/dashboard', + products: '/products', + orders: '/orders', + customers: '/customers', + fiado: '/fiado', + inventory: '/inventory', + settings: '/settings', +}; diff --git a/apps/frontend/e2e/navigation.spec.ts b/apps/frontend/e2e/navigation.spec.ts new file mode 100644 index 000000000..eca032bfc --- /dev/null +++ b/apps/frontend/e2e/navigation.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E Tests - Navegacion + * MiChangarrito Frontend + * + * Nota: Estos tests requieren autenticacion + * Se usan con un usuario de prueba pre-configurado + */ + +test.describe('Navegacion Publica', () => { + test('debe cargar la aplicacion', async ({ page }) => { + await page.goto('/'); + + // Al no estar autenticado, debe redirigir a login + await expect(page).toHaveURL('/login'); + }); + + test('rutas invalidas redirigen a login', async ({ page }) => { + await page.goto('/ruta-que-no-existe'); + + await expect(page).toHaveURL('/login'); + }); + + test('login page tiene titulo correcto', async ({ page }) => { + await page.goto('/login'); + + await expect(page).toHaveTitle(/MiChangarrito/i); + }); +}); + +test.describe('UI Responsiva', () => { + test('login se adapta a mobile', async ({ page }) => { + // Viewport mobile + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/login'); + + // El formulario debe ser visible y usable + await expect(page.locator('form')).toBeVisible(); + await expect(page.locator('input[name="phone"]')).toBeVisible(); + await expect(page.locator('input[name="pin"]')).toBeVisible(); + await expect(page.locator('button[type="submit"]')).toBeVisible(); + }); + + test('login se adapta a tablet', async ({ page }) => { + // Viewport tablet + await page.setViewportSize({ width: 768, height: 1024 }); + await page.goto('/login'); + + await expect(page.locator('form')).toBeVisible(); + }); + + test('login se adapta a desktop', async ({ page }) => { + // Viewport desktop + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.goto('/login'); + + await expect(page.locator('form')).toBeVisible(); + }); +}); + +test.describe('Accesibilidad', () => { + test('campos tienen labels', async ({ page }) => { + await page.goto('/login'); + + // Verificar que los inputs tienen labels asociados + const phoneLabel = page.locator('label[for="phone"]'); + const pinLabel = page.locator('label[for="pin"]'); + + await expect(phoneLabel).toBeVisible(); + await expect(pinLabel).toBeVisible(); + }); + + test('formulario es navegable con teclado', async ({ page }) => { + await page.goto('/login'); + + // Tab al primer campo + await page.keyboard.press('Tab'); + const phoneInput = page.locator('input[name="phone"]'); + await expect(phoneInput).toBeFocused(); + + // Tab al segundo campo + await page.keyboard.press('Tab'); + const pinInput = page.locator('input[name="pin"]'); + await expect(pinInput).toBeFocused(); + + // Tab al boton + await page.keyboard.press('Tab'); + const submitButton = page.locator('button[type="submit"]'); + await expect(submitButton).toBeFocused(); + }); + + test('formulario puede enviarse con Enter', async ({ page }) => { + await page.goto('/login'); + + // Llenar campos + await page.locator('input[name="phone"]').fill('5512345678'); + await page.locator('input[name="pin"]').fill('1234'); + + // Enviar con Enter + await page.keyboard.press('Enter'); + + // El formulario debe intentar enviarse (el boton estara en estado loading) + const submitButton = page.locator('button[type="submit"]'); + // Puede estar disabled durante el envio o ya haber terminado + await page.waitForTimeout(500); + }); +}); + +test.describe('Performance', () => { + test('login carga en menos de 3 segundos', async ({ page }) => { + const startTime = Date.now(); + + await page.goto('/login'); + await expect(page.locator('h1')).toBeVisible(); + + const loadTime = Date.now() - startTime; + expect(loadTime).toBeLessThan(3000); + }); +}); diff --git a/apps/frontend/e2e/orders.spec.ts b/apps/frontend/e2e/orders.spec.ts new file mode 100644 index 000000000..7d36151ca --- /dev/null +++ b/apps/frontend/e2e/orders.spec.ts @@ -0,0 +1,149 @@ +import { test, expect } from '@playwright/test'; +import { TEST_ORDER, TEST_CUSTOMER, ROUTES } from './fixtures/test-data'; + +/** + * E2E Tests - Pedidos y Fiado + * MiChangarrito Frontend + * + * Nota: Estos tests requieren autenticacion previa + * y un backend corriendo con datos de prueba + */ + +test.describe('Pedidos - Sin Autenticacion', () => { + test('redirige a login si no esta autenticado', async ({ page }) => { + await page.goto(ROUTES.orders); + await expect(page).toHaveURL('/login'); + }); + + test('fiado redirige a login sin autenticacion', async ({ page }) => { + await page.goto(ROUTES.fiado); + await expect(page).toHaveURL('/login'); + }); + + test('clientes redirige a login sin autenticacion', async ({ page }) => { + await page.goto(ROUTES.customers); + await expect(page).toHaveURL('/login'); + }); +}); + +test.describe('Pedidos - Estructura de Datos', () => { + test('datos de pedido de prueba tienen estructura correcta', () => { + expect(TEST_ORDER.items).toBeInstanceOf(Array); + expect(TEST_ORDER.items.length).toBeGreaterThan(0); + expect(TEST_ORDER.total).toBeGreaterThan(0); + expect(TEST_ORDER.paymentMethod).toBeDefined(); + + // Verificar estructura de items + const item = TEST_ORDER.items[0]; + expect(item.productId).toBeDefined(); + expect(item.quantity).toBeGreaterThan(0); + expect(item.price).toBeGreaterThan(0); + }); + + test('datos de cliente de prueba tienen estructura correcta', () => { + expect(TEST_CUSTOMER.name).toBeDefined(); + expect(TEST_CUSTOMER.phone).toBeDefined(); + expect(TEST_CUSTOMER.phone.length).toBe(10); + expect(TEST_CUSTOMER.creditLimit).toBeGreaterThanOrEqual(0); + }); + + test('total del pedido es correcto', () => { + // Verificar calculo: 2 * 18.00 + 1 * 25.00 = 61.00 + const calculatedTotal = TEST_ORDER.items.reduce( + (sum, item) => sum + item.quantity * item.price, + 0 + ); + expect(calculatedTotal).toBe(TEST_ORDER.total); + }); +}); + +test.describe('Sistema de Fiado', () => { + test('rutas de fiado protegidas', async ({ page }) => { + const fiadoRoutes = [ + '/fiado', + '/customers', + ]; + + for (const route of fiadoRoutes) { + await page.goto(route); + await expect(page).toHaveURL('/login'); + } + }); +}); + +test.describe('Historial de Pedidos', () => { + test('ruta de pedidos protegida', async ({ page }) => { + await page.goto('/orders'); + await expect(page).toHaveURL('/login'); + }); + + test('ruta de dashboard protegida', async ({ page }) => { + await page.goto(ROUTES.dashboard); + await expect(page).toHaveURL('/login'); + }); +}); + +test.describe('Clientes', () => { + test('ruta de clientes protegida', async ({ page }) => { + await page.goto('/customers'); + await expect(page).toHaveURL('/login'); + }); + + test('validacion de limite de credito', () => { + // El limite de credito debe ser un numero positivo o cero + expect(TEST_CUSTOMER.creditLimit).toBeGreaterThanOrEqual(0); + expect(typeof TEST_CUSTOMER.creditLimit).toBe('number'); + }); +}); + +test.describe('Metodos de Pago', () => { + test('metodo de pago valido', () => { + const validPaymentMethods = ['cash', 'card', 'transfer', 'fiado']; + expect(validPaymentMethods).toContain(TEST_ORDER.paymentMethod); + }); +}); + +test.describe('Configuracion', () => { + test('ruta de configuracion protegida', async ({ page }) => { + await page.goto(ROUTES.settings); + await expect(page).toHaveURL('/login'); + }); +}); + +test.describe('Rutas Completas', () => { + test('todas las rutas definidas en fixtures existen', () => { + const expectedRoutes = [ + 'login', + 'register', + 'dashboard', + 'products', + 'orders', + 'customers', + 'fiado', + 'inventory', + 'settings', + ]; + + for (const route of expectedRoutes) { + expect(ROUTES[route as keyof typeof ROUTES]).toBeDefined(); + expect(ROUTES[route as keyof typeof ROUTES]).toMatch(/^\//); + } + }); + + test('todas las rutas protegidas redirigen a login', async ({ page }) => { + const protectedRoutes = [ + ROUTES.dashboard, + ROUTES.products, + ROUTES.orders, + ROUTES.customers, + ROUTES.fiado, + ROUTES.inventory, + ROUTES.settings, + ]; + + for (const route of protectedRoutes) { + await page.goto(route); + await expect(page).toHaveURL('/login'); + } + }); +}); diff --git a/apps/frontend/e2e/pos.spec.ts b/apps/frontend/e2e/pos.spec.ts new file mode 100644 index 000000000..462fd5e61 --- /dev/null +++ b/apps/frontend/e2e/pos.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test'; +import { TEST_PRODUCT, ROUTES } from './fixtures/test-data'; + +/** + * E2E Tests - Punto de Venta (POS) + * MiChangarrito Frontend + * + * Nota: Estos tests requieren autenticacion previa + * y un backend corriendo con datos de prueba + */ + +test.describe('Punto de Venta - Sin Autenticacion', () => { + test('redirige a login si no esta autenticado', async ({ page }) => { + await page.goto('/pos'); + await expect(page).toHaveURL('/login'); + }); +}); + +test.describe('Punto de Venta - UI Basica', () => { + // Estos tests verifican la estructura esperada del POS + // cuando el usuario esta autenticado + + test.beforeEach(async ({ page }) => { + // Mock de autenticacion para tests + // En un escenario real, se usaria un fixture de login + await page.goto('/login'); + }); + + test('pagina de productos existe', async ({ page }) => { + await page.goto(ROUTES.products); + // Sin auth, redirige a login + await expect(page).toHaveURL('/login'); + }); +}); + +test.describe('Punto de Venta - Flujo de Venta', () => { + // Tests de flujo completo de venta + // Requieren setup de autenticacion + + test('estructura del formulario de busqueda', async ({ page }) => { + await page.goto('/login'); + + // Verificar que la pagina de login tiene estructura correcta + // antes de poder probar el POS + await expect(page.locator('form')).toBeVisible(); + }); +}); + +test.describe('Carrito de Compras', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login'); + }); + + test('login tiene campos necesarios para acceder al POS', async ({ page }) => { + // Verificar campos necesarios + await expect(page.locator('input[name="phone"]')).toBeVisible(); + await expect(page.locator('input[name="pin"]')).toBeVisible(); + await expect(page.locator('button[type="submit"]')).toBeVisible(); + }); +}); + +test.describe('Busqueda de Productos', () => { + test('rutas de productos protegidas', async ({ page }) => { + // Verificar que todas las rutas relacionadas al POS estan protegidas + const posRoutes = [ + '/products', + '/pos', + '/inventory', + ]; + + for (const route of posRoutes) { + await page.goto(route); + await expect(page).toHaveURL('/login'); + } + }); +}); + +test.describe('Metodos de Pago', () => { + test('verificar existencia de ruta de pedidos', async ({ page }) => { + await page.goto(ROUTES.orders); + // Sin auth, redirige + await expect(page).toHaveURL('/login'); + }); +}); + +test.describe('Recibos y Tickets', () => { + test('estructura de datos de producto de prueba', () => { + // Verificar que los datos de prueba tienen la estructura correcta + expect(TEST_PRODUCT.name).toBeDefined(); + expect(TEST_PRODUCT.price).toBeGreaterThan(0); + expect(TEST_PRODUCT.cost).toBeGreaterThan(0); + expect(TEST_PRODUCT.stock).toBeGreaterThanOrEqual(0); + expect(TEST_PRODUCT.sku).toBeDefined(); + expect(TEST_PRODUCT.barcode).toBeDefined(); + }); +}); + +test.describe('Inventario desde POS', () => { + test('ruta de inventario protegida', async ({ page }) => { + await page.goto(ROUTES.inventory); + await expect(page).toHaveURL('/login'); + }); +}); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index d9cb72b9c..b76509ffa 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -7,7 +7,11 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:report": "playwright show-report" }, "dependencies": { "@tanstack/react-query": "^5.90.16", @@ -19,6 +23,7 @@ "react-router-dom": "^7.11.0" }, "devDependencies": { + "@playwright/test": "^1.50.1", "@eslint/js": "^9.39.1", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/postcss": "^4.1.18", diff --git a/apps/frontend/playwright.config.ts b/apps/frontend/playwright.config.ts new file mode 100644 index 000000000..cb556df9a --- /dev/null +++ b/apps/frontend/playwright.config.ts @@ -0,0 +1,53 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright E2E Test Configuration - MiChangarrito Frontend + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html', { outputFolder: 'playwright-report' }], + ['list'], + ], + use: { + baseURL: 'http://localhost:3140', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, + ], + + webServer: { + command: 'npm run dev', + url: 'http://localhost:3140', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 097d32e12..6c79a46f2 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -9,6 +9,9 @@ import { Customers } from './pages/Customers'; import { Fiado } from './pages/Fiado'; import { Inventory } from './pages/Inventory'; import { Settings } from './pages/Settings'; +import { Referrals } from './pages/Referrals'; +import { Invoices } from './pages/Invoices'; +import { Marketplace } from './pages/Marketplace'; import Login from './pages/Login'; import Register from './pages/Register'; @@ -73,6 +76,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/apps/frontend/src/components/Layout.tsx b/apps/frontend/src/components/Layout.tsx index a1857d7d1..174cc3ecb 100644 --- a/apps/frontend/src/components/Layout.tsx +++ b/apps/frontend/src/components/Layout.tsx @@ -11,6 +11,9 @@ import { X, Store, LogOut, + Gift, + FileText, + Truck, } from 'lucide-react'; import { useState } from 'react'; import clsx from 'clsx'; @@ -23,6 +26,9 @@ const navigation = [ { name: 'Clientes', href: '/customers', icon: Users }, { name: 'Fiado', href: '/fiado', icon: CreditCard }, { name: 'Inventario', href: '/inventory', icon: Boxes }, + { name: 'Facturacion', href: '/invoices', icon: FileText }, + { name: 'Proveedores', href: '/marketplace', icon: Truck }, + { name: 'Referidos', href: '/referrals', icon: Gift }, { name: 'Ajustes', href: '/settings', icon: Settings }, ]; diff --git a/apps/frontend/src/components/payments/ClabeDisplay.tsx b/apps/frontend/src/components/payments/ClabeDisplay.tsx new file mode 100644 index 000000000..49c630903 --- /dev/null +++ b/apps/frontend/src/components/payments/ClabeDisplay.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Copy, Check, Building } from 'lucide-react'; +import { codiSpeiApi } from '../../lib/api'; + +interface ClabeDisplayProps { + showCreateButton?: boolean; + beneficiaryName?: string; +} + +export function ClabeDisplay({ showCreateButton = true, beneficiaryName }: ClabeDisplayProps) { + const [copied, setCopied] = useState(false); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['clabe'], + queryFn: async () => { + const res = await codiSpeiApi.getClabe(); + return res.data; + }, + }); + + const createMutation = useMutation({ + mutationFn: (name: string) => codiSpeiApi.createClabe(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['clabe'] }); + }, + }); + + const copyClabe = async () => { + if (data?.clabe) { + await navigator.clipboard.writeText(data.clabe); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const formatClabe = (clabe: string) => { + // Format: XXX XXX XXXX XXXX XXXX + return clabe.replace(/(\d{3})(\d{3})(\d{4})(\d{4})(\d{4})/, '$1 $2 $3 $4 $5'); + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!data?.clabe && showCreateButton) { + return ( +
+
+ +
+

CLABE Virtual

+

Recibe transferencias SPEI

+
+
+ +
+ ); + } + + if (!data?.clabe) { + return null; + } + + return ( +
+
+
+ + Tu CLABE para recibir SPEI +
+ +
+ +
+

+ {formatClabe(data.clabe)} +

+ {data.beneficiaryName && ( +

+ {data.beneficiaryName} +

+ )} +
+ +

+ Las transferencias se reflejan automaticamente +

+
+ ); +} diff --git a/apps/frontend/src/components/payments/CodiQR.tsx b/apps/frontend/src/components/payments/CodiQR.tsx new file mode 100644 index 000000000..741606691 --- /dev/null +++ b/apps/frontend/src/components/payments/CodiQR.tsx @@ -0,0 +1,167 @@ +import { useState, useEffect } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { QrCode, Clock, Check, X, RefreshCw } from 'lucide-react'; +import { codiSpeiApi } from '../../lib/api'; + +interface CodiQRProps { + amount: number; + description?: string; + saleId?: string; + onSuccess?: () => void; + onCancel?: () => void; +} + +export function CodiQR({ amount, description, saleId, onSuccess, onCancel }: CodiQRProps) { + const [transactionId, setTransactionId] = useState(null); + const [timeLeft, setTimeLeft] = useState(300); // 5 minutes in seconds + + const generateMutation = useMutation({ + mutationFn: () => codiSpeiApi.generateQr({ amount, description, saleId }), + onSuccess: (res) => { + setTransactionId(res.data.id); + setTimeLeft(300); + }, + }); + + const { data: status, refetch } = useQuery({ + queryKey: ['codi-status', transactionId], + queryFn: async () => { + if (!transactionId) return null; + const res = await codiSpeiApi.getCodiStatus(transactionId); + return res.data; + }, + enabled: !!transactionId, + refetchInterval: 3000, // Poll every 3 seconds + }); + + // Generate QR on mount + useEffect(() => { + generateMutation.mutate(); + }, []); + + // Countdown timer + useEffect(() => { + if (timeLeft <= 0) return; + + const timer = setInterval(() => { + setTimeLeft((prev) => prev - 1); + }, 1000); + + return () => clearInterval(timer); + }, [timeLeft]); + + // Handle status changes + useEffect(() => { + if (status?.status === 'confirmed') { + onSuccess?.(); + } else if (status?.status === 'expired' || status?.status === 'cancelled') { + // QR expired or cancelled + } + }, [status?.status]); + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const handleRefresh = () => { + generateMutation.mutate(); + }; + + if (generateMutation.isPending) { + return ( +
+
+

Generando QR de cobro...

+
+ ); + } + + if (status?.status === 'confirmed') { + return ( +
+
+ +
+

Pago Confirmado

+

El pago de ${amount.toFixed(2)} fue recibido

+
+ ); + } + + if (status?.status === 'expired' || timeLeft <= 0) { + return ( +
+
+ +
+

QR Expirado

+

El codigo QR ha expirado

+ +
+ ); + } + + return ( +
+
+

Pagar con CoDi

+

${amount.toFixed(2)}

+ {description &&

{description}

} +
+ + {/* QR Code Display */} +
+
+ {/* In production, generate actual QR code from qrData */} +
+ +

Escanea con tu app bancaria

+
+
+
+ + {/* Timer */} +
+ + Expira en: {formatTime(timeLeft)} +
+ + {/* Instructions */} +
+

1. Abre la app de tu banco

+

2. Escanea el codigo QR

+

3. Confirma el pago

+
+ + {/* Actions */} +
+ + +
+ + {/* Reference */} + {status?.reference && ( +

Ref: {status.reference}

+ )} +
+ ); +} diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index 69de1a6b2..65ac10f13 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -129,3 +129,88 @@ export const dashboardApi = { getSalesChart: (period: string) => api.get('/dashboard/sales', { params: { period } }), getTopProducts: () => api.get('/dashboard/top-products'), }; + +// Referrals API +export const referralsApi = { + getMyCode: () => api.get('/referrals/my-code'), + generateCode: () => api.post('/referrals/generate-code'), + validateCode: (code: string) => api.get(`/referrals/validate/${code}`), + applyCode: (code: string) => api.post('/referrals/apply-code', { code }), + getMyReferrals: () => api.get('/referrals/list'), + getStats: () => api.get('/referrals/stats'), + getRewards: () => api.get('/referrals/rewards'), + getAvailableMonths: () => api.get('/referrals/rewards/available-months'), + getDiscount: () => api.get('/referrals/discount'), +}; + +// CoDi/SPEI API +export const codiSpeiApi = { + // CoDi + generateQr: (data: { amount: number; description?: string; saleId?: string }) => + api.post('/codi/generate-qr', data), + getCodiStatus: (id: string) => api.get(`/codi/status/${id}`), + getCodiTransactions: (limit?: number) => + api.get('/codi/transactions', { params: { limit } }), + + // SPEI + getClabe: () => api.get('/spei/clabe'), + createClabe: (beneficiaryName: string) => + api.post('/spei/create-clabe', { beneficiaryName }), + getSpeiTransactions: (limit?: number) => + api.get('/spei/transactions', { params: { limit } }), + + // Summary + getSummary: (date?: string) => + api.get('/payments/summary', { params: { date } }), +}; + +// Invoices API (SAT/CFDI) +export const invoicesApi = { + // Tax Config + getTaxConfig: () => api.get('/invoices/tax-config'), + saveTaxConfig: (data: any) => api.post('/invoices/tax-config', data), + + // Invoices + getAll: (params?: { status?: string; from?: string; to?: string; limit?: number }) => + api.get('/invoices', { params }), + getById: (id: string) => api.get(`/invoices/${id}`), + create: (data: any) => api.post('/invoices', data), + stamp: (id: string) => api.post(`/invoices/${id}/stamp`), + cancel: (id: string, reason: string, uuidReplacement?: string) => + api.post(`/invoices/${id}/cancel`, { reason, uuidReplacement }), + send: (id: string, email?: string) => + api.post(`/invoices/${id}/send`, { email }), + getSummary: (month?: string) => + api.get('/invoices/summary', { params: { month } }), +}; + +// Marketplace API +export const marketplaceApi = { + // Suppliers + getSuppliers: (params?: { category?: string; zipCode?: string; search?: string; limit?: number }) => + api.get('/marketplace/suppliers', { params }), + getSupplier: (id: string) => api.get(`/marketplace/suppliers/${id}`), + getSupplierProducts: (id: string, params?: { category?: string; search?: string }) => + api.get(`/marketplace/suppliers/${id}/products`, { params }), + getSupplierReviews: (id: string, params?: { limit?: number }) => + api.get(`/marketplace/suppliers/${id}/reviews`, { params }), + + // Orders + createOrder: (data: any) => api.post('/marketplace/orders', data), + getOrders: (params?: { status?: string; supplierId?: string; limit?: number }) => + api.get('/marketplace/orders', { params }), + getOrder: (id: string) => api.get(`/marketplace/orders/${id}`), + cancelOrder: (id: string, reason: string) => + api.put(`/marketplace/orders/${id}/cancel`, { reason }), + + // Reviews + createReview: (data: any) => api.post('/marketplace/reviews', data), + + // Favorites + getFavorites: () => api.get('/marketplace/favorites'), + addFavorite: (supplierId: string) => api.post(`/marketplace/favorites/${supplierId}`), + removeFavorite: (supplierId: string) => api.delete(`/marketplace/favorites/${supplierId}`), + + // Stats + getStats: () => api.get('/marketplace/stats'), +}; diff --git a/apps/frontend/src/lib/i18n.ts b/apps/frontend/src/lib/i18n.ts new file mode 100644 index 000000000..d79338bb8 --- /dev/null +++ b/apps/frontend/src/lib/i18n.ts @@ -0,0 +1,72 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +// Import translations +import esMX from '../locales/es-MX'; +import esCO from '../locales/es-CO'; +import esAR from '../locales/es-AR'; +import ptBR from '../locales/pt-BR'; + +const resources = { + 'es-MX': { translation: esMX }, + 'es-CO': { translation: esCO }, + 'es-AR': { translation: esAR }, + 'pt-BR': { translation: ptBR }, +}; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'es-MX', + defaultNS: 'translation', + interpolation: { + escapeValue: false, + }, + detection: { + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + }, + }); + +export default i18n; + +// Currency formatting by locale +export const currencyConfig: Record = { + 'es-MX': { currency: 'MXN', symbol: '$' }, + 'es-CO': { currency: 'COP', symbol: '$' }, + 'es-AR': { currency: 'ARS', symbol: '$' }, + 'pt-BR': { currency: 'BRL', symbol: 'R$' }, +}; + +export function formatCurrency(amount: number, locale: string = 'es-MX'): string { + const config = currencyConfig[locale] || currencyConfig['es-MX']; + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: config.currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); +} + +export function formatDate(date: Date | string, locale: string = 'es-MX'): string { + const d = typeof date === 'string' ? new Date(date) : date; + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + year: 'numeric', + }).format(d); +} + +export function formatDateTime(date: Date | string, locale: string = 'es-MX'): string { + const d = typeof date === 'string' ? new Date(date) : date; + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(d); +} diff --git a/apps/frontend/src/locales/es-AR/index.ts b/apps/frontend/src/locales/es-AR/index.ts new file mode 100644 index 000000000..ed36f710b --- /dev/null +++ b/apps/frontend/src/locales/es-AR/index.ts @@ -0,0 +1,63 @@ +import esMX from '../es-MX'; + +// Argentina Spanish - Override specific terms +const esAR = { + ...esMX, + + // Navigation overrides + nav: { + ...esMX.nav, + // Same in Argentina + }, + + // Auth overrides + auth: { + ...esMX.auth, + phone: 'Celular', + enterPhone: 'Ingresa tu celular', + }, + + // Dashboard overrides + dashboard: { + ...esMX.dashboard, + // Same in Argentina + }, + + // Products overrides - Argentine terminology + products: { + ...esMX.products, + subtitle: 'Administra tu stock', + stock: 'Stock', + }, + + // Fiado overrides - Argentine terminology + fiado: { + ...esMX.fiado, + title: 'Cuenta', + subtitle: 'Control de cuentas corrientes', + registerPayment: 'Registrar pago', + }, + + // Payments overrides + payments: { + ...esMX.payments, + fiado: 'Cuenta corriente', + transfer: 'Transferencia', + change: 'Vuelto', + }, + + // Business Types - Argentine terminology + businessTypes: { + tienda: 'Almacen', + papeleria: 'Libreria', + farmacia: 'Farmacia', + ferreteria: 'Ferreteria', + carniceria: 'Carniceria', + verduleria: 'Verduleria', + panaderia: 'Panaderia', + tortilleria: 'Rotiseria', + otro: 'Otro', + }, +}; + +export default esAR; diff --git a/apps/frontend/src/locales/es-CO/index.ts b/apps/frontend/src/locales/es-CO/index.ts new file mode 100644 index 000000000..0e375b0d9 --- /dev/null +++ b/apps/frontend/src/locales/es-CO/index.ts @@ -0,0 +1,60 @@ +import esMX from '../es-MX'; + +// Colombia Spanish - Override specific terms +const esCO = { + ...esMX, + + // Navigation overrides + nav: { + ...esMX.nav, + // Same in Colombia + }, + + // Auth overrides + auth: { + ...esMX.auth, + phone: 'Celular', + enterPhone: 'Ingresa tu celular', + }, + + // Dashboard overrides + dashboard: { + ...esMX.dashboard, + // Same in Colombia + }, + + // Products overrides - Colombian terminology + products: { + ...esMX.products, + subtitle: 'Administra tu inventario', + }, + + // Fiado overrides + fiado: { + ...esMX.fiado, + title: 'Credito', + subtitle: 'Control de creditos a clientes', + }, + + // Payments overrides + payments: { + ...esMX.payments, + fiado: 'Credito', + transfer: 'Transferencia bancaria', + }, + + // Business Types - Colombian terminology + businessTypes: { + tienda: 'Tienda', + papeleria: 'Papeleria', + farmacia: 'Drogueria', + ferreteria: 'Ferreteria', + carniceria: 'Carniceria', + verduleria: 'Fruteria', + panaderia: 'Panaderia', + tortilleria: 'Areperia', + otro: 'Otro', + }, +}; + +export default esCO; diff --git a/apps/frontend/src/locales/es-MX/index.ts b/apps/frontend/src/locales/es-MX/index.ts new file mode 100644 index 000000000..d5dab5dbc --- /dev/null +++ b/apps/frontend/src/locales/es-MX/index.ts @@ -0,0 +1,231 @@ +export default { + // Common + common: { + save: 'Guardar', + cancel: 'Cancelar', + delete: 'Eliminar', + edit: 'Editar', + add: 'Agregar', + search: 'Buscar', + loading: 'Cargando...', + error: 'Error', + success: 'Exito', + confirm: 'Confirmar', + back: 'Volver', + next: 'Siguiente', + close: 'Cerrar', + yes: 'Si', + no: 'No', + all: 'Todos', + none: 'Ninguno', + required: 'Requerido', + optional: 'Opcional', + }, + + // Navigation + nav: { + dashboard: 'Dashboard', + products: 'Productos', + orders: 'Pedidos', + customers: 'Clientes', + fiado: 'Fiado', + inventory: 'Inventario', + referrals: 'Referidos', + settings: 'Ajustes', + logout: 'Cerrar sesion', + }, + + // Auth + auth: { + login: 'Iniciar sesion', + register: 'Registrarse', + phone: 'Telefono', + pin: 'PIN', + enterPhone: 'Ingresa tu telefono', + enterPin: 'Ingresa tu PIN', + forgotPin: 'Olvide mi PIN', + noAccount: 'No tienes cuenta?', + hasAccount: 'Ya tienes cuenta?', + createAccount: 'Crear cuenta', + businessName: 'Nombre del negocio', + ownerName: 'Tu nombre', + businessType: 'Tipo de negocio', + }, + + // Dashboard + dashboard: { + title: 'Dashboard', + todaySales: 'Ventas de hoy', + weekSales: 'Ventas de la semana', + monthSales: 'Ventas del mes', + transactions: 'transacciones', + lowStock: 'Stock bajo', + pendingOrders: 'Pedidos pendientes', + pendingCredits: 'Fiados pendientes', + topProducts: 'Productos mas vendidos', + }, + + // Products + products: { + title: 'Productos', + subtitle: 'Administra tu catalogo', + addProduct: 'Agregar producto', + editProduct: 'Editar producto', + name: 'Nombre', + price: 'Precio', + cost: 'Costo', + stock: 'Stock', + sku: 'SKU', + barcode: 'Codigo de barras', + category: 'Categoria', + description: 'Descripcion', + noProducts: 'No hay productos', + scanBarcode: 'Escanear codigo', + }, + + // Orders + orders: { + title: 'Pedidos', + subtitle: 'Gestiona los pedidos', + newOrder: 'Nuevo pedido', + orderNumber: 'Pedido #{{number}}', + status: 'Estado', + pending: 'Pendiente', + preparing: 'Preparando', + ready: 'Listo', + delivered: 'Entregado', + cancelled: 'Cancelado', + total: 'Total', + items: 'articulos', + noOrders: 'No hay pedidos', + }, + + // Customers + customers: { + title: 'Clientes', + subtitle: 'Administra tus clientes', + addCustomer: 'Agregar cliente', + name: 'Nombre', + phone: 'Telefono', + email: 'Correo', + creditLimit: 'Limite de credito', + currentBalance: 'Saldo actual', + noCustomers: 'No hay clientes', + }, + + // Fiado (Credit) + fiado: { + title: 'Fiado', + subtitle: 'Control de creditos', + totalOwed: 'Total adeudado', + overdueAmount: 'Vencido', + customersWithCredit: 'Clientes con fiado', + registerPayment: 'Registrar abono', + paymentHistory: 'Historial de pagos', + dueDate: 'Fecha de vencimiento', + overdue: 'Vencido', + noCredits: 'No hay fiados pendientes', + }, + + // Inventory + inventory: { + title: 'Inventario', + subtitle: 'Control de existencias', + movements: 'Movimientos', + addMovement: 'Agregar movimiento', + entry: 'Entrada', + exit: 'Salida', + adjustment: 'Ajuste', + lowStockAlerts: 'Alertas de stock bajo', + reorder: 'Reabastecer', + noMovements: 'No hay movimientos', + }, + + // Referrals + referrals: { + title: 'Programa de Referidos', + subtitle: 'Invita amigos y gana', + yourCode: 'Tu codigo de referido', + copy: 'Copiar', + share: 'Compartir', + shareWhatsApp: 'Compartir por WhatsApp', + invited: 'Invitados', + converted: 'Convertidos', + monthsEarned: 'Meses ganados', + monthsAvailable: 'Disponibles', + howItWorks: 'Como funciona', + step1: 'Comparte tu codigo', + step1Desc: 'Envia tu codigo a amigos por WhatsApp', + step2: 'Tu amigo se registra', + step2Desc: 'Obtiene 50% de descuento en su primer mes', + step3: 'Tu ganas 1 mes gratis', + step3Desc: 'Cuando tu amigo paga su primer mes', + yourReferrals: 'Tus referidos', + noReferrals: 'Aun no tienes referidos', + }, + + // Settings + settings: { + title: 'Ajustes', + subtitle: 'Configura tu tienda', + businessInfo: 'Informacion del negocio', + fiadoSettings: 'Configuracion de fiado', + whatsapp: 'WhatsApp Business', + notifications: 'Notificaciones', + subscription: 'Suscripcion', + language: 'Idioma', + enableFiado: 'Habilitar fiado', + defaultCreditLimit: 'Limite de credito por defecto', + gracePeriod: 'Dias de gracia', + connected: 'Conectado', + autoResponses: 'Respuestas automaticas', + lowStockAlerts: 'Alertas de stock bajo', + overdueAlerts: 'Alertas de fiados vencidos', + newOrderAlerts: 'Alertas de nuevos pedidos', + currentPlan: 'Plan actual', + upgradePlan: 'Mejorar plan', + }, + + // Payments + payments: { + cash: 'Efectivo', + card: 'Tarjeta', + transfer: 'Transferencia', + fiado: 'Fiado', + codi: 'CoDi', + spei: 'SPEI', + change: 'Cambio', + total: 'Total', + subtotal: 'Subtotal', + tax: 'IVA', + discount: 'Descuento', + payNow: 'Pagar ahora', + generateQR: 'Generar QR', + scanQR: 'Escanea el QR con tu app bancaria', + paymentReceived: 'Pago recibido', + paymentFailed: 'Pago fallido', + }, + + // Errors + errors: { + generic: 'Algo salio mal', + networkError: 'Error de conexion', + unauthorized: 'No autorizado', + notFound: 'No encontrado', + validationError: 'Error de validacion', + serverError: 'Error del servidor', + }, + + // Business Types + businessTypes: { + tienda: 'Tienda de abarrotes', + papeleria: 'Papeleria', + farmacia: 'Farmacia', + ferreteria: 'Ferreteria', + carniceria: 'Carniceria', + verduleria: 'Verduleria', + panaderia: 'Panaderia', + tortilleria: 'Tortilleria', + otro: 'Otro', + }, +}; diff --git a/apps/frontend/src/locales/pt-BR/index.ts b/apps/frontend/src/locales/pt-BR/index.ts new file mode 100644 index 000000000..708e3f2db --- /dev/null +++ b/apps/frontend/src/locales/pt-BR/index.ts @@ -0,0 +1,234 @@ +// Brazilian Portuguese +const ptBR = { + // Common + common: { + save: 'Salvar', + cancel: 'Cancelar', + delete: 'Excluir', + edit: 'Editar', + add: 'Adicionar', + search: 'Buscar', + loading: 'Carregando...', + error: 'Erro', + success: 'Sucesso', + confirm: 'Confirmar', + back: 'Voltar', + next: 'Proximo', + close: 'Fechar', + yes: 'Sim', + no: 'Nao', + all: 'Todos', + none: 'Nenhum', + required: 'Obrigatorio', + optional: 'Opcional', + }, + + // Navigation + nav: { + dashboard: 'Painel', + products: 'Produtos', + orders: 'Pedidos', + customers: 'Clientes', + fiado: 'Fiado', + inventory: 'Estoque', + referrals: 'Indicacoes', + settings: 'Configuracoes', + logout: 'Sair', + }, + + // Auth + auth: { + login: 'Entrar', + register: 'Cadastrar', + phone: 'Celular', + pin: 'PIN', + enterPhone: 'Digite seu celular', + enterPin: 'Digite seu PIN', + forgotPin: 'Esqueci meu PIN', + noAccount: 'Nao tem conta?', + hasAccount: 'Ja tem conta?', + createAccount: 'Criar conta', + businessName: 'Nome do negocio', + ownerName: 'Seu nome', + businessType: 'Tipo de negocio', + }, + + // Dashboard + dashboard: { + title: 'Painel', + todaySales: 'Vendas de hoje', + weekSales: 'Vendas da semana', + monthSales: 'Vendas do mes', + transactions: 'transacoes', + lowStock: 'Estoque baixo', + pendingOrders: 'Pedidos pendentes', + pendingCredits: 'Fiados pendentes', + topProducts: 'Produtos mais vendidos', + }, + + // Products + products: { + title: 'Produtos', + subtitle: 'Gerencie seu catalogo', + addProduct: 'Adicionar produto', + editProduct: 'Editar produto', + name: 'Nome', + price: 'Preco', + cost: 'Custo', + stock: 'Estoque', + sku: 'SKU', + barcode: 'Codigo de barras', + category: 'Categoria', + description: 'Descricao', + noProducts: 'Nenhum produto', + scanBarcode: 'Escanear codigo', + }, + + // Orders + orders: { + title: 'Pedidos', + subtitle: 'Gerencie os pedidos', + newOrder: 'Novo pedido', + orderNumber: 'Pedido #{{number}}', + status: 'Status', + pending: 'Pendente', + preparing: 'Preparando', + ready: 'Pronto', + delivered: 'Entregue', + cancelled: 'Cancelado', + total: 'Total', + items: 'itens', + noOrders: 'Nenhum pedido', + }, + + // Customers + customers: { + title: 'Clientes', + subtitle: 'Gerencie seus clientes', + addCustomer: 'Adicionar cliente', + name: 'Nome', + phone: 'Celular', + email: 'Email', + creditLimit: 'Limite de credito', + currentBalance: 'Saldo atual', + noCustomers: 'Nenhum cliente', + }, + + // Fiado (Credit) + fiado: { + title: 'Fiado', + subtitle: 'Controle de creditos', + totalOwed: 'Total devido', + overdueAmount: 'Vencido', + customersWithCredit: 'Clientes com fiado', + registerPayment: 'Registrar pagamento', + paymentHistory: 'Historico de pagamentos', + dueDate: 'Data de vencimento', + overdue: 'Vencido', + noCredits: 'Nenhum fiado pendente', + }, + + // Inventory + inventory: { + title: 'Estoque', + subtitle: 'Controle de estoque', + movements: 'Movimentacoes', + addMovement: 'Adicionar movimentacao', + entry: 'Entrada', + exit: 'Saida', + adjustment: 'Ajuste', + lowStockAlerts: 'Alertas de estoque baixo', + reorder: 'Repor', + noMovements: 'Nenhuma movimentacao', + }, + + // Referrals + referrals: { + title: 'Programa de Indicacoes', + subtitle: 'Indique amigos e ganhe', + yourCode: 'Seu codigo de indicacao', + copy: 'Copiar', + share: 'Compartilhar', + shareWhatsApp: 'Compartilhar por WhatsApp', + invited: 'Indicados', + converted: 'Convertidos', + monthsEarned: 'Meses ganhos', + monthsAvailable: 'Disponiveis', + howItWorks: 'Como funciona', + step1: 'Compartilhe seu codigo', + step1Desc: 'Envie seu codigo para amigos por WhatsApp', + step2: 'Seu amigo se cadastra', + step2Desc: 'Ganha 50% de desconto no primeiro mes', + step3: 'Voce ganha 1 mes gratis', + step3Desc: 'Quando seu amigo paga o primeiro mes', + yourReferrals: 'Suas indicacoes', + noReferrals: 'Voce ainda nao tem indicacoes', + }, + + // Settings + settings: { + title: 'Configuracoes', + subtitle: 'Configure sua loja', + businessInfo: 'Informacoes do negocio', + fiadoSettings: 'Configuracao de fiado', + whatsapp: 'WhatsApp Business', + notifications: 'Notificacoes', + subscription: 'Assinatura', + language: 'Idioma', + enableFiado: 'Habilitar fiado', + defaultCreditLimit: 'Limite de credito padrao', + gracePeriod: 'Dias de carencia', + connected: 'Conectado', + autoResponses: 'Respostas automaticas', + lowStockAlerts: 'Alertas de estoque baixo', + overdueAlerts: 'Alertas de fiados vencidos', + newOrderAlerts: 'Alertas de novos pedidos', + currentPlan: 'Plano atual', + upgradePlan: 'Melhorar plano', + }, + + // Payments + payments: { + cash: 'Dinheiro', + card: 'Cartao', + transfer: 'Transferencia', + fiado: 'Fiado', + codi: 'Pix', + spei: 'TED', + change: 'Troco', + total: 'Total', + subtotal: 'Subtotal', + tax: 'Impostos', + discount: 'Desconto', + payNow: 'Pagar agora', + generateQR: 'Gerar QR', + scanQR: 'Escaneie o QR com seu app do banco', + paymentReceived: 'Pagamento recebido', + paymentFailed: 'Pagamento falhou', + }, + + // Errors + errors: { + generic: 'Algo deu errado', + networkError: 'Erro de conexao', + unauthorized: 'Nao autorizado', + notFound: 'Nao encontrado', + validationError: 'Erro de validacao', + serverError: 'Erro do servidor', + }, + + // Business Types + businessTypes: { + tienda: 'Loja', + papeleria: 'Papelaria', + farmacia: 'Farmacia', + ferreteria: 'Ferragem', + carniceria: 'Acougue', + verduleria: 'Hortifruti', + panaderia: 'Padaria', + tortilleria: 'Tapiocaria', + otro: 'Outro', + }, +}; + +export default ptBR; diff --git a/apps/frontend/src/pages/Invoices.tsx b/apps/frontend/src/pages/Invoices.tsx new file mode 100644 index 000000000..a30e90b6d --- /dev/null +++ b/apps/frontend/src/pages/Invoices.tsx @@ -0,0 +1,668 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + FileText, + Send, + XCircle, + CheckCircle, + Clock, + Download, + Search, + Plus, + Settings, + AlertCircle, +} from 'lucide-react'; +import clsx from 'clsx'; +import { invoicesApi } from '../lib/api'; + +const statusConfig = { + draft: { label: 'Borrador', color: 'gray', icon: Clock }, + pending: { label: 'Pendiente', color: 'yellow', icon: Clock }, + stamped: { label: 'Timbrada', color: 'green', icon: CheckCircle }, + sent: { label: 'Enviada', color: 'blue', icon: Send }, + cancelled: { label: 'Cancelada', color: 'red', icon: XCircle }, +}; + +export function Invoices() { + const [filter, setFilter] = useState('all'); + const [showConfig, setShowConfig] = useState(false); + const [showNewInvoice, setShowNewInvoice] = useState(false); + const queryClient = useQueryClient(); + + const { data: invoices = [], isLoading } = useQuery({ + queryKey: ['invoices', filter], + queryFn: async () => { + const params = filter !== 'all' ? { status: filter } : {}; + const response = await invoicesApi.getAll(params); + return response.data; + }, + }); + + const { data: summary } = useQuery({ + queryKey: ['invoices-summary'], + queryFn: async () => { + const response = await invoicesApi.getSummary(); + return response.data; + }, + }); + + const { data: taxConfig } = useQuery({ + queryKey: ['tax-config'], + queryFn: async () => { + const response = await invoicesApi.getTaxConfig(); + return response.data; + }, + }); + + const stampMutation = useMutation({ + mutationFn: (id: string) => invoicesApi.stamp(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['invoices'] }); + }, + }); + + const sendMutation = useMutation({ + mutationFn: (id: string) => invoicesApi.send(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['invoices'] }); + }, + }); + + const hasActiveConfig = taxConfig?.status === 'active'; + + return ( +
+
+
+

Facturacion

+

Emite facturas electronicas (CFDI)

+
+
+ + +
+
+ + {/* Alert if no config */} + {!hasActiveConfig && ( +
+ +
+

Configuracion fiscal requerida

+

+ Para emitir facturas, primero configura tus datos fiscales y certificados. +

+
+
+ )} + + {/* Summary Cards */} +
+
+

Facturas del mes

+

{summary?.total_invoices || 0}

+
+
+

Monto facturado

+

+ ${(summary?.total_amount || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })} +

+
+
+

Canceladas

+

{summary?.total_cancelled || 0}

+
+
+

RFC Emisor

+

{taxConfig?.rfc || 'No configurado'}

+
+
+ + {/* Status Filter */} +
+ + {Object.entries(statusConfig).map(([status, config]) => ( + + ))} +
+ + {/* Invoices List */} + {isLoading ? ( +
+
+

Cargando facturas...

+
+ ) : invoices.length === 0 ? ( +
+ +

No hay facturas

+

Las facturas emitidas apareceran aqui

+
+ ) : ( +
+ {invoices.map((invoice: any) => { + const status = statusConfig[invoice.status as keyof typeof statusConfig] || statusConfig.draft; + const StatusIcon = status.icon; + + return ( +
+
+
+
+ +
+
+
+

+ {invoice.serie}-{invoice.folio} +

+ + {status.label} + +
+

{invoice.receptorNombre}

+

{invoice.receptorRfc}

+ {invoice.uuid && ( +

+ UUID: {invoice.uuid} +

+ )} +
+
+ +
+
+

+ ${Number(invoice.total).toLocaleString('es-MX', { minimumFractionDigits: 2 })} +

+

+ {new Date(invoice.createdAt).toLocaleDateString('es-MX')} +

+
+ +
+ {invoice.status === 'draft' && ( + + )} + {invoice.status === 'stamped' && ( + + )} + {(invoice.status === 'stamped' || invoice.status === 'sent') && ( + + )} +
+
+
+
+ ); + })} +
+ )} + + {/* Tax Config Modal */} + {showConfig && ( + setShowConfig(false)} + onSave={() => { + queryClient.invalidateQueries({ queryKey: ['tax-config'] }); + setShowConfig(false); + }} + /> + )} + + {/* New Invoice Modal */} + {showNewInvoice && ( + setShowNewInvoice(false)} + onSuccess={() => { + queryClient.invalidateQueries({ queryKey: ['invoices'] }); + setShowNewInvoice(false); + }} + /> + )} +
+ ); +} + +function TaxConfigModal({ + config, + onClose, + onSave, +}: { + config: any; + onClose: () => void; + onSave: () => void; +}) { + const [formData, setFormData] = useState({ + rfc: config?.rfc || '', + razonSocial: config?.razonSocial || '', + regimenFiscal: config?.regimenFiscal || '601', + codigoPostal: config?.codigoPostal || '', + serie: config?.serie || 'A', + pacProvider: config?.pacProvider || 'facturapi', + pacSandbox: config?.pacSandbox ?? true, + }); + + const mutation = useMutation({ + mutationFn: (data: any) => invoicesApi.saveTaxConfig(data), + onSuccess: onSave, + }); + + return ( +
+
+
+

Configuracion Fiscal

+
+ +
{ + e.preventDefault(); + mutation.mutate(formData); + }} + className="p-6 space-y-4" + > +
+ + setFormData({ ...formData, rfc: e.target.value.toUpperCase() })} + className="input" + maxLength={13} + required + /> +
+ +
+ + setFormData({ ...formData, razonSocial: e.target.value })} + className="input" + required + /> +
+ +
+
+ + +
+ +
+ + setFormData({ ...formData, codigoPostal: e.target.value })} + className="input" + maxLength={5} + required + /> +
+
+ +
+
+ + setFormData({ ...formData, serie: e.target.value.toUpperCase() })} + className="input" + maxLength={10} + /> +
+ +
+ + +
+
+ +
+ setFormData({ ...formData, pacSandbox: e.target.checked })} + className="rounded border-gray-300" + /> + +
+ +
+ + +
+
+
+
+ ); +} + +function NewInvoiceModal({ + onClose, + onSuccess, +}: { + onClose: () => void; + onSuccess: () => void; +}) { + const [formData, setFormData] = useState({ + receptorRfc: '', + receptorNombre: '', + receptorRegimenFiscal: '601', + receptorCodigoPostal: '', + receptorUsoCfdi: 'G03', + receptorEmail: '', + formaPago: '01', + metodoPago: 'PUE', + items: [{ descripcion: '', cantidad: 1, valorUnitario: 0, claveProdServ: '01010101', claveUnidad: 'H87' }], + }); + + const mutation = useMutation({ + mutationFn: (data: any) => invoicesApi.create(data), + onSuccess, + }); + + const addItem = () => { + setFormData({ + ...formData, + items: [ + ...formData.items, + { descripcion: '', cantidad: 1, valorUnitario: 0, claveProdServ: '01010101', claveUnidad: 'H87' }, + ], + }); + }; + + const updateItem = (index: number, field: string, value: any) => { + const newItems = [...formData.items]; + newItems[index] = { ...newItems[index], [field]: value }; + setFormData({ ...formData, items: newItems }); + }; + + const removeItem = (index: number) => { + if (formData.items.length > 1) { + setFormData({ + ...formData, + items: formData.items.filter((_, i) => i !== index), + }); + } + }; + + const total = formData.items.reduce( + (sum, item) => sum + item.cantidad * item.valorUnitario * 1.16, + 0 + ); + + return ( +
+
+
+

Nueva Factura

+
+ +
{ + e.preventDefault(); + mutation.mutate(formData); + }} + className="p-6 space-y-6" + > + {/* Receptor */} +
+

Datos del Receptor

+
+
+ + setFormData({ ...formData, receptorRfc: e.target.value.toUpperCase() })} + className="input" + maxLength={13} + required + /> +
+
+ + setFormData({ ...formData, receptorNombre: e.target.value })} + className="input" + required + /> +
+
+ + setFormData({ ...formData, receptorCodigoPostal: e.target.value })} + className="input" + maxLength={5} + required + /> +
+
+ + +
+
+ + setFormData({ ...formData, receptorEmail: e.target.value })} + className="input" + placeholder="cliente@email.com" + /> +
+
+
+ + {/* Pago */} +
+

Metodo de Pago

+
+
+ + +
+
+ + +
+
+
+ + {/* Items */} +
+
+

Conceptos

+ +
+ +
+ {formData.items.map((item, index) => ( +
+
+
+ updateItem(index, 'descripcion', e.target.value)} + className="input" + placeholder="Descripcion" + required + /> +
+
+ updateItem(index, 'cantidad', Number(e.target.value))} + className="input" + placeholder="Cant" + min={1} + required + /> +
+
+ updateItem(index, 'valorUnitario', Number(e.target.value))} + className="input" + placeholder="Precio" + min={0} + step={0.01} + required + /> +
+
+ {formData.items.length > 1 && ( + + )} +
+
+
+ ))} +
+
+ + {/* Total */} +
+

Total (IVA incluido)

+

+ ${total.toLocaleString('es-MX', { minimumFractionDigits: 2 })} +

+
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/pages/Marketplace.tsx b/apps/frontend/src/pages/Marketplace.tsx new file mode 100644 index 000000000..84d6c6147 --- /dev/null +++ b/apps/frontend/src/pages/Marketplace.tsx @@ -0,0 +1,731 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Store, + Search, + Star, + Heart, + ShoppingCart, + Package, + Truck, + Clock, + CheckCircle, + XCircle, + MapPin, + Phone, + ChevronRight, + Filter, +} from 'lucide-react'; +import clsx from 'clsx'; +import { marketplaceApi } from '../lib/api'; + +const categories = [ + { id: 'bebidas', name: 'Bebidas', icon: '🥤' }, + { id: 'botanas', name: 'Botanas', icon: '🍿' }, + { id: 'lacteos', name: 'Lacteos', icon: '🥛' }, + { id: 'pan', name: 'Pan', icon: '🍞' }, + { id: 'abarrotes', name: 'Abarrotes', icon: '🛒' }, + { id: 'limpieza', name: 'Limpieza', icon: '🧹' }, +]; + +const orderStatusConfig = { + pending: { label: 'Pendiente', color: 'yellow', icon: Clock }, + confirmed: { label: 'Confirmado', color: 'blue', icon: CheckCircle }, + preparing: { label: 'Preparando', color: 'indigo', icon: Package }, + shipped: { label: 'En camino', color: 'purple', icon: Truck }, + delivered: { label: 'Entregado', color: 'green', icon: CheckCircle }, + cancelled: { label: 'Cancelado', color: 'red', icon: XCircle }, + rejected: { label: 'Rechazado', color: 'red', icon: XCircle }, +}; + +export function Marketplace() { + const [view, setView] = useState<'suppliers' | 'orders' | 'favorites'>('suppliers'); + const [selectedCategory, setSelectedCategory] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedSupplier, setSelectedSupplier] = useState(null); + const [showCart, setShowCart] = useState(false); + const [cart, setCart] = useState<{ product: any; quantity: number }[]>([]); + const queryClient = useQueryClient(); + + const { data: suppliers = [], isLoading: loadingSuppliers } = useQuery({ + queryKey: ['suppliers', selectedCategory, searchQuery], + queryFn: async () => { + const response = await marketplaceApi.getSuppliers({ + category: selectedCategory || undefined, + search: searchQuery || undefined, + }); + return response.data; + }, + }); + + const { data: orders = [], isLoading: loadingOrders } = useQuery({ + queryKey: ['marketplace-orders'], + queryFn: async () => { + const response = await marketplaceApi.getOrders(); + return response.data; + }, + enabled: view === 'orders', + }); + + const { data: favorites = [] } = useQuery({ + queryKey: ['supplier-favorites'], + queryFn: async () => { + const response = await marketplaceApi.getFavorites(); + return response.data; + }, + enabled: view === 'favorites', + }); + + const addToCart = (product: any) => { + const existing = cart.find((item) => item.product.id === product.id); + if (existing) { + setCart( + cart.map((item) => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ) + ); + } else { + setCart([...cart, { product, quantity: 1 }]); + } + }; + + const removeFromCart = (productId: string) => { + setCart(cart.filter((item) => item.product.id !== productId)); + }; + + const updateCartQuantity = (productId: string, quantity: number) => { + if (quantity <= 0) { + removeFromCart(productId); + } else { + setCart( + cart.map((item) => + item.product.id === productId ? { ...item, quantity } : item + ) + ); + } + }; + + const cartTotal = cart.reduce( + (sum, item) => sum + Number(item.product.unitPrice) * item.quantity, + 0 + ); + + return ( +
+
+
+

Marketplace

+

Encuentra proveedores para tu negocio

+
+ + {cart.length > 0 && ( + + )} +
+ + {/* Tabs */} +
+ + + +
+ + {view === 'suppliers' && ( + <> + {/* Search & Filter */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Buscar proveedores..." + className="input pl-10" + /> +
+
+ + {/* Categories */} +
+ + {categories.map((category) => ( + + ))} +
+ + {/* Suppliers Grid */} + {loadingSuppliers ? ( +
+
+

Buscando proveedores...

+
+ ) : suppliers.length === 0 ? ( +
+ +

No hay proveedores

+

Pronto habra mas proveedores disponibles

+
+ ) : ( +
+ {suppliers.map((supplier: any) => ( + setSelectedSupplier(supplier)} + /> + ))} +
+ )} + + )} + + {view === 'orders' && ( +
+ {loadingOrders ? ( +
+
+
+ ) : orders.length === 0 ? ( +
+ +

No hay pedidos

+

Tus pedidos a proveedores apareceran aqui

+
+ ) : ( + orders.map((order: any) => ( + + )) + )} +
+ )} + + {view === 'favorites' && ( +
+ {favorites.length === 0 ? ( +
+ +

Sin favoritos

+

Agrega proveedores a tus favoritos

+
+ ) : ( + favorites.map((supplier: any) => ( + setSelectedSupplier(supplier)} + /> + )) + )} +
+ )} + + {/* Supplier Detail Modal */} + {selectedSupplier && ( + setSelectedSupplier(null)} + onAddToCart={addToCart} + cart={cart} + /> + )} + + {/* Cart Modal */} + {showCart && ( + setShowCart(false)} + onUpdateQuantity={updateCartQuantity} + onRemove={removeFromCart} + onOrderSuccess={() => { + setCart([]); + setShowCart(false); + queryClient.invalidateQueries({ queryKey: ['marketplace-orders'] }); + }} + /> + )} +
+ ); +} + +function SupplierCard({ + supplier, + onClick, +}: { + supplier: any; + onClick: () => void; +}) { + return ( +
+
+
+ {supplier.logoUrl ? ( + {supplier.name} + ) : ( + + )} +
+
+
+

{supplier.name}

+ {supplier.verified && ( + + )} +
+
+
+ + {Number(supplier.rating).toFixed(1)} +
+ · + {supplier.totalReviews} resenas +
+
+ {supplier.categories?.slice(0, 3).map((cat: string) => ( + + {cat} + + ))} +
+
+ Min: ${Number(supplier.minOrderAmount).toFixed(0)} + {Number(supplier.deliveryFee) > 0 ? ( + Envio: ${Number(supplier.deliveryFee).toFixed(0)} + ) : ( + Envio gratis + )} +
+
+ +
+
+ ); +} + +function OrderCard({ order }: { order: any }) { + const status = orderStatusConfig[order.status as keyof typeof orderStatusConfig] || orderStatusConfig.pending; + const StatusIcon = status.icon; + + return ( +
+
+
+
+ +
+
+
+

Pedido #{order.orderNumber}

+ + {status.label} + +
+

{order.supplier?.name}

+

+ {order.items?.length} productos +

+
+
+ +
+

+ ${Number(order.total).toLocaleString('es-MX', { minimumFractionDigits: 2 })} +

+

+ {new Date(order.createdAt).toLocaleDateString('es-MX')} +

+
+
+
+ ); +} + +function SupplierDetailModal({ + supplier, + onClose, + onAddToCart, + cart, +}: { + supplier: any; + onClose: () => void; + onAddToCart: (product: any) => void; + cart: { product: any; quantity: number }[]; +}) { + const [search, setSearch] = useState(''); + const queryClient = useQueryClient(); + + const { data: products = [], isLoading } = useQuery({ + queryKey: ['supplier-products', supplier.id, search], + queryFn: async () => { + const response = await marketplaceApi.getSupplierProducts(supplier.id, { + search: search || undefined, + }); + return response.data; + }, + }); + + const favoriteMutation = useMutation({ + mutationFn: () => marketplaceApi.addFavorite(supplier.id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['supplier-favorites'] }); + }, + }); + + const getCartQuantity = (productId: string) => { + const item = cart.find((i) => i.product.id === productId); + return item?.quantity || 0; + }; + + return ( +
+
+ {/* Header */} +
+
+
+ {supplier.logoUrl ? ( + {supplier.name} + ) : ( + + )} +
+
+
+

{supplier.name}

+ +
+
+ + {Number(supplier.rating).toFixed(1)} + · + {supplier.totalReviews} resenas +
+ {supplier.address && ( +

+ + {supplier.city}, {supplier.state} +

+ )} +
+
+ +
+ + {supplier.contactPhone && ( + + + Llamar + + )} +
+
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Buscar productos..." + className="input pl-10" + /> +
+
+ + {/* Products */} +
+ {isLoading ? ( +
+
+
+ ) : products.length === 0 ? ( +
+ No hay productos disponibles +
+ ) : ( +
+ {products.map((product: any) => { + const inCart = getCartQuantity(product.id); + return ( +
+
+ {product.imageUrl ? ( + {product.name} + ) : ( + + )} +
+
+

{product.name}

+

+ Min: {product.minQuantity} {product.unitType} +

+
+ + ${Number(product.unitPrice).toFixed(2)} + + {inCart > 0 ? ( + + {inCart} en carrito + + ) : ( + + )} +
+
+
+ ); + })} +
+ )} +
+
+
+ ); +} + +function CartModal({ + cart, + supplier, + onClose, + onUpdateQuantity, + onRemove, + onOrderSuccess, +}: { + cart: { product: any; quantity: number }[]; + supplier: any; + onClose: () => void; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemove: (productId: string) => void; + onOrderSuccess: () => void; +}) { + const [deliveryAddress, setDeliveryAddress] = useState(''); + const [deliveryPhone, setDeliveryPhone] = useState(''); + const [notes, setNotes] = useState(''); + + const subtotal = cart.reduce( + (sum, item) => sum + Number(item.product.unitPrice) * item.quantity, + 0 + ); + + const orderMutation = useMutation({ + mutationFn: (data: any) => marketplaceApi.createOrder(data), + onSuccess: onOrderSuccess, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const supplierId = cart[0]?.product?.supplierId; + if (!supplierId) return; + + orderMutation.mutate({ + supplierId, + items: cart.map((item) => ({ + productId: item.product.id, + quantity: item.quantity, + })), + deliveryAddress, + deliveryPhone, + notes, + }); + }; + + return ( +
+
+
+

Tu Carrito

+ +
+ +
+ {/* Items */} +
+ {cart.map((item) => ( +
+
+

{item.product.name}

+

+ ${Number(item.product.unitPrice).toFixed(2)} x {item.quantity} +

+
+
+ + {item.quantity} + +
+

+ ${(Number(item.product.unitPrice) * item.quantity).toFixed(2)} +

+
+ ))} +
+ + {/* Delivery Info */} +
+

Datos de entrega

+
+ +