# Contexto para agentes — Construcción de módulos nuevos

Este documento existe para que un agente externo pueda construir un módulo nuevo acoplado a las convenciones del proyecto, sin reinventar la rueda.

---

## Stack

| Capa | Tecnología |
|---|---|
| Backend | PHP 8.5 + Laravel 13 |
| Interactividad | Livewire 4 + Volt 1.10 |
| UI | MaryUI 2.8 + DaisyUI 5 + TailwindCSS 4 |
| Permisos | Spatie Laravel Permission 7 |
| Frontend build | Vite 6 + Axios |
| BD desarrollo | SQLite (configurable a MySQL) |

---

## Estructura de un módulo

Todos los módulos viven en `Modules/`. La convención de nombres es PascalCase.

```
Modules/NombreModulo/
  module.json              ← metadatos y configuración del módulo
  pages.json               ← define las páginas en el menú de navegación
  settings.json            ← campos de configuración del módulo en la UI de settings
  Http/
    Controllers/           ← controllers clásicos (si se usan)
    Livewire/              ← componentes Livewire (preferido)
  Models/                  ← modelos Eloquent del módulo
  Observers/               ← observers de modelos (opcional)
  Helpers/                 ← clases estáticas de utilidad (opcional)
  Routes/
    web.php                ← rutas del módulo
  Resources/
    views/
      livewire/            ← blades de componentes Livewire
    lang/
      es.json              ← traducciones (opcional)
  Database/
    migrations/
    seeders/
    factories/             ← opcional
```

El PSR-4 ya mapea `Modules\` → `Modules/` en `composer.json`, así que no hay configuración extra para namespaces.

---

## module.json

```json
{
  "id": "NombreModulo",
  "alias": "nm",
  "priority": 5,
  "title": "Nombre legible",
  "description": "Descripción corta.",
  "version": "1.0.0",
  "enabled": true,
  "core": false,
  "requires": [],
  "permissions": ["nm_dashboard", "recurso_a", "recurso_b"]
}
```

- `alias`: lowercase, corto, único. Se usa como prefijo de vistas (`nm::vista`).
- `priority`: número mayor = se registra con mayor prioridad en rutas. Los existentes son 1 (Core), 2 (SFM), 3 (Bitacora), 4 (MediaMinistry).
- `requires`: array de `id` de módulos que deben estar habilitados.
- `permissions`: lista de recursos base (sin acción). El wizard de permisos genera `.index`, `.create`, `.update`, `.delete`, `.export` por cada uno.

---

## pages.json

Define las entradas del menú lateral. El sistema de navegación lee este archivo automáticamente.

**IMPORTANTE:** Es un objeto con clave `pages`, no un array plano.

```json
{
  "priority": 1,
  "icon": "fas-hotel",
  "pages": [
    {
      "icon": "fas-hotel",
      "title": "Hoteles",
      "menu": "Hoteles",
      "permission": "hotel",
      "route": "hotel.index",
      "submenu": "none"
    },
    {
      "icon": "fas-bed",
      "title": "Habitaciones",
      "menu": "Hoteles",
      "permission": "room",
      "route": "room.index",
      "submenu": "none"
    }
  ]
}
```

- `icon` (raíz): ícono del grupo en el menú lateral.
- `priority`: orden del grupo (mayor = primero).
- `pages[].icon`: nombre de ícono en formato blade-icons. Usar heroicons (`o-nombre` outline, `s-nombre` solid) — son los que están confirmados. FontAwesome usa `fas.nombre` con punto, pero no todos los íconos están disponibles en la versión free.
- `pages[].menu`: nombre del grupo en el sidebar. Entradas con el mismo valor se agrupan.
- `pages[].permission`: **solo el recurso base, SIN acción** — e.g., `"hotel"` no `"hotel.index"`. El sistema agrega `.index` internamente para verificar acceso.
- `pages[].route`: nombre de ruta Laravel completo — e.g., `"hotel.index"`.
- `pages[].submenu`: siempre `"none"` por ahora.

Ver `docs/pages_for_dummies.md` para detalles completos.

---

## settings.json

Define las secciones de configuración que aparecen en la UI de Settings del sistema.

```json
{
  "hotels": {}
}
```

Los valores se leen y escriben vía `AppSettingService`:

```php
app(AppSettingService::class)->get('hotels', 'campo', 'default');
app(AppSettingService::class)->set('hotels', 'campo', 'valor');
```

---

## Modelos

### Convenciones obligatorias

```php
namespace Modules\NombreModulo\Models;

