#!/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()
