# Guía — Exportar tablas (XLSX, CSV, PDF)

> Última actualización: 2026-04-24

---

## Los dos tipos de PDF

El sistema maneja dos variantes de PDF que coexisten en la misma pantalla:

| | PDF de tabla (trait) | PDF general (controller) |
|---|---|---|
| **Dispara desde** | Botón dentro del componente Livewire | Link en el header de la página |
| **Datos** | Respeta el filtro y orden activos | Siempre todos los registros |
| **Implementación** | `WithTableExport` + `wire:click` | Ruta GET + Controller |
| **Template** | Genérico `exports.pdf-table` (o propio) | Vista Blade propia por módulo |
| **Cuándo usarlo** | Export rápido de lo que el usuario está viendo | Reporte formal con formato fijo |

Ambos usan `barryvdh/laravel-dompdf` (ya instalado).

---

## Archivos del sistema

| Archivo | Rol |
|---|---|
| `app/Traits/WithTableExport.php` | Trait con la lógica de XLSX, CSV y PDF de tabla |
| `resources/views/exports/pdf-table.blade.php` | Template PDF genérico compartido |

---

## Parte A — PDF de tabla (trait `WithTableExport`)

### A.1 PHP — tres métodos obligatorios

```php
use App\Traits\WithTableExport;

final class MiTabla extends Component
{
    use WithPagination, WithTableExport;

    // ... propiedades y métodos existentes sin cambios ...

    /**
     * La misma query que usa la tabla, pero SIN paginate().
     * Aplica los mismos filtros y orden activos.
     */
    protected function exportableQuery()
    {
        return Producto::when($this->search, fn($q, $s) =>
                $q->where('name', 'like', "%{$s}%")
            )
            ->orderBy($this->sortBy['column'], $this->sortBy['direction']);
    }

    /** Etiquetas de columna para la fila de encabezado del archivo. */
    protected function exportableHeaders(): array
    {
        return ['ID', __('Name'), __('Price'), __('Stock')];
    }

    /**
     * Transforma UN registro en un array de valores,
     * mismo orden que exportableHeaders().
     */
    protected function exportableRow(mixed $record): array
    {
        return [
            $record->id,
            $record->name,
            number_format($record->price, 2),
            $record->stock,
        ];
    }
}
```

Con esos tres métodos ya funcionan `exportXlsx()`, `exportCsv()` y `exportPdf()`.

### A.2 Blade — botones en el toolbar del componente

```blade
<x-mary-button
    icon="o-table-cells"
    label="XLSX"
    wire:click="exportXlsx"
    class="btn-ghost btn-sm"
    spinner="exportXlsx"
/>
<x-mary-button
    icon="o-document-text"
    label="CSV"
    wire:click="exportCsv"
    class="btn-ghost btn-sm"
    spinner="exportCsv"
/>
<x-mary-button
    icon="o-document-arrow-down"
    label="PDF"
    wire:click="exportPdf"
    class="btn-ghost btn-sm"
    spinner="exportPdf"
/>
```

`spinner` muestra el indicador de carga mientras Livewire procesa la descarga.

> Los exports respetan el filtro de búsqueda y el orden activos — exportan exactamente lo que el usuario está viendo, sin paginación.

---

### A.3 Personalizar el PDF de tabla

#### Cambiar título, logo o color

Sobrescribe `exportPdfOptions()` en el componente:

```php
protected function exportPdfOptions(): array
{
    return [
        'title'       => __('Products Report'),
        'subtitle'    => 'Período: ' . now()->format('F Y'),
        'orientation' => 'portrait',           // 'landscape' | 'portrait'
        'paper'       => 'a4',                 // 'a4' | 'letter' | etc.
        'logo'        => public_path('assets/images/logo.png'),
        'accent'      => '#16a34a',            // hex para thead y línea de cabecera
    ];
}
```

Todas las claves son opcionales. Defaults: landscape, A4, sin logo, color indigo `#4f46e5`,
título = nombre de la clase.

#### Cambiar el nombre base del archivo

```php
protected function exportFilename(): string
{
    return 'productos'; // → productos_20260424_143022.xlsx / .csv / .pdf
}
```