use Illuminate\Database\Eloquent\Model;
use App\Traits\WithAuditLog;
use Illuminate\Support\Facades\Auth;

class Hotel extends Model
{
    use WithAuditLog;

    protected $table = 'nm_hotels'; // prefijo del alias del módulo

    protected $fillable = [
        'user_id', 'name', 'address', 'stars',
    ];

    protected $casts = [
        'stars' => 'integer',
        'is_active' => 'boolean',
    ];

    // Global scope por usuario (SIEMPRE en modelos que pertenecen a un usuario)
    protected static function booted(): void
    {
        static::addGlobalScope('user', function ($query) {
            if (Auth::check()) {
                $query->where('user_id', Auth::id());
            }
        });
    }

    public function rooms()
    {
        return $this->hasMany(Room::class);
    }
}
```

- Tabla: prefijo del `alias` del módulo (`nm_hotels`, `sfm_wallets`, etc.)
- `$fillable` explícito — nunca `$guarded = []`
- Global scope por usuario si el recurso es per-user
- `WithAuditLog` en modelos con operaciones CRUD relevantes

---

## Migraciones

```php
// Database/migrations/2026_05_01_000001_create_nm_hotels_table.php

public function up(): void
{
    Schema::create('nm_hotels', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete();
        $table->string('name');
        $table->string('address')->nullable();
        $table->unsignedTinyInteger('stars')->default(3);
        $table->boolean('is_active')->default(true);
        $table->timestamps();
        $table->softDeletes();

        $table->index('user_id');
    });
}
```

---

## Rutas (Routes/web.php)

```php
<?php

use Illuminate\Support\Facades\Route;
use Modules\NombreModulo\Http\Controllers\HotelController;

Route::prefix('/Hotel')->middleware('auth')->group(function () {
    Route::controller(HotelController::class)->group(function () {
        Route::get('/', 'index')->name('hotel.index');
        Route::post('/Store', 'store')->name('hotel.store');
        Route::patch('/Update', 'update')->name('hotel.update');
        Route::delete('/Destroy', 'delete')->name('hotel.delete');
    });
});
```

O si usas Livewire directamente:

```php
Route::middleware('auth')->group(function () {
    Route::get('/Hotels', \Modules\NombreModulo\Http\Livewire\HotelIndex::class)->name('hotel.index');
});
```

---

## Componente Livewire

```php
<?php

namespace Modules\NombreModulo\Http\Livewire;

use Livewire\Component;
use Livewire\WithPagination;
use App\Traits\WithPermissionCheck;
use App\Traits\WithAppSettings;
use Modules\NombreModulo\Models\Hotel;

class HotelIndex extends Component
{
    use WithPagination, WithPermissionCheck, WithAppSettings;

    public string $search = '';
    public ?Hotel $editing = null;
    public bool $showForm = false;

    // Campos del formulario
    public string $name = '';
    public string $address = '';
    public int $stars = 3;

    public function openCreate(): void
    {
        $this->requirePermission('hotel.create');
        $this->reset(['name', 'address', 'stars', 'editing']);
        $this->showForm = true;
    }

    public function openEdit(Hotel $hotel): void
    {
        $this->requirePermission('hotel.update');
        $this->editing = $hotel;
        $this->name = $hotel->name;
        $this->address = $hotel->address;
        $this->stars = $hotel->stars;
        $this->showForm = true;
    }

    public function save(): void
    {
        $data = $this->validate([
            'name'    => 'required|string|max:255',
            'address' => 'nullable|string|max:500',
            'stars'   => 'required|integer|min:1|max:5',
        ]);

        if ($this->editing) {
            $this->requirePermission('hotel.update');
            $this->editing->update($data);
        } else {
            $this->requirePermission('hotel.create');
            Hotel::create(['user_id' => auth()->id(), ...$data]);
        }

        $this->showForm = false;
        $this->reset(['name', 'address', 'stars', 'editing']);
    }

