2419 lines
70 KiB
Python
2419 lines
70 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Script para generar Especificaciones Técnicas (ET) a partir de Requerimientos Funcionales (RF).
|
|
Genera 2 archivos por RF: ET-BACKEND y ET-FRONTEND
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Dict, List, Tuple
|
|
|
|
# Configuración base
|
|
BASE_DIR = Path(__file__).parent
|
|
RF_DIR = BASE_DIR.parent / "requerimientos-funcionales"
|
|
BACKEND_DIR = BASE_DIR / "backend"
|
|
FRONTEND_DIR = BASE_DIR / "frontend"
|
|
SCHEMAS_DIR = BASE_DIR.parent / "database-design" / "schemas"
|
|
|
|
# Mapeo de módulos a schemas
|
|
MODULE_TO_SCHEMA = {
|
|
"MGN-001": "auth",
|
|
"MGN-002": "auth",
|
|
"MGN-003": "core",
|
|
"MGN-004": "financial",
|
|
"MGN-005": "inventory",
|
|
"MGN-006": "purchase",
|
|
"MGN-007": "sales",
|
|
"MGN-008": "analytics",
|
|
"MGN-009": "sales", # CRM en sales schema
|
|
"MGN-010": "core", # RRHH en core schema
|
|
"MGN-011": "projects",
|
|
"MGN-012": "system",
|
|
"MGN-013": "system",
|
|
"MGN-014": "system",
|
|
}
|
|
|
|
# Mapeo de módulos a recursos API
|
|
MODULE_RESOURCES = {
|
|
"MGN-001": {
|
|
"001": "auth/login",
|
|
"002": "roles",
|
|
"003": "users",
|
|
"004": "tenants",
|
|
"005": "auth/reset-password",
|
|
"006": "auth/signup",
|
|
"007": "auth/sessions",
|
|
"008": "record-rules",
|
|
},
|
|
"MGN-002": {
|
|
"001": "companies",
|
|
"002": "companies/:id/settings",
|
|
"003": "companies/:id/users",
|
|
"004": "companies/hierarchy",
|
|
"005": "companies/templates",
|
|
},
|
|
"MGN-003": {
|
|
"001": "partners",
|
|
"002": "countries",
|
|
"003": "currencies",
|
|
"004": "uom",
|
|
"005": "product-categories",
|
|
"006": "payment-terms",
|
|
},
|
|
"MGN-004": {
|
|
"001": "chart-of-accounts",
|
|
"002": "journals",
|
|
"003": "accounting-entries",
|
|
"004": "taxes",
|
|
"005": "invoices/customer",
|
|
"006": "invoices/vendor",
|
|
"007": "payments",
|
|
"008": "reports/financial",
|
|
},
|
|
"MGN-005": {
|
|
"001": "products",
|
|
"002": "warehouses",
|
|
"003": "stock-moves",
|
|
"004": "pickings",
|
|
"005": "lots-serial-numbers",
|
|
"006": "inventory-valuation",
|
|
"007": "inventory-adjustments",
|
|
},
|
|
"MGN-006": {
|
|
"001": "purchase/rfq",
|
|
"002": "purchase/orders",
|
|
"003": "purchase/approvals",
|
|
"004": "purchase/receipts",
|
|
"005": "purchase/invoices",
|
|
"006": "purchase/reports",
|
|
},
|
|
"MGN-007": {
|
|
"001": "sales/quotations",
|
|
"002": "sales/quotations/convert",
|
|
"003": "sales/orders",
|
|
"004": "sales/deliveries",
|
|
"005": "sales/invoices",
|
|
"006": "sales/reports",
|
|
},
|
|
"MGN-008": {
|
|
"001": "analytic-accounts",
|
|
"002": "analytic-lines",
|
|
"003": "analytic-distributions",
|
|
"004": "analytic-tags",
|
|
"005": "analytic-reports",
|
|
},
|
|
"MGN-009": {
|
|
"001": "crm/leads",
|
|
"002": "crm/pipeline",
|
|
"003": "crm/activities",
|
|
"004": "crm/lead-scoring",
|
|
"005": "crm/convert-to-quote",
|
|
},
|
|
"MGN-010": {
|
|
"001": "hr/employees",
|
|
"002": "hr/departments",
|
|
"003": "hr/contracts",
|
|
"004": "hr/attendances",
|
|
"005": "hr/leaves",
|
|
},
|
|
"MGN-011": {
|
|
"001": "projects",
|
|
"002": "projects/tasks",
|
|
"003": "projects/milestones",
|
|
"004": "projects/timesheets",
|
|
"005": "projects/gantt",
|
|
},
|
|
"MGN-012": {
|
|
"001": "dashboards",
|
|
"002": "reports/custom",
|
|
"003": "exports",
|
|
"004": "charts",
|
|
},
|
|
"MGN-013": {
|
|
"001": "portal/access",
|
|
"002": "portal/documents",
|
|
"003": "portal/signatures",
|
|
"004": "portal/messages",
|
|
},
|
|
"MGN-014": {
|
|
"001": "messages",
|
|
"002": "notifications",
|
|
"003": "tracking",
|
|
"004": "activities",
|
|
"005": "followers",
|
|
"006": "email-templates",
|
|
},
|
|
}
|
|
|
|
|
|
def parse_rf_file(rf_path: Path) -> Dict:
|
|
"""Extrae información clave de un RF"""
|
|
content = rf_path.read_text()
|
|
|
|
# Extraer título
|
|
title_match = re.search(r'# RF-MGN-(\d+)-(\d+): (.+)', content)
|
|
if not title_match:
|
|
return None
|
|
|
|
module = f"MGN-{title_match.group(1)}"
|
|
rf_num = title_match.group(2)
|
|
title = title_match.group(3).strip()
|
|
|
|
# Extraer metadata
|
|
priority_match = re.search(r'\*\*Prioridad:\*\* (.+)', content)
|
|
sp_match = re.search(r'\*\*Story Points:\*\* (\d+)', content)
|
|
|
|
# Extraer entidades involucradas
|
|
entities_section = re.search(r'## Entidades Involucradas\n\n(.+?)(?=\n##)', content, re.DOTALL)
|
|
entities = []
|
|
if entities_section:
|
|
entities = re.findall(r'- \*\*Principales:\*\*\n\s+- (.+)', entities_section.group(1))
|
|
if not entities:
|
|
entities = re.findall(r'- (.+)', entities_section.group(1))
|
|
|
|
# Extraer reglas de negocio
|
|
rn_section = re.search(r'## Reglas de Negocio\n\n(.+?)(?=\n##)', content, re.DOTALL)
|
|
rules = []
|
|
if rn_section:
|
|
rules = re.findall(r'- \*\*RN-\d+:\*\* (.+)', rn_section.group(1))
|
|
|
|
return {
|
|
"module": module,
|
|
"rf_num": rf_num,
|
|
"title": title,
|
|
"priority": priority_match.group(1) if priority_match else "P0",
|
|
"story_points": int(sp_match.group(1)) if sp_match else 8,
|
|
"entities": entities,
|
|
"rules": rules,
|
|
"content": content,
|
|
}
|
|
|
|
|
|
def generate_backend_et(rf_data: Dict, resource: str) -> str:
|
|
"""Genera contenido del ET-Backend"""
|
|
module = rf_data["module"]
|
|
rf_num = rf_data["rf_num"]
|
|
title = rf_data["title"]
|
|
sp = rf_data["story_points"]
|
|
sp_backend = int(sp * 0.5) # 50% backend
|
|
sp_testing = int(sp * 0.3) # 30% testing
|
|
sp_review = int(sp * 0.2) # 20% review
|
|
|
|
schema = MODULE_TO_SCHEMA.get(module, "core")
|
|
|
|
# Convertir título a PascalCase para nombres de clases
|
|
entity_name = "".join(word.capitalize() for word in title.lower().split() if word not in ["de", "del", "la", "el", "y", "a"])
|
|
entity_name = entity_name.replace("(", "").replace(")", "").replace("/", "")
|
|
|
|
# Ejemplo de tabla
|
|
table_example = rf_data["entities"][0] if rf_data["entities"] else f"{schema}.{entity_name.lower()}"
|
|
|
|
template = f'''# ET-BACKEND-{module}-{rf_num}: {title}
|
|
|
|
**RF Asociado:** [RF-{module}-{rf_num}](../../requerimientos-funcionales/{module.lower()}/RF-{module}-{rf_num}-{title.lower().replace(" ", "-").replace("(", "").replace(")", "").replace("/", "-")}.md)
|
|
**Módulo:** {module}
|
|
**Complejidad:** {'Alta' if sp >= 13 else 'Media' if sp >= 8 else 'Baja'}
|
|
**Story Points:** {sp_backend} SP (Backend)
|
|
**Estado:** Diseñado
|
|
**Fecha:** 2025-11-24
|
|
|
|
## Resumen Técnico
|
|
|
|
Backend API para {title.lower()}. Implementa endpoints RESTful con NestJS, validaciones de negocio, persistencia con Prisma ORM y soporte multi-tenancy con Row Level Security (RLS).
|
|
|
|
## Stack Tecnológico
|
|
|
|
- **Runtime:** Node.js 20 LTS
|
|
- **Framework:** NestJS 10.x
|
|
- **ORM:** Prisma 5.x (con multi-schema support)
|
|
- **Validación:** class-validator + class-transformer
|
|
- **Autenticación:** JWT (Passport.js)
|
|
- **Base de Datos:** PostgreSQL 16 (multi-schema + RLS)
|
|
- **Testing:** Jest + Supertest
|
|
- **Documentación:** Swagger/OpenAPI 3.0
|
|
|
|
## Arquitectura del Endpoint
|
|
|
|
### API Endpoints
|
|
|
|
#### POST /api/v1/{resource}
|
|
```typescript
|
|
// Request
|
|
POST /api/v1/{resource}
|
|
Authorization: Bearer <JWT>
|
|
Content-Type: application/json
|
|
|
|
{{
|
|
// Payload según RF
|
|
}}
|
|
|
|
// Response 201 Created
|
|
{{
|
|
"data": {{ ... }},
|
|
"message": "{entity_name} creado exitosamente",
|
|
"timestamp": "2025-11-24T..."
|
|
}}
|
|
|
|
// Response 400 Bad Request
|
|
{{
|
|
"error": "ValidationError",
|
|
"message": "Datos inválidos",
|
|
"details": [...]
|
|
}}
|
|
|
|
// Response 403 Forbidden
|
|
{{
|
|
"error": "ForbiddenError",
|
|
"message": "Sin permisos para crear {entity_name.lower()}"
|
|
}}
|
|
```
|
|
|
|
#### GET /api/v1/{resource}
|
|
```typescript
|
|
// Request
|
|
GET /api/v1/{resource}?page=1&limit=20&sortBy=createdAt&sortOrder=desc
|
|
Authorization: Bearer <JWT>
|
|
|
|
// Response 200 OK
|
|
{{
|
|
"data": [...],
|
|
"meta": {{
|
|
"page": 1,
|
|
"limit": 20,
|
|
"total": 100,
|
|
"totalPages": 5
|
|
}}
|
|
}}
|
|
```
|
|
|
|
#### GET /api/v1/{resource}/:id
|
|
```typescript
|
|
// Request
|
|
GET /api/v1/{resource}/{{id}}
|
|
Authorization: Bearer <JWT>
|
|
|
|
// Response 200 OK
|
|
{{
|
|
"data": {{ ... }}
|
|
}}
|
|
|
|
// Response 404 Not Found
|
|
{{
|
|
"error": "NotFoundError",
|
|
"message": "{entity_name} no encontrado"
|
|
}}
|
|
```
|
|
|
|
#### PUT /api/v1/{resource}/:id
|
|
```typescript
|
|
// Request
|
|
PUT /api/v1/{resource}/{{id}}
|
|
Authorization: Bearer <JWT>
|
|
Content-Type: application/json
|
|
|
|
{{
|
|
// Payload de actualización
|
|
}}
|
|
|
|
// Response 200 OK
|
|
{{
|
|
"data": {{ ... }},
|
|
"message": "{entity_name} actualizado exitosamente"
|
|
}}
|
|
```
|
|
|
|
#### DELETE /api/v1/{resource}/:id
|
|
```typescript
|
|
// Request
|
|
DELETE /api/v1/{resource}/{{id}}
|
|
Authorization: Bearer <JWT>
|
|
|
|
// Response 200 OK (soft delete)
|
|
{{
|
|
"message": "{entity_name} eliminado exitosamente"
|
|
}}
|
|
|
|
// Response 409 Conflict
|
|
{{
|
|
"error": "ConflictError",
|
|
"message": "No se puede eliminar: tiene registros relacionados"
|
|
}}
|
|
```
|
|
|
|
## Modelo de Datos (Prisma Schema)
|
|
|
|
```prisma
|
|
// Basado en el DDL SQL: {schema}-schema-ddl.sql
|
|
model {entity_name} {{
|
|
id String @id @default(uuid())
|
|
tenantId String @map("tenant_id")
|
|
|
|
// Campos específicos del negocio
|
|
name String @db.VarChar(255)
|
|
code String? @db.VarChar(50)
|
|
|
|
// Auditoría
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
createdBy String? @map("created_by")
|
|
updatedAt DateTime? @updatedAt @map("updated_at")
|
|
updatedBy String? @map("updated_by")
|
|
deletedAt DateTime? @map("deleted_at")
|
|
deletedBy String? @map("deleted_by")
|
|
|
|
// Relations
|
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
|
|
|
@@map("{schema}.{table_example.split('.')[-1] if '.' in table_example else entity_name.lower()}")
|
|
@@index([tenantId])
|
|
@@unique([tenantId, code])
|
|
}}
|
|
```
|
|
|
|
## DTOs (Data Transfer Objects)
|
|
|
|
### Create DTO
|
|
```typescript
|
|
// src/modules/{module.lower()}/dto/create-{entity_name.lower()}.dto.ts
|
|
import {{ IsString, IsEmail, IsOptional, MinLength, MaxLength, IsUUID }} from 'class-validator';
|
|
|
|
export class Create{entity_name}Dto {{
|
|
@IsString()
|
|
@MinLength(3)
|
|
@MaxLength(255)
|
|
name: string;
|
|
|
|
@IsString()
|
|
@IsOptional()
|
|
@MaxLength(50)
|
|
code?: string;
|
|
|
|
// Campos adicionales según RF
|
|
}}
|
|
```
|
|
|
|
### Update DTO
|
|
```typescript
|
|
// src/modules/{module.lower()}/dto/update-{entity_name.lower()}.dto.ts
|
|
import {{ PartialType }} from '@nestjs/mapped-types';
|
|
import {{ Create{entity_name}Dto }} from './create-{entity_name.lower()}.dto';
|
|
|
|
export class Update{entity_name}Dto extends PartialType(Create{entity_name}Dto) {{}}
|
|
```
|
|
|
|
### Response DTO
|
|
```typescript
|
|
// src/modules/{module.lower()}/dto/{entity_name.lower()}-response.dto.ts
|
|
export class {entity_name}ResponseDto {{
|
|
id: string;
|
|
tenantId: string;
|
|
name: string;
|
|
code?: string;
|
|
createdAt: Date;
|
|
updatedAt?: Date;
|
|
}}
|
|
```
|
|
|
|
### Filter DTO
|
|
```typescript
|
|
// src/modules/{module.lower()}/dto/filter-{entity_name.lower()}.dto.ts
|
|
import {{ IsOptional, IsString, IsInt, Min, Max }} from 'class-validator';
|
|
import {{ Type }} from 'class-transformer';
|
|
|
|
export class Filter{entity_name}Dto {{
|
|
@IsOptional()
|
|
@IsString()
|
|
search?: string;
|
|
|
|
@IsOptional()
|
|
@Type(() => Number)
|
|
@IsInt()
|
|
@Min(1)
|
|
page?: number = 1;
|
|
|
|
@IsOptional()
|
|
@Type(() => Number)
|
|
@IsInt()
|
|
@Min(1)
|
|
@Max(100)
|
|
limit?: number = 20;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
sortBy?: string = 'createdAt';
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
sortOrder?: 'asc' | 'desc' = 'desc';
|
|
}}
|
|
```
|
|
|
|
## Servicios de Negocio
|
|
|
|
### Service Principal
|
|
```typescript
|
|
// src/modules/{module.lower()}/services/{entity_name.lower()}.service.ts
|
|
import {{ Injectable, NotFoundException, BadRequestException, ConflictException }} from '@nestjs/common';
|
|
import {{ PrismaService }} from '@shared/prisma/prisma.service';
|
|
import {{ Create{entity_name}Dto, Update{entity_name}Dto, Filter{entity_name}Dto }} from '../dto';
|
|
|
|
@Injectable()
|
|
export class {entity_name}Service {{
|
|
constructor(private readonly prisma: PrismaService) {{}}
|
|
|
|
async create(tenantId: string, userId: string, dto: Create{entity_name}Dto) {{
|
|
// 1. Validaciones de negocio
|
|
await this.validateBusinessRules(tenantId, dto);
|
|
|
|
// 2. Verificar duplicados
|
|
if (dto.code) {{
|
|
const existing = await this.prisma.{entity_name.lower()}.findFirst({{
|
|
where: {{
|
|
tenantId,
|
|
code: dto.code,
|
|
deletedAt: null,
|
|
}},
|
|
}});
|
|
|
|
if (existing) {{
|
|
throw new ConflictException(`{entity_name} con código ${{dto.code}} ya existe`);
|
|
}}
|
|
}}
|
|
|
|
// 3. Crear en BD (RLS automático por tenantId)
|
|
const entity = await this.prisma.{entity_name.lower()}.create({{
|
|
data: {{
|
|
tenantId,
|
|
createdBy: userId,
|
|
...dto,
|
|
}},
|
|
}});
|
|
|
|
// 4. Eventos/side effects (si aplica)
|
|
// await this.eventEmitter.emit('{entity_name.lower()}.created', entity);
|
|
|
|
return entity;
|
|
}}
|
|
|
|
async findAll(tenantId: string, filters: Filter{entity_name}Dto) {{
|
|
const {{ page, limit, sortBy, sortOrder, search }} = filters;
|
|
const skip = (page - 1) * limit;
|
|
|
|
const where = {{
|
|
tenantId,
|
|
deletedAt: null,
|
|
...(search && {{
|
|
OR: [
|
|
{{ name: {{ contains: search, mode: 'insensitive' }} }},
|
|
{{ code: {{ contains: search, mode: 'insensitive' }} }},
|
|
],
|
|
}}),
|
|
}};
|
|
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.{entity_name.lower()}.findMany({{
|
|
where,
|
|
skip,
|
|
take: limit,
|
|
orderBy: {{ [sortBy]: sortOrder }},
|
|
}}),
|
|
this.prisma.{entity_name.lower()}.count({{ where }}),
|
|
]);
|
|
|
|
return {{
|
|
data,
|
|
meta: {{
|
|
page,
|
|
limit,
|
|
total,
|
|
totalPages: Math.ceil(total / limit),
|
|
}},
|
|
}};
|
|
}}
|
|
|
|
async findOne(tenantId: string, id: string) {{
|
|
const entity = await this.prisma.{entity_name.lower()}.findFirst({{
|
|
where: {{
|
|
id,
|
|
tenantId,
|
|
deletedAt: null,
|
|
}},
|
|
}});
|
|
|
|
if (!entity) {{
|
|
throw new NotFoundException(`{entity_name} con ID ${{id}} no encontrado`);
|
|
}}
|
|
|
|
return entity;
|
|
}}
|
|
|
|
async update(tenantId: string, userId: string, id: string, dto: Update{entity_name}Dto) {{
|
|
// 1. Verificar existencia
|
|
await this.findOne(tenantId, id);
|
|
|
|
// 2. Validar cambios
|
|
await this.validateBusinessRules(tenantId, dto, id);
|
|
|
|
// 3. Actualizar
|
|
const entity = await this.prisma.{entity_name.lower()}.update({{
|
|
where: {{ id }},
|
|
data: {{
|
|
...dto,
|
|
updatedBy: userId,
|
|
updatedAt: new Date(),
|
|
}},
|
|
}});
|
|
|
|
// 4. Eventos
|
|
// await this.eventEmitter.emit('{entity_name.lower()}.updated', entity);
|
|
|
|
return entity;
|
|
}}
|
|
|
|
async remove(tenantId: string, userId: string, id: string) {{
|
|
// 1. Verificar existencia
|
|
await this.findOne(tenantId, id);
|
|
|
|
// 2. Verificar dependencias (opcional)
|
|
// await this.checkDependencies(id);
|
|
|
|
// 3. Soft delete
|
|
await this.prisma.{entity_name.lower()}.update({{
|
|
where: {{ id }},
|
|
data: {{
|
|
deletedAt: new Date(),
|
|
deletedBy: userId,
|
|
}},
|
|
}});
|
|
|
|
// 4. Eventos
|
|
// await this.eventEmitter.emit('{entity_name.lower()}.deleted', {{ id }});
|
|
}}
|
|
|
|
private async validateBusinessRules(
|
|
tenantId: string,
|
|
dto: Partial<Create{entity_name}Dto>,
|
|
excludeId?: string,
|
|
) {{
|
|
// Implementar reglas de negocio del RF
|
|
// Ejemplo:
|
|
// if (dto.field && !await this.isValidField(dto.field)) {{
|
|
// throw new BadRequestException('Field inválido');
|
|
// }}
|
|
}}
|
|
}}
|
|
```
|
|
|
|
## Controller
|
|
|
|
```typescript
|
|
// src/modules/{module.lower()}/controllers/{entity_name.lower()}.controller.ts
|
|
import {{
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Put,
|
|
Delete,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
UseGuards,
|
|
HttpCode,
|
|
HttpStatus
|
|
}} from '@nestjs/common';
|
|
import {{ ApiTags, ApiOperation, ApiBearerAuth, ApiResponse }} from '@nestjs/swagger';
|
|
import {{ JwtAuthGuard }} from '@modules/auth/guards/jwt-auth.guard';
|
|
import {{ PermissionsGuard }} from '@modules/auth/guards/permissions.guard';
|
|
import {{ RequirePermissions }} from '@shared/decorators/require-permissions.decorator';
|
|
import {{ CurrentTenant }} from '@shared/decorators/current-tenant.decorator';
|
|
import {{ CurrentUser }} from '@shared/decorators/current-user.decorator';
|
|
import {{ {entity_name}Service }} from '../services/{entity_name.lower()}.service';
|
|
import {{ Create{entity_name}Dto, Update{entity_name}Dto, Filter{entity_name}Dto, {entity_name}ResponseDto }} from '../dto';
|
|
|
|
@ApiTags('{entity_name}')
|
|
@ApiBearerAuth()
|
|
@Controller('api/v1/{resource}')
|
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
|
export class {entity_name}Controller {{
|
|
constructor(private readonly service: {entity_name}Service) {{}}
|
|
|
|
@Post()
|
|
@RequirePermissions('{module.lower()}.{entity_name.lower()}.create')
|
|
@ApiOperation({{ summary: 'Crear {entity_name.lower()}' }})
|
|
@ApiResponse({{ status: 201, description: '{entity_name} creado exitosamente', type: {entity_name}ResponseDto }})
|
|
@ApiResponse({{ status: 400, description: 'Datos inválidos' }})
|
|
@ApiResponse({{ status: 403, description: 'Sin permisos' }})
|
|
async create(
|
|
@CurrentTenant() tenantId: string,
|
|
@CurrentUser('id') userId: string,
|
|
@Body() dto: Create{entity_name}Dto,
|
|
) {{
|
|
const entity = await this.service.create(tenantId, userId, dto);
|
|
return {{
|
|
data: entity,
|
|
message: '{entity_name} creado exitosamente',
|
|
}};
|
|
}}
|
|
|
|
@Get()
|
|
@RequirePermissions('{module.lower()}.{entity_name.lower()}.read')
|
|
@ApiOperation({{ summary: 'Listar {entity_name.lower()}s' }})
|
|
@ApiResponse({{ status: 200, description: 'Lista de {entity_name.lower()}s', type: [{entity_name}ResponseDto] }})
|
|
async findAll(
|
|
@CurrentTenant() tenantId: string,
|
|
@Query() filters: Filter{entity_name}Dto,
|
|
) {{
|
|
return this.service.findAll(tenantId, filters);
|
|
}}
|
|
|
|
@Get(':id')
|
|
@RequirePermissions('{module.lower()}.{entity_name.lower()}.read')
|
|
@ApiOperation({{ summary: 'Obtener {entity_name.lower()} por ID' }})
|
|
@ApiResponse({{ status: 200, description: '{entity_name} encontrado', type: {entity_name}ResponseDto }})
|
|
@ApiResponse({{ status: 404, description: '{entity_name} no encontrado' }})
|
|
async findOne(
|
|
@CurrentTenant() tenantId: string,
|
|
@Param('id') id: string,
|
|
) {{
|
|
const entity = await this.service.findOne(tenantId, id);
|
|
return {{ data: entity }};
|
|
}}
|
|
|
|
@Put(':id')
|
|
@RequirePermissions('{module.lower()}.{entity_name.lower()}.update')
|
|
@ApiOperation({{ summary: 'Actualizar {entity_name.lower()}' }})
|
|
@ApiResponse({{ status: 200, description: '{entity_name} actualizado exitosamente' }})
|
|
@ApiResponse({{ status: 404, description: '{entity_name} no encontrado' }})
|
|
async update(
|
|
@CurrentTenant() tenantId: string,
|
|
@CurrentUser('id') userId: string,
|
|
@Param('id') id: string,
|
|
@Body() dto: Update{entity_name}Dto,
|
|
) {{
|
|
const entity = await this.service.update(tenantId, userId, id, dto);
|
|
return {{
|
|
data: entity,
|
|
message: '{entity_name} actualizado exitosamente',
|
|
}};
|
|
}}
|
|
|
|
@Delete(':id')
|
|
@HttpCode(HttpStatus.NO_CONTENT)
|
|
@RequirePermissions('{module.lower()}.{entity_name.lower()}.delete')
|
|
@ApiOperation({{ summary: 'Eliminar {entity_name.lower()}' }})
|
|
@ApiResponse({{ status: 204, description: '{entity_name} eliminado exitosamente' }})
|
|
@ApiResponse({{ status: 404, description: '{entity_name} no encontrado' }})
|
|
async remove(
|
|
@CurrentTenant() tenantId: string,
|
|
@CurrentUser('id') userId: string,
|
|
@Param('id') id: string,
|
|
) {{
|
|
await this.service.remove(tenantId, userId, id);
|
|
}}
|
|
}}
|
|
```
|
|
|
|
## Reglas de Negocio (Implementación)
|
|
|
|
'''
|
|
|
|
# Agregar reglas de negocio del RF
|
|
if rf_data["rules"]:
|
|
for i, rule in enumerate(rf_data["rules"][:5], 1): # Max 5 reglas
|
|
template += f'''
|
|
### RN-{i}: {rule}
|
|
```typescript
|
|
private async validateRN{i}(dto: Partial<Create{entity_name}Dto>): Promise<void> {{
|
|
// Implementación de la regla: {rule}
|
|
// TODO: Implementar validación específica
|
|
}}
|
|
```
|
|
'''
|
|
|
|
template += f'''
|
|
|
|
## Seguridad
|
|
|
|
### Multi-Tenancy (RLS)
|
|
- Prisma middleware automático que agrega `tenantId` a todas las queries
|
|
- Row Level Security en PostgreSQL valida acceso
|
|
- Soft delete: usa `deletedAt` en lugar de borrado físico
|
|
|
|
### Permisos (RBAC)
|
|
```typescript
|
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
|
@RequirePermissions('{module.lower()}.{entity_name.lower()}.create')
|
|
@Post()
|
|
async create(...) {{ ... }}
|
|
```
|
|
|
|
Permisos requeridos:
|
|
- `{module.lower()}.{entity_name.lower()}.create` - Crear registros
|
|
- `{module.lower()}.{entity_name.lower()}.read` - Leer registros
|
|
- `{module.lower()}.{entity_name.lower()}.update` - Actualizar registros
|
|
- `{module.lower()}.{entity_name.lower()}.delete` - Eliminar registros
|
|
|
|
### Validación de Input
|
|
- DTOs con class-validator para validación automática
|
|
- Sanitización de strings (XSS prevention)
|
|
- Rate limiting: 100 requests/min por usuario
|
|
- SQL Injection prevention: Prisma ORM con prepared statements
|
|
|
|
### Auditoría
|
|
Todos los cambios registran:
|
|
- `createdBy` / `updatedBy` / `deletedBy` - Usuario que ejecuta acción
|
|
- `createdAt` / `updatedAt` / `deletedAt` - Timestamp de acción
|
|
- Integración con MGN-014 (Tracking Automático de Cambios)
|
|
|
|
## Testing
|
|
|
|
### Unit Tests
|
|
```typescript
|
|
// {entity_name.lower()}.service.spec.ts
|
|
import {{ Test, TestingModule }} from '@nestjs/testing';
|
|
import {{ {entity_name}Service }} from './{entity_name.lower()}.service';
|
|
import {{ PrismaService }} from '@shared/prisma/prisma.service';
|
|
import {{ NotFoundException, ConflictException }} from '@nestjs/common';
|
|
|
|
describe('{entity_name}Service', () => {{
|
|
let service: {entity_name}Service;
|
|
let prisma: PrismaService;
|
|
|
|
const mockPrisma = {{
|
|
{entity_name.lower()}: {{
|
|
create: jest.fn(),
|
|
findMany: jest.fn(),
|
|
findFirst: jest.fn(),
|
|
update: jest.fn(),
|
|
count: jest.fn(),
|
|
}},
|
|
}};
|
|
|
|
beforeEach(async () => {{
|
|
const module: TestingModule = await Test.createTestingModule({{
|
|
providers: [
|
|
{entity_name}Service,
|
|
{{ provide: PrismaService, useValue: mockPrisma }},
|
|
],
|
|
}}).compile();
|
|
|
|
service = module.get<{entity_name}Service>({entity_name}Service);
|
|
prisma = module.get<PrismaService>(PrismaService);
|
|
}});
|
|
|
|
afterEach(() => {{
|
|
jest.clearAllMocks();
|
|
}});
|
|
|
|
describe('create', () => {{
|
|
it('should create {entity_name.lower()} with valid data', async () => {{
|
|
const tenantId = 'tenant-123';
|
|
const userId = 'user-456';
|
|
const dto = {{ name: 'Test {entity_name}', code: 'TEST' }};
|
|
const expected = {{ id: 'id-789', tenantId, ...dto }};
|
|
|
|
mockPrisma.{entity_name.lower()}.findFirst.mockResolvedValue(null);
|
|
mockPrisma.{entity_name.lower()}.create.mockResolvedValue(expected);
|
|
|
|
const result = await service.create(tenantId, userId, dto);
|
|
|
|
expect(result).toEqual(expected);
|
|
expect(mockPrisma.{entity_name.lower()}.create).toHaveBeenCalledWith({{
|
|
data: {{ tenantId, createdBy: userId, ...dto }},
|
|
}});
|
|
}});
|
|
|
|
it('should throw ConflictException when code already exists', async () => {{
|
|
const tenantId = 'tenant-123';
|
|
const userId = 'user-456';
|
|
const dto = {{ name: 'Test {entity_name}', code: 'DUPLICATE' }};
|
|
|
|
mockPrisma.{entity_name.lower()}.findFirst.mockResolvedValue({{ id: 'existing' }});
|
|
|
|
await expect(service.create(tenantId, userId, dto)).rejects.toThrow(ConflictException);
|
|
}});
|
|
}});
|
|
|
|
describe('findOne', () => {{
|
|
it('should return {entity_name.lower()} when found', async () => {{
|
|
const tenantId = 'tenant-123';
|
|
const id = 'id-789';
|
|
const expected = {{ id, tenantId, name: 'Test' }};
|
|
|
|
mockPrisma.{entity_name.lower()}.findFirst.mockResolvedValue(expected);
|
|
|
|
const result = await service.findOne(tenantId, id);
|
|
|
|
expect(result).toEqual(expected);
|
|
}});
|
|
|
|
it('should throw NotFoundException when not found', async () => {{
|
|
const tenantId = 'tenant-123';
|
|
const id = 'non-existent';
|
|
|
|
mockPrisma.{entity_name.lower()}.findFirst.mockResolvedValue(null);
|
|
|
|
await expect(service.findOne(tenantId, id)).rejects.toThrow(NotFoundException);
|
|
}});
|
|
}});
|
|
|
|
describe('update', () => {{
|
|
it('should update {entity_name.lower()} successfully', async () => {{
|
|
const tenantId = 'tenant-123';
|
|
const userId = 'user-456';
|
|
const id = 'id-789';
|
|
const dto = {{ name: 'Updated Name' }};
|
|
const existing = {{ id, tenantId, name: 'Old Name' }};
|
|
const expected = {{ ...existing, ...dto }};
|
|
|
|
mockPrisma.{entity_name.lower()}.findFirst.mockResolvedValue(existing);
|
|
mockPrisma.{entity_name.lower()}.update.mockResolvedValue(expected);
|
|
|
|
const result = await service.update(tenantId, userId, id, dto);
|
|
|
|
expect(result).toEqual(expected);
|
|
}});
|
|
}});
|
|
|
|
describe('remove', () => {{
|
|
it('should soft delete {entity_name.lower()}', async () => {{
|
|
const tenantId = 'tenant-123';
|
|
const userId = 'user-456';
|
|
const id = 'id-789';
|
|
const existing = {{ id, tenantId, name: 'To Delete' }};
|
|
|
|
mockPrisma.{entity_name.lower()}.findFirst.mockResolvedValue(existing);
|
|
mockPrisma.{entity_name.lower()}.update.mockResolvedValue({{ ...existing, deletedAt: new Date() }});
|
|
|
|
await service.remove(tenantId, userId, id);
|
|
|
|
expect(mockPrisma.{entity_name.lower()}.update).toHaveBeenCalledWith({{
|
|
where: {{ id }},
|
|
data: {{
|
|
deletedAt: expect.any(Date),
|
|
deletedBy: userId,
|
|
}},
|
|
}});
|
|
}});
|
|
}});
|
|
}});
|
|
```
|
|
|
|
### Integration Tests (e2e)
|
|
```typescript
|
|
// {entity_name.lower()}.controller.e2e-spec.ts
|
|
import {{ Test, TestingModule }} from '@nestjs/testing';
|
|
import {{ INestApplication }} from '@nestjs/common';
|
|
import * as request from 'supertest';
|
|
import {{ AppModule }} from '@/app.module';
|
|
|
|
describe('{entity_name}Controller (e2e)', () => {{
|
|
let app: INestApplication;
|
|
let authToken: string;
|
|
let tenantId: string;
|
|
|
|
beforeAll(async () => {{
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({{
|
|
imports: [AppModule],
|
|
}}).compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
await app.init();
|
|
|
|
// Login para obtener token
|
|
const loginResponse = await request(app.getHttpServer())
|
|
.post('/api/v1/auth/login')
|
|
.send({{ email: 'test@example.com', password: 'Test1234!' }});
|
|
|
|
authToken = loginResponse.body.data.accessToken;
|
|
tenantId = loginResponse.body.data.user.tenantId;
|
|
}});
|
|
|
|
afterAll(async () => {{
|
|
await app.close();
|
|
}});
|
|
|
|
describe('POST /api/v1/{resource}', () => {{
|
|
it('should create {entity_name.lower()} successfully', () => {{
|
|
return request(app.getHttpServer())
|
|
.post('/api/v1/{resource}')
|
|
.set('Authorization', `Bearer ${{authToken}}`)
|
|
.send({{
|
|
name: 'Test {entity_name}',
|
|
code: 'TEST001',
|
|
}})
|
|
.expect(201)
|
|
.expect((res) => {{
|
|
expect(res.body.data).toHaveProperty('id');
|
|
expect(res.body.data.name).toBe('Test {entity_name}');
|
|
expect(res.body.message).toContain('creado exitosamente');
|
|
}});
|
|
}});
|
|
|
|
it('should return 400 for invalid data', () => {{
|
|
return request(app.getHttpServer())
|
|
.post('/api/v1/{resource}')
|
|
.set('Authorization', `Bearer ${{authToken}}`)
|
|
.send({{ name: '' }}) // Invalid: name too short
|
|
.expect(400);
|
|
}});
|
|
|
|
it('should return 401 without token', () => {{
|
|
return request(app.getHttpServer())
|
|
.post('/api/v1/{resource}')
|
|
.send({{ name: 'Test' }})
|
|
.expect(401);
|
|
}});
|
|
}});
|
|
|
|
describe('GET /api/v1/{resource}', () => {{
|
|
it('should return list of {entity_name.lower()}s', () => {{
|
|
return request(app.getHttpServer())
|
|
.get('/api/v1/{resource}')
|
|
.set('Authorization', `Bearer ${{authToken}}`)
|
|
.expect(200)
|
|
.expect((res) => {{
|
|
expect(res.body.data).toBeInstanceOf(Array);
|
|
expect(res.body.meta).toHaveProperty('page');
|
|
expect(res.body.meta).toHaveProperty('total');
|
|
}});
|
|
}});
|
|
|
|
it('should filter by search term', () => {{
|
|
return request(app.getHttpServer())
|
|
.get('/api/v1/{resource}?search=test')
|
|
.set('Authorization', `Bearer ${{authToken}}`)
|
|
.expect(200);
|
|
}});
|
|
}});
|
|
|
|
describe('GET /api/v1/{resource}/:id', () => {{
|
|
it('should return {entity_name.lower()} by ID', async () => {{
|
|
// Crear primero
|
|
const createRes = await request(app.getHttpServer())
|
|
.post('/api/v1/{resource}')
|
|
.set('Authorization', `Bearer ${{authToken}}`)
|
|
.send({{ name: 'Find By ID Test' }});
|
|
|
|
const id = createRes.body.data.id;
|
|
|
|
return request(app.getHttpServer())
|
|
.get(`/api/v1/{resource}/${{id}}`)
|
|
.set('Authorization', `Bearer ${{authToken}}`)
|
|
.expect(200)
|
|
.expect((res) => {{
|
|
expect(res.body.data.id).toBe(id);
|
|
}});
|
|
}});
|
|
|
|
it('should return 404 for non-existent ID', () => {{
|
|
return request(app.getHttpServer())
|
|
.get('/api/v1/{resource}/non-existent-id')
|
|
.set('Authorization', `Bearer ${{authToken}}`)
|
|
.expect(404);
|
|
}});
|
|
}});
|
|
|
|
describe('PUT /api/v1/{resource}/:id', () => {{
|
|
it('should update {entity_name.lower()} successfully', async () => {{
|
|
// Crear primero
|
|
const createRes = await request(app.getHttpServer())
|
|
.post('/api/v1/{resource}')
|
|
.set('Authorization', `Bearer ${{authToken}}`)
|
|
.send({{ name: 'Original Name' }});
|
|
|
|
const id = createRes.body.data.id;
|
|
|
|
return request(app.getHttpServer())
|
|
.put(`/api/v1/{resource}/${{id}}`)
|
|
.set('Authorization', `Bearer ${{authToken}}`)
|
|
.send({{ name: 'Updated Name' }})
|
|
.expect(200)
|
|
.expect((res) => {{
|
|
expect(res.body.data.name).toBe('Updated Name');
|
|
}});
|
|
}});
|
|
}});
|
|
|
|
describe('DELETE /api/v1/{resource}/:id', () => {{
|
|
it('should soft delete {entity_name.lower()}', async () => {{
|
|
// Crear primero
|
|
const createRes = await request(app.getHttpServer())
|
|
.post('/api/v1/{resource}')
|
|
.set('Authorization', `Bearer ${{authToken}}`)
|
|
.send({{ name: 'To Delete' }});
|
|
|
|
const id = createRes.body.data.id;
|
|
|
|
await request(app.getHttpServer())
|
|
.delete(`/api/v1/{resource}/${{id}}`)
|
|
.set('Authorization', `Bearer ${{authToken}}`)
|
|
.expect(204);
|
|
|
|
// Verificar que no aparece en listados
|
|
const listRes = await request(app.getHttpServer())
|
|
.get('/api/v1/{resource}')
|
|
.set('Authorization', `Bearer ${{authToken}}`);
|
|
|
|
const deleted = listRes.body.data.find(item => item.id === id);
|
|
expect(deleted).toBeUndefined();
|
|
}});
|
|
}});
|
|
}});
|
|
```
|
|
|
|
## Performance
|
|
|
|
### Índices Necesarios
|
|
```sql
|
|
-- Índices definidos en {schema}-schema-ddl.sql
|
|
CREATE INDEX idx_{entity_name.lower()}_tenant_code ON {schema}.{entity_name.lower()}(tenant_id, code);
|
|
CREATE INDEX idx_{entity_name.lower()}_tenant_name ON {schema}.{entity_name.lower()}(tenant_id, name);
|
|
CREATE INDEX idx_{entity_name.lower()}_created_at ON {schema}.{entity_name.lower()}(created_at DESC);
|
|
|
|
-- Para soft delete
|
|
CREATE INDEX idx_{entity_name.lower()}_deleted_at ON {schema}.{entity_name.lower()}(deleted_at) WHERE deleted_at IS NULL;
|
|
```
|
|
|
|
### Caching (Redis)
|
|
```typescript
|
|
// Para listas maestras estáticas
|
|
@Cacheable({{ ttl: 3600 }}) // 1 hora
|
|
async findAll(tenantId: string, filters: Filter{entity_name}Dto) {{
|
|
// ...
|
|
}}
|
|
|
|
// Invalidación en cambios
|
|
@CacheEvict({{ pattern: '{entity_name.lower()}:*' }})
|
|
async update(...) {{ ... }}
|
|
|
|
@CacheEvict({{ pattern: '{entity_name.lower()}:*' }})
|
|
async remove(...) {{ ... }}
|
|
```
|
|
|
|
### Paginación
|
|
- **Default:** 20 items por página
|
|
- **Máximo:** 100 items por página
|
|
- **Cursor-based:** Para listados grandes (opcional)
|
|
|
|
### Query Optimization
|
|
```typescript
|
|
// Usar select para reducir payload
|
|
async findAll(tenantId: string, filters: Filter{entity_name}Dto) {{
|
|
return this.prisma.{entity_name.lower()}.findMany({{
|
|
where: {{ ... }},
|
|
select: {{
|
|
id: true,
|
|
name: true,
|
|
code: true,
|
|
createdAt: true,
|
|
// Omitir campos pesados
|
|
}},
|
|
}});
|
|
}}
|
|
```
|
|
|
|
## Logging y Monitoreo
|
|
|
|
```typescript
|
|
import {{ Logger }} from '@nestjs/common';
|
|
|
|
export class {entity_name}Service {{
|
|
private readonly logger = new Logger({entity_name}Service.name);
|
|
|
|
async create(tenantId: string, userId: string, dto: Create{entity_name}Dto) {{
|
|
this.logger.log({{
|
|
action: '{entity_name.lower()}.create',
|
|
tenantId,
|
|
userId,
|
|
data: dto,
|
|
}});
|
|
|
|
try {{
|
|
const entity = await this.prisma.{entity_name.lower()}.create({{ ... }});
|
|
|
|
this.logger.log({{
|
|
action: '{entity_name.lower()}.created',
|
|
tenantId,
|
|
userId,
|
|
entityId: entity.id,
|
|
}});
|
|
|
|
return entity;
|
|
}} catch (error) {{
|
|
this.logger.error({{
|
|
action: '{entity_name.lower()}.create.error',
|
|
tenantId,
|
|
userId,
|
|
error: error.message,
|
|
stack: error.stack,
|
|
}});
|
|
throw error;
|
|
}}
|
|
}}
|
|
}}
|
|
```
|
|
|
|
### Métricas (Prometheus)
|
|
- `http_requests_total{{method="POST",endpoint="/api/v1/{resource}",status="201"}}`
|
|
- `http_request_duration_seconds{{endpoint="/api/v1/{resource}"}}`
|
|
- `{entity_name.lower()}_created_total{{tenant_id="xxx"}}`
|
|
|
|
## Referencias
|
|
|
|
- [RF Asociado](../../requerimientos-funcionales/{module.lower()}/RF-{module}-{rf_num}-{title.lower().replace(" ", "-").replace("(", "").replace(")", "").replace("/", "-")}.md)
|
|
- [Database Schema](../database-design/schemas/{schema}-schema-ddl.sql)
|
|
- [Domain Model](../domain-models/{schema}-domain.md)
|
|
- [ADR-001: Stack Tecnológico](../../adr/ADR-001-stack-tecnologico.md)
|
|
- [ADR-005: API Design](../../adr/ADR-005-api-design.md)
|
|
|
|
## Dependencias
|
|
|
|
### Módulos NestJS
|
|
- `AuthModule` - Autenticación y autorización
|
|
- `PrismaModule` - ORM y acceso a BD
|
|
- `LoggerModule` - Logging centralizado
|
|
- `EventsModule` - Event emitter (opcional)
|
|
|
|
### RF Bloqueantes
|
|
- RF-MGN-001-001 (Autenticación de Usuarios)
|
|
- RF-MGN-001-004 (Multi-Tenancy)
|
|
|
|
### RF Dependientes
|
|
- [Listar RF que dependen de este]
|
|
|
|
## Notas de Implementación
|
|
|
|
- [ ] Crear módulo NestJS: `nest g module {module.lower()}/{entity_name.lower()}`
|
|
- [ ] Crear service: `nest g service {module.lower()}/{entity_name.lower()}`
|
|
- [ ] Crear controller: `nest g controller {module.lower()}/{entity_name.lower()}`
|
|
- [ ] Crear DTOs en `{module.lower()}/dto/` folder
|
|
- [ ] Definir Prisma model en `schema.prisma`
|
|
- [ ] Implementar unit tests (>80% coverage)
|
|
- [ ] Implementar e2e tests
|
|
- [ ] Documentar endpoints en Swagger
|
|
- [ ] Validar con criterios de aceptación del RF
|
|
- [ ] Code review por Tech Lead
|
|
- [ ] QA testing
|
|
|
|
## Estimación
|
|
|
|
- **Backend Development:** {sp_backend} SP
|
|
- **Testing (Unit + e2e):** {sp_testing} SP
|
|
- **Code Review + QA:** {sp_review} SP
|
|
- **Total:** {sp} SP
|
|
|
|
---
|
|
|
|
**Documento generado:** 2025-11-24
|
|
**Versión:** 1.0
|
|
**Estado:** Diseñado
|
|
**Próximo paso:** Implementación
|
|
'''
|
|
|
|
return template
|
|
|
|
|
|
def generate_frontend_et(rf_data: Dict, resource: str) -> str:
|
|
"""Genera contenido del ET-Frontend"""
|
|
module = rf_data["module"]
|
|
rf_num = rf_data["rf_num"]
|
|
title = rf_data["title"]
|
|
sp = rf_data["story_points"]
|
|
sp_frontend = int(sp * 0.5) # 50% frontend
|
|
sp_testing = int(sp * 0.3) # 30% testing
|
|
sp_review = int(sp * 0.2) # 20% review
|
|
|
|
# Convertir título a PascalCase
|
|
entity_name = "".join(word.capitalize() for word in title.lower().split() if word not in ["de", "del", "la", "el", "y", "a"])
|
|
entity_name = entity_name.replace("(", "").replace(")", "").replace("/", "")
|
|
|
|
entity_name_lower = entity_name[0].lower() + entity_name[1:] if entity_name else "entity"
|
|
|
|
template = f'''# ET-FRONTEND-{module}-{rf_num}: {title}
|
|
|
|
**RF Asociado:** [RF-{module}-{rf_num}](../../requerimientos-funcionales/{module.lower()}/RF-{module}-{rf_num}-{title.lower().replace(" ", "-").replace("(", "").replace(")", "").replace("/", "-")}.md)
|
|
**ET Backend:** [ET-BACKEND-{module}-{rf_num}](../backend/{module.lower()}/ET-BACKEND-{module}-{rf_num}-{title.lower().replace(" ", "-").replace("(", "").replace(")", "").replace("/", "-")}.md)
|
|
**Módulo:** {module}
|
|
**Complejidad:** {'Alta' if sp >= 13 else 'Media' if sp >= 8 else 'Baja'}
|
|
**Story Points:** {sp_frontend} SP (Frontend)
|
|
**Estado:** Diseñado
|
|
**Fecha:** 2025-11-24
|
|
|
|
## Resumen Técnico
|
|
|
|
Implementación frontend para {title.lower()}. Incluye componentes React con TypeScript, formularios con validación, integración con API backend, y arquitectura Feature-Sliced Design (FSD).
|
|
|
|
## Stack Tecnológico
|
|
|
|
- **Framework:** React 18.x + TypeScript 5.x
|
|
- **Build Tool:** Vite 5.x
|
|
- **UI Library:** Ant Design 5.x (AntD)
|
|
- **State Management:** Zustand + React Query (TanStack Query)
|
|
- **Routing:** React Router 6.x
|
|
- **Forms:** React Hook Form + Zod validation
|
|
- **HTTP Client:** Axios (con interceptors para auth)
|
|
- **Testing:** Vitest + React Testing Library + Playwright
|
|
|
|
## Arquitectura Frontend (FSD)
|
|
|
|
Estructura basada en Feature-Sliced Design:
|
|
|
|
```
|
|
src/
|
|
├── app/ # App-level config
|
|
│ ├── providers/
|
|
│ │ └── router.tsx
|
|
│ └── styles/
|
|
│ └── theme.ts
|
|
├── pages/ # Route pages
|
|
│ └── {entity_name}Page/
|
|
│ ├── index.tsx
|
|
│ └── {entity_name}Page.tsx
|
|
├── widgets/ # Complex UI blocks
|
|
│ └── {entity_name}Table/
|
|
│ ├── ui/
|
|
│ │ └── {entity_name}Table.tsx
|
|
│ └── index.ts
|
|
├── features/ # User interactions
|
|
│ ├── create{entity_name}/
|
|
│ │ ├── ui/
|
|
│ │ │ └── Create{entity_name}Form.tsx
|
|
│ │ ├── model/
|
|
│ │ │ └── use{entity_name}Actions.ts
|
|
│ │ └── index.ts
|
|
│ ├── update{entity_name}/
|
|
│ └── delete{entity_name}/
|
|
├── entities/ # Business entities
|
|
│ └── {entity_name_lower}/
|
|
│ ├── model/
|
|
│ │ ├── types.ts
|
|
│ │ ├── schemas.ts
|
|
│ │ └── {entity_name_lower}.store.ts
|
|
│ ├── api/
|
|
│ │ ├── {entity_name_lower}.api.ts
|
|
│ │ └── {entity_name_lower}.queries.ts
|
|
│ ├── ui/
|
|
│ │ └── {entity_name}Card.tsx
|
|
│ └── index.ts
|
|
└── shared/ # Shared code
|
|
├── ui/ # UI kit
|
|
│ ├── Button/
|
|
│ ├── Modal/
|
|
│ └── Table/
|
|
├── api/
|
|
│ └── client.ts
|
|
└── lib/
|
|
└── utils.ts
|
|
```
|
|
|
|
## Rutas
|
|
|
|
```typescript
|
|
// src/app/routes/{module.lower()}.routes.tsx
|
|
export const {entity_name}Routes = {{
|
|
list: '/{module.lower()}/{entity_name_lower}',
|
|
create: '/{module.lower()}/{entity_name_lower}/create',
|
|
edit: '/{module.lower()}/{entity_name_lower}/:id/edit',
|
|
view: '/{module.lower()}/{entity_name_lower}/:id',
|
|
}};
|
|
|
|
// Integración en Router
|
|
<Route path="/{module.lower()}">
|
|
<Route path="{entity_name_lower}" element={{<{entity_name}Page />}} />
|
|
<Route path="{entity_name_lower}/create" element={{<Create{entity_name}Page />}} />
|
|
<Route path="{entity_name_lower}/:id/edit" element={{<Edit{entity_name}Page />}} />
|
|
<Route path="{entity_name_lower}/:id" element={{<View{entity_name}Page />}} />
|
|
</Route>
|
|
```
|
|
|
|
## Types / Interfaces
|
|
|
|
```typescript
|
|
// src/entities/{entity_name_lower}/model/types.ts
|
|
|
|
export interface {entity_name} {{
|
|
id: string;
|
|
tenantId: string;
|
|
name: string;
|
|
code?: string;
|
|
createdAt: string;
|
|
updatedAt?: string;
|
|
deletedAt?: string;
|
|
}}
|
|
|
|
export interface Create{entity_name}Dto {{
|
|
name: string;
|
|
code?: string;
|
|
}}
|
|
|
|
export type Update{entity_name}Dto = Partial<Create{entity_name}Dto>;
|
|
|
|
export interface {entity_name}Filters {{
|
|
search?: string;
|
|
page?: number;
|
|
limit?: number;
|
|
sortBy?: string;
|
|
sortOrder?: 'asc' | 'desc';
|
|
}}
|
|
|
|
export interface {entity_name}ListResponse {{
|
|
data: {entity_name}[];
|
|
meta: {{
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
totalPages: number;
|
|
}};
|
|
}}
|
|
```
|
|
|
|
## Schemas de Validación (Zod)
|
|
|
|
```typescript
|
|
// src/entities/{entity_name_lower}/model/schemas.ts
|
|
import {{ z }} from 'zod';
|
|
|
|
export const create{entity_name}Schema = z.object({{
|
|
name: z.string()
|
|
.min(3, 'El nombre debe tener al menos 3 caracteres')
|
|
.max(255, 'El nombre no puede exceder 255 caracteres'),
|
|
code: z.string()
|
|
.min(2, 'El código debe tener al menos 2 caracteres')
|
|
.max(50, 'El código no puede exceder 50 caracteres')
|
|
.optional(),
|
|
}});
|
|
|
|
export const update{entity_name}Schema = create{entity_name}Schema.partial();
|
|
|
|
export type Create{entity_name}FormData = z.infer<typeof create{entity_name}Schema>;
|
|
export type Update{entity_name}FormData = z.infer<typeof update{entity_name}Schema>;
|
|
|
|
// Validación personalizada (ejemplo)
|
|
export const validate{entity_name}Code = (code: string): boolean => {{
|
|
return /^[A-Z0-9-]+$/.test(code);
|
|
}};
|
|
```
|
|
|
|
## API Client
|
|
|
|
```typescript
|
|
// src/entities/{entity_name_lower}/api/{entity_name_lower}.api.ts
|
|
import {{ apiClient }} from '@shared/api/client';
|
|
import type {{
|
|
{entity_name},
|
|
Create{entity_name}Dto,
|
|
Update{entity_name}Dto,
|
|
{entity_name}Filters,
|
|
{entity_name}ListResponse
|
|
}} from '../model/types';
|
|
|
|
const BASE_URL = '/api/v1/{resource}';
|
|
|
|
export const {entity_name_lower}Api = {{
|
|
getAll: async (filters?: {entity_name}Filters): Promise<{entity_name}ListResponse> => {{
|
|
const {{ data }} = await apiClient.get<{entity_name}ListResponse>(BASE_URL, {{
|
|
params: filters,
|
|
}});
|
|
return data;
|
|
}},
|
|
|
|
getById: async (id: string): Promise<{entity_name}> => {{
|
|
const {{ data }} = await apiClient.get<{{ data: {entity_name} }}>(`${{BASE_URL}}/${{id}}`);
|
|
return data.data;
|
|
}},
|
|
|
|
create: async (dto: Create{entity_name}Dto): Promise<{entity_name}> => {{
|
|
const {{ data }} = await apiClient.post<{{ data: {entity_name} }}>(BASE_URL, dto);
|
|
return data.data;
|
|
}},
|
|
|
|
update: async (id: string, dto: Update{entity_name}Dto): Promise<{entity_name}> => {{
|
|
const {{ data }} = await apiClient.put<{{ data: {entity_name} }}>(`${{BASE_URL}}/${{id}}`, dto);
|
|
return data.data;
|
|
}},
|
|
|
|
delete: async (id: string): Promise<void> => {{
|
|
await apiClient.delete(`${{BASE_URL}}/${{id}}`);
|
|
}},
|
|
}};
|
|
|
|
// Configuración de Axios client
|
|
// src/shared/api/client.ts
|
|
import axios from 'axios';
|
|
import {{ message }} from 'antd';
|
|
|
|
export const apiClient = axios.create({{
|
|
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000',
|
|
timeout: 30000,
|
|
}});
|
|
|
|
// Request interceptor: agregar auth token
|
|
apiClient.interceptors.request.use(
|
|
(config) => {{
|
|
const token = localStorage.getItem('accessToken');
|
|
if (token) {{
|
|
config.headers.Authorization = `Bearer ${{token}}`;
|
|
}}
|
|
return config;
|
|
}},
|
|
(error) => Promise.reject(error)
|
|
);
|
|
|
|
// Response interceptor: manejar errores globales
|
|
apiClient.interceptors.response.use(
|
|
(response) => response,
|
|
(error) => {{
|
|
const {{ response }} = error;
|
|
|
|
if (response?.status === 401) {{
|
|
// Token expirado: redirigir a login
|
|
localStorage.removeItem('accessToken');
|
|
window.location.href = '/login';
|
|
}} else if (response?.status === 403) {{
|
|
message.error('No tienes permisos para realizar esta acción');
|
|
}} else if (response?.status === 500) {{
|
|
message.error('Error interno del servidor');
|
|
}}
|
|
|
|
return Promise.reject(error);
|
|
}}
|
|
);
|
|
```
|
|
|
|
## State Management (Zustand + React Query)
|
|
|
|
### Zustand Store (estado local UI)
|
|
```typescript
|
|
// src/entities/{entity_name_lower}/model/{entity_name_lower}.store.ts
|
|
import {{ create }} from 'zustand';
|
|
import {{ {entity_name} }} from './types';
|
|
|
|
interface {entity_name}Store {{
|
|
selected{entity_name}: {entity_name} | null;
|
|
isModalOpen: boolean;
|
|
modalMode: 'create' | 'edit' | 'view' | null;
|
|
|
|
setSelected{entity_name}: (entity: {entity_name} | null) => void;
|
|
openModal: (mode: 'create' | 'edit' | 'view', entity?: {entity_name}) => void;
|
|
closeModal: () => void;
|
|
}}
|
|
|
|
export const use{entity_name}Store = create<{entity_name}Store>((set) => ({{
|
|
selected{entity_name}: null,
|
|
isModalOpen: false,
|
|
modalMode: null,
|
|
|
|
setSelected{entity_name}: (entity) => set({{ selected{entity_name}: entity }}),
|
|
|
|
openModal: (mode, entity) => set({{
|
|
isModalOpen: true,
|
|
modalMode: mode,
|
|
selected{entity_name}: entity || null,
|
|
}}),
|
|
|
|
closeModal: () => set({{
|
|
isModalOpen: false,
|
|
modalMode: null,
|
|
selected{entity_name}: null,
|
|
}}),
|
|
}}));
|
|
```
|
|
|
|
### React Query Hooks (servidor state)
|
|
```typescript
|
|
// src/entities/{entity_name_lower}/api/{entity_name_lower}.queries.ts
|
|
import {{ useQuery, useMutation, useQueryClient }} from '@tanstack/react-query';
|
|
import {{ message }} from 'antd';
|
|
import {{ {entity_name_lower}Api }} from './{entity_name_lower}.api';
|
|
import type {{ Create{entity_name}Dto, Update{entity_name}Dto, {entity_name}Filters }} from '../model/types';
|
|
|
|
const QUERY_KEY = '{entity_name_lower}';
|
|
|
|
// Query: obtener lista
|
|
export const use{entity_name}s = (filters?: {entity_name}Filters) => {{
|
|
return useQuery({{
|
|
queryKey: [QUERY_KEY, filters],
|
|
queryFn: () => {entity_name_lower}Api.getAll(filters),
|
|
staleTime: 5 * 60 * 1000, // 5 minutos
|
|
}});
|
|
}};
|
|
|
|
// Query: obtener por ID
|
|
export const use{entity_name} = (id: string) => {{
|
|
return useQuery({{
|
|
queryKey: [QUERY_KEY, id],
|
|
queryFn: () => {entity_name_lower}Api.getById(id),
|
|
enabled: !!id,
|
|
}});
|
|
}};
|
|
|
|
// Mutation: crear
|
|
export const useCreate{entity_name} = () => {{
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({{
|
|
mutationFn: (dto: Create{entity_name}Dto) => {entity_name_lower}Api.create(dto),
|
|
onSuccess: () => {{
|
|
queryClient.invalidateQueries({{ queryKey: [QUERY_KEY] }});
|
|
message.success('{entity_name} creado exitosamente');
|
|
}},
|
|
onError: (error: any) => {{
|
|
const errorMsg = error.response?.data?.message || 'Error al crear {entity_name.lower()}';
|
|
message.error(errorMsg);
|
|
}},
|
|
}});
|
|
}};
|
|
|
|
// Mutation: actualizar
|
|
export const useUpdate{entity_name} = () => {{
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({{
|
|
mutationFn: ({{ id, dto }}: {{ id: string; dto: Update{entity_name}Dto }}) =>
|
|
{entity_name_lower}Api.update(id, dto),
|
|
onSuccess: (data) => {{
|
|
queryClient.invalidateQueries({{ queryKey: [QUERY_KEY] }});
|
|
queryClient.setQueryData([QUERY_KEY, data.id], data);
|
|
message.success('{entity_name} actualizado exitosamente');
|
|
}},
|
|
onError: (error: any) => {{
|
|
const errorMsg = error.response?.data?.message || 'Error al actualizar {entity_name.lower()}';
|
|
message.error(errorMsg);
|
|
}},
|
|
}});
|
|
}};
|
|
|
|
// Mutation: eliminar
|
|
export const useDelete{entity_name} = () => {{
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({{
|
|
mutationFn: (id: string) => {entity_name_lower}Api.delete(id),
|
|
onSuccess: () => {{
|
|
queryClient.invalidateQueries({{ queryKey: [QUERY_KEY] }});
|
|
message.success('{entity_name} eliminado exitosamente');
|
|
}},
|
|
onError: (error: any) => {{
|
|
const errorMsg = error.response?.data?.message || 'Error al eliminar {entity_name.lower()}';
|
|
message.error(errorMsg);
|
|
}},
|
|
}});
|
|
}};
|
|
```
|
|
|
|
## Components UI
|
|
|
|
### Tabla de Listado
|
|
```typescript
|
|
// src/widgets/{entity_name}Table/ui/{entity_name}Table.tsx
|
|
import React, {{ useState }} from 'react';
|
|
import {{ Table, Button, Space, Input, Modal }} from 'antd';
|
|
import {{ EditOutlined, DeleteOutlined, EyeOutlined, PlusOutlined }} from '@ant-design/icons';
|
|
import type {{ ColumnsType }} from 'antd/es/table';
|
|
import {{ use{entity_name}s, useDelete{entity_name} }} from '@entities/{entity_name_lower}';
|
|
import {{ use{entity_name}Store }} from '@entities/{entity_name_lower}';
|
|
import type {{ {entity_name}, {entity_name}Filters }} from '@entities/{entity_name_lower}';
|
|
|
|
const {{ Search }} = Input;
|
|
|
|
export const {entity_name}Table: React.FC = () => {{
|
|
const [filters, setFilters] = useState<{entity_name}Filters>({{
|
|
page: 1,
|
|
limit: 20,
|
|
sortBy: 'createdAt',
|
|
sortOrder: 'desc',
|
|
}});
|
|
|
|
const {{ data, isLoading }} = use{entity_name}s(filters);
|
|
const deleteMutation = useDelete{entity_name}();
|
|
const {{ openModal }} = use{entity_name}Store();
|
|
|
|
const handleSearch = (value: string) => {{
|
|
setFilters((prev) => ({{ ...prev, search: value, page: 1 }}));
|
|
}};
|
|
|
|
const handleTableChange = (pagination: any, filters: any, sorter: any) => {{
|
|
setFilters((prev) => ({{
|
|
...prev,
|
|
page: pagination.current,
|
|
limit: pagination.pageSize,
|
|
sortBy: sorter.field || 'createdAt',
|
|
sortOrder: sorter.order === 'ascend' ? 'asc' : 'desc',
|
|
}}));
|
|
}};
|
|
|
|
const handleDelete = (id: string, name: string) => {{
|
|
Modal.confirm({{
|
|
title: '¿Confirmar eliminación?',
|
|
content: `¿Está seguro de eliminar "${{name}}"? Esta acción no se puede deshacer.`,
|
|
okText: 'Eliminar',
|
|
okType: 'danger',
|
|
cancelText: 'Cancelar',
|
|
onOk: () => deleteMutation.mutate(id),
|
|
}});
|
|
}};
|
|
|
|
const columns: ColumnsType<{entity_name}> = [
|
|
{{
|
|
title: 'Nombre',
|
|
dataIndex: 'name',
|
|
key: 'name',
|
|
sorter: true,
|
|
width: '40%',
|
|
}},
|
|
{{
|
|
title: 'Código',
|
|
dataIndex: 'code',
|
|
key: 'code',
|
|
width: '20%',
|
|
}},
|
|
{{
|
|
title: 'Fecha Creación',
|
|
dataIndex: 'createdAt',
|
|
key: 'createdAt',
|
|
sorter: true,
|
|
width: '20%',
|
|
render: (date: string) => new Date(date).toLocaleDateString('es-ES'),
|
|
}},
|
|
{{
|
|
title: 'Acciones',
|
|
key: 'actions',
|
|
width: '20%',
|
|
render: (_, record) => (
|
|
<Space>
|
|
<Button
|
|
icon={{<EyeOutlined />}}
|
|
onClick={{() => openModal('view', record)}}
|
|
title="Ver detalles"
|
|
/>
|
|
<Button
|
|
icon={{<EditOutlined />}}
|
|
onClick={{() => openModal('edit', record)}}
|
|
title="Editar"
|
|
/>
|
|
<Button
|
|
icon={{<DeleteOutlined />}}
|
|
danger
|
|
onClick={{() => handleDelete(record.id, record.name)}}
|
|
title="Eliminar"
|
|
/>
|
|
</Space>
|
|
),
|
|
}},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Space style={{{{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}}}>
|
|
<Search
|
|
placeholder="Buscar por nombre o código"
|
|
onSearch={{handleSearch}}
|
|
style={{{{ width: 300 }}}}
|
|
allowClear
|
|
/>
|
|
<Button
|
|
type="primary"
|
|
icon={{<PlusOutlined />}}
|
|
onClick={{() => openModal('create')}}
|
|
>
|
|
Crear {entity_name}
|
|
</Button>
|
|
</Space>
|
|
|
|
<Table
|
|
columns={{columns}}
|
|
dataSource={{data?.data || []}}
|
|
loading={{isLoading || deleteMutation.isPending}}
|
|
rowKey="id"
|
|
pagination={{{{
|
|
current: filters.page,
|
|
pageSize: filters.limit,
|
|
total: data?.meta.total || 0,
|
|
showSizeChanger: true,
|
|
showTotal: (total) => `Total: ${{total}} registros`,
|
|
}}}}
|
|
onChange={{handleTableChange}}
|
|
/>
|
|
</div>
|
|
);
|
|
}};
|
|
```
|
|
|
|
### Formulario de Creación/Edición
|
|
```typescript
|
|
// src/features/create{entity_name}/ui/Create{entity_name}Form.tsx
|
|
import React, {{ useEffect }} from 'react';
|
|
import {{ Form, Input, Button, Space }} from 'antd';
|
|
import {{ useForm, Controller }} from 'react-hook-form';
|
|
import {{ zodResolver }} from '@hookform/resolvers/zod';
|
|
import {{
|
|
create{entity_name}Schema,
|
|
update{entity_name}Schema,
|
|
type Create{entity_name}FormData
|
|
}} from '@entities/{entity_name_lower}';
|
|
import {{ useCreate{entity_name}, useUpdate{entity_name} }} from '@entities/{entity_name_lower}';
|
|
import type {{ {entity_name} }} from '@entities/{entity_name_lower}';
|
|
|
|
interface {entity_name}FormProps {{
|
|
mode: 'create' | 'edit';
|
|
initialData?: {entity_name};
|
|
onSuccess?: () => void;
|
|
}}
|
|
|
|
export const {entity_name}Form: React.FC<{entity_name}FormProps> = ({{
|
|
mode,
|
|
initialData,
|
|
onSuccess,
|
|
}}) => {{
|
|
const isEditMode = mode === 'edit';
|
|
const schema = isEditMode ? update{entity_name}Schema : create{entity_name}Schema;
|
|
|
|
const {{ control, handleSubmit, formState: {{ errors }}, reset }} = useForm<Create{entity_name}FormData>({{
|
|
resolver: zodResolver(schema),
|
|
defaultValues: initialData || {{
|
|
name: '',
|
|
code: '',
|
|
}},
|
|
}});
|
|
|
|
const createMutation = useCreate{entity_name}();
|
|
const updateMutation = useUpdate{entity_name}();
|
|
|
|
const mutation = isEditMode ? updateMutation : createMutation;
|
|
|
|
useEffect(() => {{
|
|
if (initialData) {{
|
|
reset(initialData);
|
|
}}
|
|
}}, [initialData, reset]);
|
|
|
|
const onSubmit = (data: Create{entity_name}FormData) => {{
|
|
if (isEditMode && initialData) {{
|
|
updateMutation.mutate(
|
|
{{ id: initialData.id, dto: data }},
|
|
{{ onSuccess: () => onSuccess?.() }}
|
|
);
|
|
}} else {{
|
|
createMutation.mutate(data, {{
|
|
onSuccess: () => {{
|
|
reset();
|
|
onSuccess?.();
|
|
}},
|
|
}});
|
|
}}
|
|
}};
|
|
|
|
return (
|
|
<Form layout="vertical" onFinish={{handleSubmit(onSubmit)}}>
|
|
<Controller
|
|
name="name"
|
|
control={{control}}
|
|
render={{({{ field }}) => (
|
|
<Form.Item
|
|
label="Nombre"
|
|
validateStatus={{errors.name ? 'error' : ''}}
|
|
help={{errors.name?.message}}
|
|
required
|
|
>
|
|
<Input {{...field}} placeholder="Ingrese el nombre" />
|
|
</Form.Item>
|
|
)}}
|
|
/>
|
|
|
|
<Controller
|
|
name="code"
|
|
control={{control}}
|
|
render={{({{ field }}) => (
|
|
<Form.Item
|
|
label="Código"
|
|
validateStatus={{errors.code ? 'error' : ''}}
|
|
help={{errors.code?.message}}
|
|
>
|
|
<Input {{...field}} placeholder="Código único (opcional)" />
|
|
</Form.Item>
|
|
)}}
|
|
/>
|
|
|
|
<Form.Item>
|
|
<Space>
|
|
<Button
|
|
type="primary"
|
|
htmlType="submit"
|
|
loading={{mutation.isPending}}
|
|
>
|
|
{{isEditMode ? 'Actualizar' : 'Crear'}}
|
|
</Button>
|
|
<Button onClick={{() => reset()}}>
|
|
Limpiar
|
|
</Button>
|
|
</Space>
|
|
</Form.Item>
|
|
</Form>
|
|
);
|
|
}};
|
|
```
|
|
|
|
### Modal Wrapper
|
|
```typescript
|
|
// src/features/create{entity_name}/ui/Create{entity_name}Modal.tsx
|
|
import React from 'react';
|
|
import {{ Modal }} from 'antd';
|
|
import {{ use{entity_name}Store }} from '@entities/{entity_name_lower}';
|
|
import {{ {entity_name}Form }} from './Create{entity_name}Form';
|
|
|
|
export const {entity_name}Modal: React.FC = () => {{
|
|
const {{ isModalOpen, modalMode, selected{entity_name}, closeModal }} = use{entity_name}Store();
|
|
|
|
const title = {{
|
|
create: 'Crear {entity_name}',
|
|
edit: 'Editar {entity_name}',
|
|
view: 'Ver {entity_name}',
|
|
}}[modalMode || 'create'];
|
|
|
|
return (
|
|
<Modal
|
|
title={{title}}
|
|
open={{isModalOpen}}
|
|
onCancel={{closeModal}}
|
|
footer={{null}}
|
|
width={{600}}
|
|
destroyOnClose
|
|
>
|
|
{{modalMode !== 'view' ? (
|
|
<{entity_name}Form
|
|
mode={{modalMode === 'edit' ? 'edit' : 'create'}}
|
|
initialData={{selected{entity_name} || undefined}}
|
|
onSuccess={{closeModal}}
|
|
/>
|
|
) : (
|
|
<div>
|
|
<p><strong>Nombre:</strong> {{selected{entity_name}?.name}}</p>
|
|
<p><strong>Código:</strong> {{selected{entity_name}?.code || 'N/A'}}</p>
|
|
<p><strong>Creado:</strong> {{new Date(selected{entity_name}?.createdAt || '').toLocaleString('es-ES')}}</p>
|
|
</div>
|
|
)}}
|
|
</Modal>
|
|
);
|
|
}};
|
|
```
|
|
|
|
### Página Principal
|
|
```typescript
|
|
// src/pages/{entity_name}Page/{entity_name}Page.tsx
|
|
import React from 'react';
|
|
import {{ Card }} from 'antd';
|
|
import {{ {entity_name}Table }} from '@widgets/{entity_name}Table';
|
|
import {{ {entity_name}Modal }} from '@features/create{entity_name}';
|
|
|
|
export const {entity_name}Page: React.FC = () => {{
|
|
return (
|
|
<div style={{{{ padding: 24 }}}}>
|
|
<Card title="{title}">
|
|
<{entity_name}Table />
|
|
</Card>
|
|
<{entity_name}Modal />
|
|
</div>
|
|
);
|
|
}};
|
|
```
|
|
|
|
## Validaciones del Cliente
|
|
|
|
### Validación en Tiempo Real
|
|
```typescript
|
|
// Las validaciones Zod se ejecutan automáticamente con React Hook Form
|
|
|
|
// Validación custom adicional (ejemplo)
|
|
const validateUnique{entity_name}Code = async (code: string): Promise<boolean> => {{
|
|
try {{
|
|
const {{ data }} = await {entity_name_lower}Api.getAll({{ search: code }});
|
|
return data.data.length === 0;
|
|
}} catch {{
|
|
return false;
|
|
}}
|
|
}};
|
|
|
|
// Uso en formulario
|
|
<Controller
|
|
name="code"
|
|
control={{control}}
|
|
rules={{{{
|
|
validate: async (value) => {{
|
|
if (value && !(await validateUnique{entity_name}Code(value))) {{
|
|
return 'Este código ya existe';
|
|
}}
|
|
return true;
|
|
}},
|
|
}}}}
|
|
render={{...}}
|
|
/>
|
|
```
|
|
|
|
### Mensajes de Error
|
|
- Español claro y conciso
|
|
- Sugerencias de corrección cuando sea posible
|
|
- Highlight visual de campos con error (Ant Design `validateStatus`)
|
|
|
|
## UX/UI
|
|
|
|
### Diseño Visual
|
|
- **Layout:** Ant Design Pro Layout (Header + Sidebar + Content)
|
|
- **Colores:**
|
|
- Primary: `#1890ff` (Ant Design default)
|
|
- Success: `#52c41a`
|
|
- Warning: `#faad14`
|
|
- Error: `#f5222d`
|
|
- **Tipografía:**
|
|
- Headings: Inter font family
|
|
- Body: Roboto font family
|
|
- **Iconos:** Ant Design Icons
|
|
|
|
### Feedback al Usuario
|
|
- **Loading States:** Spinners en botones (`Button loading={{true}}`) y tablas (`Table loading={{true}}`)
|
|
- **Success Messages:** `message.success('Operación exitosa')`
|
|
- **Error Messages:** `message.error('Error: detalles...')`
|
|
- **Confirmations:** `Modal.confirm()` para acciones destructivas (delete)
|
|
- **Progress:** `Progress` para operaciones largas
|
|
|
|
### Responsiveness
|
|
- **Desktop (1200px+):** Tabla completa con todas las columnas
|
|
- **Tablet (768-1199px):** Tabla adaptada, algunas columnas ocultas
|
|
- **Mobile (<768px):** Reemplazar tabla por cards (`<List>` de Ant Design)
|
|
|
|
```typescript
|
|
// Ejemplo responsive
|
|
import {{ useMediaQuery }} from '@shared/hooks/useMediaQuery';
|
|
|
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
|
|
|
return isMobile ? (
|
|
<List
|
|
dataSource={{data?.data}}
|
|
renderItem={{(item) => (
|
|
<List.Item>
|
|
<Card>{{/* Card layout */}}</Card>
|
|
</List.Item>
|
|
)}}
|
|
/>
|
|
) : (
|
|
<Table ... />
|
|
);
|
|
```
|
|
|
|
## Permisos (RBAC en UI)
|
|
|
|
```typescript
|
|
// src/shared/hooks/usePermissions.ts
|
|
import {{ useAuth }} from '@modules/auth';
|
|
|
|
export const usePermissions = () => {{
|
|
const {{ user }} = useAuth();
|
|
|
|
const can = (permission: string): boolean => {{
|
|
return user?.permissions?.includes(permission) ?? false;
|
|
}};
|
|
|
|
const hasRole = (role: string): boolean => {{
|
|
return user?.roles?.includes(role) ?? false;
|
|
}};
|
|
|
|
return {{ can, hasRole }};
|
|
}};
|
|
|
|
// Uso en componentes
|
|
import {{ usePermissions }} from '@shared/hooks/usePermissions';
|
|
|
|
const {{ can }} = usePermissions();
|
|
|
|
{{can('{module.lower()}.{entity_name_lower}.create') && (
|
|
<Button type="primary" onClick={{handleCreate}}>
|
|
Crear {entity_name}
|
|
</Button>
|
|
)}}
|
|
|
|
{{can('{module.lower()}.{entity_name_lower}.delete') && (
|
|
<Button danger onClick={{handleDelete}}>
|
|
Eliminar
|
|
</Button>
|
|
)}}
|
|
```
|
|
|
|
## Testing
|
|
|
|
### Component Tests (Vitest + React Testing Library)
|
|
```typescript
|
|
// src/widgets/{entity_name}Table/ui/{entity_name}Table.test.tsx
|
|
import {{ render, screen, waitFor }} from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import {{ QueryClient, QueryClientProvider }} from '@tanstack/react-query';
|
|
import {{ {entity_name}Table }} from './{entity_name}Table';
|
|
import {{ {entity_name_lower}Api }} from '@entities/{entity_name_lower}';
|
|
|
|
// Mock API
|
|
vi.mock('@entities/{entity_name_lower}', () => ({{
|
|
{entity_name_lower}Api: {{
|
|
getAll: vi.fn(),
|
|
delete: vi.fn(),
|
|
}},
|
|
}}));
|
|
|
|
describe('{entity_name}Table', () => {{
|
|
const queryClient = new QueryClient({{
|
|
defaultOptions: {{
|
|
queries: {{ retry: false }},
|
|
}},
|
|
}});
|
|
|
|
const wrapper = ({{ children }}) => (
|
|
<QueryClientProvider client={{queryClient}}>
|
|
{{children}}
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
beforeEach(() => {{
|
|
vi.clearAllMocks();
|
|
}});
|
|
|
|
it('should render table with data', async () => {{
|
|
const mockData = {{
|
|
data: [
|
|
{{ id: '1', name: 'Test {entity_name} 1', code: 'TEST1', createdAt: '2025-11-24' }},
|
|
{{ id: '2', name: 'Test {entity_name} 2', code: 'TEST2', createdAt: '2025-11-24' }},
|
|
],
|
|
meta: {{ page: 1, limit: 20, total: 2, totalPages: 1 }},
|
|
}};
|
|
|
|
vi.mocked({entity_name_lower}Api.getAll).mockResolvedValue(mockData);
|
|
|
|
render(<{entity_name}Table />, {{ wrapper }});
|
|
|
|
await waitFor(() => {{
|
|
expect(screen.getByText('Test {entity_name} 1')).toBeInTheDocument();
|
|
expect(screen.getByText('Test {entity_name} 2')).toBeInTheDocument();
|
|
}});
|
|
}});
|
|
|
|
it('should call delete mutation on delete button click', async () => {{
|
|
const user = userEvent.setup();
|
|
|
|
const mockData = {{
|
|
data: [{{ id: '1', name: 'To Delete', code: 'DEL', createdAt: '2025-11-24' }}],
|
|
meta: {{ page: 1, limit: 20, total: 1, totalPages: 1 }},
|
|
}};
|
|
|
|
vi.mocked({entity_name_lower}Api.getAll).mockResolvedValue(mockData);
|
|
vi.mocked({entity_name_lower}Api.delete).mockResolvedValue();
|
|
|
|
render(<{entity_name}Table />, {{ wrapper }});
|
|
|
|
await waitFor(() => {{
|
|
expect(screen.getByText('To Delete')).toBeInTheDocument();
|
|
}});
|
|
|
|
const deleteBtn = screen.getByTitle('Eliminar');
|
|
await user.click(deleteBtn);
|
|
|
|
// Confirmar modal
|
|
const confirmBtn = screen.getByText('Eliminar');
|
|
await user.click(confirmBtn);
|
|
|
|
await waitFor(() => {{
|
|
expect({entity_name_lower}Api.delete).toHaveBeenCalledWith('1');
|
|
}});
|
|
}});
|
|
|
|
it('should filter data on search', async () => {{
|
|
const user = userEvent.setup();
|
|
|
|
vi.mocked({entity_name_lower}Api.getAll).mockResolvedValue({{
|
|
data: [],
|
|
meta: {{ page: 1, limit: 20, total: 0, totalPages: 0 }},
|
|
}});
|
|
|
|
render(<{entity_name}Table />, {{ wrapper }});
|
|
|
|
const searchInput = screen.getByPlaceholderText('Buscar por nombre o código');
|
|
await user.type(searchInput, 'test');
|
|
await user.keyboard('{{Enter}}');
|
|
|
|
await waitFor(() => {{
|
|
expect({entity_name_lower}Api.getAll).toHaveBeenCalledWith(
|
|
expect.objectContaining({{ search: 'test' }})
|
|
);
|
|
}});
|
|
}});
|
|
}});
|
|
```
|
|
|
|
### E2E Tests (Playwright)
|
|
```typescript
|
|
// e2e/{entity_name_lower}.spec.ts
|
|
import {{ test, expect }} from '@playwright/test';
|
|
|
|
test.describe('{entity_name} Management', () => {{
|
|
test.beforeEach(async ({{ page }}) => {{
|
|
// Login
|
|
await page.goto('/login');
|
|
await page.fill('[name="email"]', 'test@example.com');
|
|
await page.fill('[name="password"]', 'Test1234!');
|
|
await page.click('button[type="submit"]');
|
|
await page.waitForURL('/{module.lower()}/{entity_name_lower}');
|
|
}});
|
|
|
|
test('should create new {entity_name_lower}', async ({{ page }}) => {{
|
|
await page.goto('/{module.lower()}/{entity_name_lower}');
|
|
|
|
// Click create button
|
|
await page.click('text=Crear {entity_name}');
|
|
|
|
// Fill form
|
|
await page.fill('[name="name"]', 'E2E Test {entity_name}');
|
|
await page.fill('[name="code"]', 'E2E001');
|
|
|
|
// Submit
|
|
await page.click('button[type="submit"]');
|
|
|
|
// Verify success message
|
|
await expect(page.locator('.ant-message-success')).toContainText('creado exitosamente');
|
|
|
|
// Verify in table
|
|
await expect(page.locator('table')).toContainText('E2E Test {entity_name}');
|
|
}});
|
|
|
|
test('should edit existing {entity_name_lower}', async ({{ page }}) => {{
|
|
await page.goto('/{module.lower()}/{entity_name_lower}');
|
|
|
|
// Click edit on first row
|
|
await page.click('table tbody tr:first-child button[title="Editar"]');
|
|
|
|
// Update name
|
|
await page.fill('[name="name"]', 'Updated Name');
|
|
|
|
// Submit
|
|
await page.click('button[type="submit"]');
|
|
|
|
// Verify success
|
|
await expect(page.locator('.ant-message-success')).toContainText('actualizado exitosamente');
|
|
}});
|
|
|
|
test('should delete {entity_name_lower} with confirmation', async ({{ page }}) => {{
|
|
await page.goto('/{module.lower()}/{entity_name_lower}');
|
|
|
|
// Click delete on first row
|
|
await page.click('table tbody tr:first-child button[title="Eliminar"]');
|
|
|
|
// Confirm deletion
|
|
await page.click('.ant-modal-confirm button.ant-btn-dangerous');
|
|
|
|
// Verify success
|
|
await expect(page.locator('.ant-message-success')).toContainText('eliminado exitosamente');
|
|
}});
|
|
|
|
test('should validate required fields', async ({{ page }}) => {{
|
|
await page.goto('/{module.lower()}/{entity_name_lower}');
|
|
|
|
// Click create button
|
|
await page.click('text=Crear {entity_name}');
|
|
|
|
// Try to submit empty form
|
|
await page.click('button[type="submit"]');
|
|
|
|
// Verify validation errors
|
|
await expect(page.locator('.ant-form-item-explain-error')).toContainText('al menos 3 caracteres');
|
|
}});
|
|
|
|
test('should search and filter {entity_name_lower}s', async ({{ page }}) => {{
|
|
await page.goto('/{module.lower()}/{entity_name_lower}');
|
|
|
|
// Search
|
|
await page.fill('[placeholder="Buscar por nombre o código"]', 'test');
|
|
await page.keyboard.press('Enter');
|
|
|
|
// Verify API call (check network tab or wait for results)
|
|
await page.waitForTimeout(500);
|
|
|
|
// Results should be filtered
|
|
const rows = page.locator('table tbody tr');
|
|
await expect(rows.first()).toBeVisible();
|
|
}});
|
|
}});
|
|
```
|
|
|
|
## Performance
|
|
|
|
### Optimizaciones React
|
|
```typescript
|
|
// 1. Lazy loading de páginas
|
|
const {entity_name}Page = React.lazy(() => import('@pages/{entity_name}Page'));
|
|
|
|
// 2. Memo para componentes pesados
|
|
export const {entity_name}Card = React.memo<{entity_name}CardProps>(({{ data }}) => {{
|
|
// ...
|
|
}});
|
|
|
|
// 3. useMemo para cálculos pesados
|
|
const filteredData = useMemo(() => {{
|
|
return data?.data.filter((item) => item.status === 'active');
|
|
}}, [data]);
|
|
|
|
// 4. useCallback para funciones pasadas como props
|
|
const handleEdit = useCallback((id: string) => {{
|
|
openModal('edit', data.find((item) => item.id === id));
|
|
}}, [data, openModal]);
|
|
```
|
|
|
|
### Virtualización para Listas Largas
|
|
```typescript
|
|
// Para tablas con >100 rows
|
|
import {{ FixedSizeList }} from 'react-window';
|
|
|
|
<FixedSizeList
|
|
height={{600}}
|
|
itemCount={{data?.data.length || 0}}
|
|
itemSize={{50}}
|
|
width={{'100%'}}
|
|
>
|
|
{{({{ index, style }}) => (
|
|
<div style={{style}}>{{/* Row content */}}</div>
|
|
)}}
|
|
</FixedSizeList>
|
|
```
|
|
|
|
### Debounce en Búsqueda
|
|
```typescript
|
|
import {{ useDebouncedCallback }} from 'use-debounce';
|
|
|
|
const debouncedSearch = useDebouncedCallback(
|
|
(value: string) => {{
|
|
setFilters((prev) => ({{ ...prev, search: value, page: 1 }}));
|
|
}},
|
|
300
|
|
);
|
|
|
|
<Search onChange={{(e) => debouncedSearch(e.target.value)}} />
|
|
```
|
|
|
|
### Bundle Size
|
|
- **Code splitting:** Lazy loading de páginas (`React.lazy()`)
|
|
- **Tree shaking:** Importar solo lo necesario de Ant Design
|
|
- **Chunk optimization:** Vite automático
|
|
- **Target bundle size:** <200 KB por chunk
|
|
|
|
## Referencias
|
|
|
|
- [RF Asociado](../../requerimientos-funcionales/{module.lower()}/RF-{module}-{rf_num}-{title.lower().replace(" ", "-").replace("(", "").replace(")", "").replace("/", "-")}.md)
|
|
- [ET Backend](../backend/{module.lower()}/ET-BACKEND-{module}-{rf_num}-{title.lower().replace(" ", "-").replace("(", "").replace(")", "").replace("/", "-")}.md)
|
|
- [Gamilit Frontend Patterns](../../00-analisis-referencias/gamilit/frontend-patterns.md)
|
|
- [ADR-009: Frontend Architecture](../../adr/ADR-009-frontend-architecture.md)
|
|
|
|
## Dependencias
|
|
|
|
### Módulos Frontend
|
|
- `AuthModule` - Autenticación y autorización
|
|
- `SharedModule` - Componentes y utilities compartidos
|
|
|
|
### RF Bloqueantes
|
|
- RF-MGN-001-001 (Autenticación de Usuarios)
|
|
- RF-{module}-{rf_num} Backend completado
|
|
|
|
## Notas de Implementación
|
|
|
|
- [ ] Crear estructura FSD: `entities/{entity_name_lower}`, `features/create{entity_name}`, `widgets/{entity_name}Table`
|
|
- [ ] Definir types e interfaces en `entities/{entity_name_lower}/model/types.ts`
|
|
- [ ] Crear schemas de validación Zod
|
|
- [ ] Implementar API client con axios
|
|
- [ ] Crear React Query hooks (queries + mutations)
|
|
- [ ] Implementar Zustand store para UI state
|
|
- [ ] Crear componentes UI (Table, Form, Modal)
|
|
- [ ] Implementar rutas en React Router
|
|
- [ ] Crear tests (componentes + e2e)
|
|
- [ ] Validar responsiveness (desktop + tablet + mobile)
|
|
- [ ] Validar con criterios de aceptación del RF
|
|
- [ ] Code review por Tech Lead
|
|
- [ ] QA testing
|
|
|
|
## Estimación
|
|
|
|
- **Frontend Development:** {sp_frontend} SP
|
|
- **Testing (Unit + e2e):** {sp_testing} SP
|
|
- **Code Review + QA:** {sp_review} SP
|
|
- **Total:** {sp} SP
|
|
|
|
---
|
|
|
|
**Documento generado:** 2025-11-24
|
|
**Versión:** 1.0
|
|
**Estado:** Diseñado
|
|
**Próximo paso:** Implementación
|
|
'''
|
|
|
|
return template
|
|
|
|
|
|
def main():
|
|
"""Genera todos los ET Backend y Frontend"""
|
|
print("🚀 Generando Especificaciones Técnicas (ET)...")
|
|
print("=" * 80)
|
|
|
|
total_backend = 0
|
|
total_frontend = 0
|
|
|
|
# Iterar por todos los módulos
|
|
for module_num in range(1, 15): # MGN-001 a MGN-014
|
|
module = f"MGN-{module_num:03d}"
|
|
module_lower = module.lower()
|
|
|
|
rf_module_dir = RF_DIR / module_lower
|
|
if not rf_module_dir.exists():
|
|
print(f"⚠️ Módulo {module} no encontrado, saltando...")
|
|
continue
|
|
|
|
backend_module_dir = BACKEND_DIR / module_lower
|
|
frontend_module_dir = FRONTEND_DIR / module_lower
|
|
|
|
# Obtener todos los RF del módulo
|
|
rf_files = sorted(rf_module_dir.glob("RF-*.md"))
|
|
|
|
print(f"\n📦 Módulo {module}: {len(rf_files)} RF")
|
|
|
|
for rf_file in rf_files:
|
|
# Parsear RF
|
|
rf_data = parse_rf_file(rf_file)
|
|
if not rf_data:
|
|
print(f" ⚠️ Error parseando {rf_file.name}")
|
|
continue
|
|
|
|
# Obtener recurso API
|
|
rf_num = rf_data["rf_num"]
|
|
resource = MODULE_RESOURCES.get(module, {}).get(rf_num, "resource")
|
|
|
|
# Generar ET-Backend
|
|
backend_content = generate_backend_et(rf_data, resource)
|
|
backend_filename = f"ET-BACKEND-{module}-{rf_num}-{rf_data['title'].lower().replace(' ', '-').replace('(', '').replace(')', '').replace('/', '-')}.md"
|
|
backend_path = backend_module_dir / backend_filename
|
|
|
|
backend_path.write_text(backend_content, encoding='utf-8')
|
|
total_backend += 1
|
|
print(f" ✅ Backend: {backend_filename}")
|
|
|
|
# Generar ET-Frontend
|
|
frontend_content = generate_frontend_et(rf_data, resource)
|
|
frontend_filename = f"ET-FRONTEND-{module}-{rf_num}-{rf_data['title'].lower().replace(' ', '-').replace('(', '').replace(')', '').replace('/', '-')}.md"
|
|
frontend_path = frontend_module_dir / frontend_filename
|
|
|
|
frontend_path.write_text(frontend_content, encoding='utf-8')
|
|
total_frontend += 1
|
|
print(f" ✅ Frontend: {frontend_filename}")
|
|
|
|
print("\n" + "=" * 80)
|
|
print(f"✨ Generación completada:")
|
|
print(f" - ET-Backend: {total_backend} archivos")
|
|
print(f" - ET-Frontend: {total_frontend} archivos")
|
|
print(f" - Total: {total_backend + total_frontend} archivos")
|
|
print("=" * 80)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|