#### Template Blade completamente propio

Si el template genérico no alcanza (filas agrupadas, gráficas, layout especial):

```php
protected function exportPdfView(): string
{
    return 'sfm::exports.movement-pdf'; // tu propia vista Blade
}
```

La vista recibe:

| Variable | Tipo | Descripción |
|---|---|---|
| `$headers` | `array` | Etiquetas de columna |
| `$rows` | `array` | Array de arrays con los valores de cada fila |
| `$count` | `int` | Total de registros exportados |
| `$options` | `array` | Opciones fusionadas (title, logo, accent…) |

> Usar `font-family: 'DejaVu Sans'` en el CSS garantiza soporte de tildes y ñ en dompdf.

---

### A.4 Referencia rápida del trait

| Método | Obligatorio | Descripción |
|---|---|---|
| `exportableQuery()` | ✅ | Query sin `paginate()` |
| `exportableHeaders()` | ✅ | Labels de columna |
| `exportableRow($record)` | ✅ | Un registro → array de valores |
| `exportFilename()` | — | Nombre base del archivo |
| `exportPdfOptions()` | — | title, subtitle, orientation, paper, logo, accent |
| `exportPdfView()` | — | Vista Blade personalizada para el PDF |

---

## Parte B — PDF general (controller)

Para reportes con formato fijo que siempre exportan todos los registros (independiente
de lo que haya en pantalla), o cuando el PDF necesita un layout muy específico
(membrete, firmas, secciones múltiples).

### B.1 Ruta

```php
// Modules/TuModulo/Routes/web.php
Route::get('/TuModuloExportPdf', 'exportPdf')->name('tu_modulo.export.pdf');
```

### B.2 Controller

```php
use Barryvdh\DomPDF\Facade\Pdf;

public function exportPdf(): \Symfony\Component\HttpFoundation\BinaryFileResponse
{
    $this->checkPermission('tu_recurso', 'export'); // si usas permisos

    $registros = TuModelo::orderBy('name')->get();

    $pdf = Pdf::loadView('tu_modulo::exports.reporte-pdf', compact('registros'))
        ->setPaper('a4', 'landscape');

    return $pdf->download('reporte_' . now()->format('Ymd_His') . '.pdf');
}
```

> `Pdf::download()` devuelve `Illuminate\Http\Response`. Si el método del controller
> necesita un tipo de retorno más específico, usa `\Symfony\Component\HttpFoundation\Response`.

### B.3 Vista Blade del reporte

Crea la vista en `Modules/TuModulo/Resources/views/exports/reporte-pdf.blade.php`.
Usa siempre `font-family: 'DejaVu Sans'` para compatibilidad de caracteres.

```blade
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
    <meta charset="UTF-8">
    <title>{{ __('Reporte') }}</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body { font-family: 'DejaVu Sans', sans-serif; font-size: 11px; color: #1a1a1a; }

        header {
            position: fixed; top: 0; left: 0; right: 0; height: 52px;
            border-bottom: 2px solid #4f46e5;
            display: flex; align-items: center; justify-content: space-between;
            padding: 0 24px; background: #fff;
        }
        .app-name { font-size: 16px; font-weight: 700; color: #4f46e5; }
        .report-info { text-align: right; font-size: 10px; color: #6b7280; }

        footer {
            position: fixed; bottom: 0; left: 0; right: 0; height: 36px;
            border-top: 1px solid #e5e7eb;
            display: flex; align-items: center; justify-content: space-between;
            padding: 0 24px; font-size: 9px; color: #9ca3af; background: #fff;
        }
        footer .page-num::after  { content: counter(page); }
        footer .page-total::before { content: counter(pages); }

        .content { margin-top: 64px; margin-bottom: 44px; padding: 0 24px; }
        h1 { font-size: 15px; font-weight: 700; margin-bottom: 12px; }

        table { width: 100%; border-collapse: collapse; }
        thead tr { background: #4f46e5; color: #fff; }
        thead th { padding: 7px 10px; text-align: left; font-size: 10px;
                   font-weight: 600; text-transform: uppercase; }
        tbody tr:nth-child(even) { background: #f9fafb; }
        tbody td { padding: 6px 10px; border-bottom: 1px solid #e5e7eb; }
    </style>
</head>
<body>
    <header>
        {{-- Agrega tu logo aquí si lo necesitas --}}
        <span class="app-name">{{ config('app.name') }}</span>
        <div class="report-info">
            <div>{{ __('Reporte') }}</div>
            <div>{{ now()->format('d/m/Y H:i') }}</div>
        </div>
    </header>

    <footer>
        <span>{{ config('app.name') }}</span>
        <span>{{ __('Page') }} <span class="page-num"></span> / <span class="page-total"></span></span>
    </footer>

    <div class="content">
        <h1>{{ __('Reporte') }} ({{ count($registros) }})</h1>
        <table>
            <thead>
                <tr>
                    <th>#</th>
                    <th>{{ __('Name') }}</th>
                    {{-- ... más columnas ... --}}
                </tr>
            </thead>
            <tbody>
                @foreach($registros as $item)
                    <tr>
                        <td>{{ $loop->iteration }}</td>
                        <td>{{ $item->name }}</td>
                    </tr>
                @endforeach
            </tbody>
        </table>
    </div>
</body>
</html>
```