    public function delete(Hotel $hotel): void
    {
        $this->requirePermission('hotel.delete');
        $hotel->delete();
    }

    public function render()
    {
        $hotels = Hotel::query()
            ->when($this->search, fn($q) => $q->where('name', 'like', "%{$this->search}%"))
            ->latest()
            ->paginate(15);

        return view('nm::livewire.hotel-index', compact('hotels'));
    }
}
```

### Traits disponibles

| Trait | Qué aporta |
|---|---|
| `WithPermissionCheck` | `requirePermission(string $perm)` — lanza abort(403) |
| `WithAppSettings` | Propiedades `$currencySymbol`, `$decimalsShow`, `$decimalsCalc` listas |
| `WithAuditLog` | En modelos: registra create/update/delete automáticamente |
| `WithTableExport` | Exporta tablas a Xlsx/Csv/Pdf |

---

## Vista Blade (Resources/views/livewire/hotel-index.blade.php)

```blade
<div>
    {{-- Header --}}
    <x-mary-header title="Hoteles" separator>
        <x-slot:actions>
            @can('hotel.create')
                <x-mary-button label="Nuevo hotel" icon="fas-plus" wire:click="openCreate" class="btn-primary" />
            @endcan
        </x-slot:actions>
    </x-mary-header>

    {{-- Búsqueda --}}
    <x-mary-input placeholder="Buscar..." wire:model.live.debounce="search" icon="fas-search" class="mb-4" />

    {{-- Tabla --}}
    <x-mary-table :rows="$hotels" striped>
        @scope('cell_name', $hotel)
            {{ $hotel->name }}
        @endscope

        @scope('cell_stars', $hotel)
            {{ $hotel->stars }} ★
        @endscope

        @scope('actions', $hotel)
            @can('hotel.update')
                <x-mary-button icon="fas-pencil" wire:click="openEdit({{ $hotel->id }})" class="btn-sm btn-ghost" />
            @endcan
            @can('hotel.delete')
                <x-mary-button icon="fas-trash" wire:click="delete({{ $hotel->id }})" wire:confirm="¿Eliminar este hotel?" class="btn-sm btn-ghost text-error" />
            @endcan
        @endscope
    </x-mary-table>

    {{ $hotels->links() }}

    {{-- Modal de formulario --}}
    <x-mary-modal wire:model="showForm" title="{{ $editing ? 'Editar hotel' : 'Nuevo hotel' }}">
        <x-mary-form wire:submit="save">
            <x-mary-input label="Nombre" wire:model="name" required />
            <x-mary-input label="Dirección" wire:model="address" />
            <x-mary-range label="Estrellas" wire:model="stars" min="1" max="5" />

            <x-slot:actions>
                <x-mary-button label="Cancelar" wire:click="$set('showForm', false)" />
                <x-mary-button label="Guardar" type="submit" class="btn-primary" />
            </x-slot:actions>
        </x-mary-form>
    </x-mary-modal>
</div>
```

### Componentes MaryUI más usados

| Componente | Uso |
|---|---|
| `x-mary-header` | Título de página con slot de acciones |
| `x-mary-table` | Tabla con rows, slots `cell_*` y `actions` |
| `x-mary-modal` | Modal controlado por `wire:model` |
| `x-mary-form` | Form con `wire:submit` |
| `x-mary-input` | Input de texto |
| `x-mary-button` | Botón con soporte de icon, wire:confirm, wire:loading |
| `x-mary-select` | Select estándar |
| `x-mary-range` | Slider numérico |

### Íconos en vistas — usar SOLO heroicons

En atributos `icon=""` de componentes MaryUI usar **exclusivamente** heroicons. `fas-*` no funciona.

| Acción | Ícono |
|---|---|
| Crear / agregar | `o-plus` |
| Buscar | `o-magnifying-glass` |
| Editar | `o-pencil` |
| Eliminar | `o-trash` |
| Salir / logout | `o-arrow-right-on-rectangle` |
| Usuario | `o-user` |
| Usuarios | `o-user-group` |
| Calendario | `o-calendar-days` |
| Dinero | `o-banknotes` |
| Tarjeta | `o-credit-card` |
| Edificio | `o-building-office` |
| Llave | `o-key` |

---

## Sistema de permisos

### Formato estándar

`recurso.accion` — las 5 acciones son: `index`, `create`, `update`, `delete`, `export`.

Ejemplos: `hotel.index`, `hotel.create`, `room.delete`.

### Flujo completo

1. Declarar el recurso base en `module.json` → campo `permissions`.
2. Declarar la página con `"permission": "hotel.index"` en `pages.json`.
3. Ir al Wizard de Permisos en la UI → sincronizar → se crean los 5 permisos en BD.
4. Asignar permisos a roles desde la UI.

### En Livewire (backend — obligatorio)

```php
$this->requirePermission('hotel.create'); // abort(403) si no autorizado
```

### En Blade (UX — no es seguridad, solo visibilidad)

```blade
@can('hotel.create')
    <x-mary-button label="Nuevo" ... />
@endcan
```

### Admins

`is_admin = true` en el modelo User pasa automáticamente cualquier check de Gate. No necesitan permisos asignados.

Ver `docs/gestion_permisos.md` para detalles completos.

---

## Logging / Auditoría

El trait `WithAuditLog` en el modelo registra create/update/delete automáticamente en `core_activity_logs`.

Para logs manuales:

```php
use Modules\Core\Services\CoreLogger;

CoreLogger::info('mensaje', ['contexto' => 'valor'], 'canal');
CoreLogger::warning('mensaje', [...]);
CoreLogger::error('mensaje', [...]);
```

Ver `docs/sistema_de_logs.md` para detalles.

---

## Helpers del módulo

Si el módulo necesita lógica reutilizable interna:

```php
// Modules/NombreModulo/Helpers/HotelHelper.php
namespace Modules\NombreModulo\Helpers;

class HotelHelper
{
    public static function occupancyRate(Hotel $hotel): float { ... }
}
```

Uso:

```php
use Modules\NombreModulo\Helpers\HotelHelper;

HotelHelper::occupancyRate($hotel);
```

Si el helper empieza a usarse en otros módulos, sube a `App\Helpers\` y opcionalmente agrega un alias en `app/helpers.php`.

Ver `docs/helpers.md` para la arquitectura completa.

---

## Qué NO hacer

- **No tocar `composer.json`** para registrar el módulo — el sistema lo descubre automáticamente con `glob('Modules/*/module.json')`.
- **No agregar rutas en `routes/web.php`** — cada módulo tiene su propio `Routes/web.php`.
- **No usar `$guarded = []`** en modelos — siempre `$fillable` explícito.
- **No verificar permisos solo en Blade** (`@can`) — siempre validar también en el backend con `requirePermission()`.
- **No crear funciones globales** en `app/helpers.php` directamente — crear primero una clase en `App\Helpers\` o `Modules\*/Helpers\` y luego el wrapper si hace falta.
- **No saltarse la convención de tabla** — siempre prefijo del alias (`nm_`, `sfm_`, `mm_`, `bk_`).
- **No ignorar el global scope por usuario** — si el recurso es per-user, agregarlo en `booted()`.

---

## Módulos existentes de referencia

| Módulo | Alias | Estado | Modelos | Descripción |
|---|---|---|---|---|
| Core | core | Siempre activo | ActivityLog, SystemLog | Auth, usuarios, permisos, logs |
| SimpleFinancialManagement | sfm | Activo | Wallet, Bucket, Transaction, CreditCard, CardCycle, Tag, Subcategory | Finanzas personales |
| MediaMinistry | mm | Activo | Event, Task, TaskLog, TaskType, CopyTemplate, Theme, BibleVersion, Book, Verse, MediaFile | Ministerio de medios |
| Bitacora | bk | Deshabilitado | Client, Task, TaskFile, TaskNote | Bitácora de trabajo |
| HotelManagement | hm | Activo | Hotel, Room, Client, Reservation, Checkin, Payment | Gestión de hoteles, habitaciones, clientes, reservas, check-in/out y pagos |