Para incluir un logo, convierte la imagen a base64 (requerido por dompdf):

```blade
@php
    $logoPath = public_path('assets/images/logo.png');
    $logo = file_exists($logoPath)
        ? 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath))
        : null;
@endphp

{{-- Dentro del <header>: --}}
@if($logo)
    <img src="{{ $logo }}" style="height: 36px; width: auto;">
@endif
```

### B.4 Botón en la vista (header de página)

```blade
<x-mary-button
    icon="o-document-arrow-down"
    label="{{ __('PDF') }}"
    class="btn-ghost btn-sm"
    link="{{ route('tu_modulo.export.pdf') }}"
    no-wire-navigate
/>
```

> **Importante — `no-wire-navigate` es obligatorio.**
> MaryUI aplica `wire:navigate` a todos los links internos por defecto. Sin este
> atributo, Livewire intercepta la petición GET del PDF, recibe bytes binarios en
> lugar de HTML y colapsa con un `Alpine Expression Error`.
> El atributo `target="_blank"` que pases directamente es ignorado por MaryUI
> (solo funciona con `external`). Para un PDF que descargue en la misma pestaña,
> `no-wire-navigate` es la opción correcta.

---

## Ejemplo completo — BucketIndex (módulo finanzas)

```php
// Modules/SimpleFinancialManagement/Http/Livewire/BucketIndex.php

use App\Traits\WithTableExport;

final class BucketIndex extends Component
{
    use WithPagination, WithTableExport;

    // ... código existente sin tocar ...

    protected function exportableQuery()
    {
        return Bucket::where('user_id', Auth::id())
            ->where(function ($q) {
                $q->where('name', 'LIKE', '%'.$this->search.'%')
                  ->orWhere('description', 'LIKE', '%'.$this->search.'%');
            })
            ->orderBy($this->sortBy, $this->sortAsc ? 'asc' : 'desc');
    }

    protected function exportableHeaders(): array
    {
        return ['ID', __('Name'), __('Description'), __('Kind')];
    }

    protected function exportableRow(mixed $record): array
    {
        return [$record->id, $record->name, $record->description, $record->kind];
    }

    protected function exportFilename(): string { return 'buckets'; }

    protected function exportPdfOptions(): array
    {
        return ['title' => __('Buckets')];
    }
}
```

```blade
{{-- En bucket-index.blade.php --}}
<div class="flex gap-2">
    <x-mary-button wire:click="exportXlsx" label="XLSX" class="btn-ghost btn-sm" spinner="exportXlsx" />
    <x-mary-button wire:click="exportCsv"  label="CSV"  class="btn-ghost btn-sm" spinner="exportCsv"  />
    <x-mary-button wire:click="exportPdf"  label="PDF"  class="btn-ghost btn-sm" spinner="exportPdf"  />
</div>
```
