# Unidades de Atendimento — Plano de Migração para Laravel Moderno (PHP 8.x / Laravel 11-12)

---

## 1. Decisão estrutural: manter `contents` ou criar tabela própria?

| Opção | Prós | Contras | Recomendação |
|---|---|---|---|
| **A. Manter `contents` + `category_contents`** | Zero migração de dados; o CMS genérico continua funcionando; a view SQL e os seeds seguem válidos. | Herda o contêiner genérico (campos irrelevantes), N+1 potencial e `text` sem escape. | **Recomendada para a primeira fase** (rebuild incremental do portal). |
| **B. Criar tabela `service_units`** | Modelo enxuto, tipagem forte, policies simples. | Exige migração de dados de `contents` (filtrando `category_contents_id = 44`), reescrita do CMS e da view SQL, e perde a integração com menus/recursos genéricos. | Fase 2, se o negócio exigir campos próprios (endereço, horário, telefone). |

A opção B **não é obrigatória**: a spec funcional do módulo é coberta pela opção A.

## 2. Schema de migração Laravel (opção B, ilustrativa)

```php
// (ilustrativo) database/migrations/2025_xx_xx_create_service_units_table.php
Schema::create('service_units', function (Blueprint $table) {
    $table->id();
    $table->foreignId('category_content_id')->constrained('category_contents');
    $table->string('title', 120);
    $table->text('text')->nullable();
    $table->string('slug', 191)->unique();
    $table->timestamp('publication_date')->nullable();
    $table->string('author', 191)->nullable();
    $table->string('source', 191)->nullable();
    $table->boolean('galery')->default(false);
    $table->unsignedInteger('clicks')->default(0);
    $table->timestamps();
    $table->softDeletes();
    $table->index(['category_content_id', 'publication_date']);
});
```

Migração de dados (opção B):

```sql
INSERT INTO service_units (category_content_id, title, text, slug, publication_date, author, source, galery, clicks, created_at, updated_at)
SELECT c.category_contents_id, c.title, c.text, c.slug, c.publication_date, c.author, c.source, c.galery, c.clicks, c.created_at, c.updated_at
FROM contents c
JOIN category_contents cc ON cc.id = c.category_contents_id
WHERE cc.slug = 'unidades-de-atendimento' AND c.deleted_at IS NULL;
```

## 3. Modelos

### 3.1 Opção A — modelos enxutos sobre `contents`

```php
// (ilustrativo) app/Models/Content.php
class Content extends Model
{
    use SoftDeletes;

    protected $fillable = ['title', 'text', 'slug', 'publication_date', 'author', 'source', 'galery', 'category_contents_id'];

    protected $casts = [
        'publication_date' => 'datetime',
        'galery' => 'boolean',
    ];

    public function categoryContents(): BelongsTo { return $this->belongsTo(CategoryContent::class, 'category_contents_id'); }
    public function archives(): HasMany { return $this->hasMany(Archive::class, 'contents_id'); }
    public function highlightArchive(): ?Archive { return $this->archives()->where('highlight', true)->first(); }
    public function galleryArchives(): HasMany { return $this->archives()->whereNull('highlight')->whereIn('extension', ['jpg','jpeg','png','gif','webp']); }
}
```

### 3.2 Opção B — `ServiceUnit`

```php
// (ilustrativo) app/Models/ServiceUnit.php
class ServiceUnit extends Model
{
    use SoftDeletes, HasFactory;

    protected $fillable = ['category_content_id', 'title', 'text', 'slug', 'publication_date', 'author', 'source', 'galery'];
    protected $casts = ['publication_date' => 'datetime', 'galery' => 'boolean'];
}
```

### 3.3 Enums sugeridos

| Enum | Valores | Uso |
|---|---|---|
| `ContentStatus` | `draft`, `published` | Substitui `contents.status` booleano (hoje não filtrado no portal). |
| `ArchiveKind` | `highlight`, `gallery`, `document` | Substitui o uso de `highlight` nulo/`true` + `extension`. |

> Regra de equivalência: enquanto o portal legado **não** filtra por status, a
> v7 não pode começar a filtrar sem decisão do negócio (mudaria o que o
> cidadão vê).

## 4. Form Requests

```php
// (ilustrativo) app/Http/Requests/StoreServiceUnitRequest.php
class StoreServiceUnitRequest extends FormRequest
{
    public function authorize(): bool { return $this->user()->can('create', ServiceUnit::class); }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:120'],
            'text' => ['nullable', 'string'],
            'category_contents_id' => ['required', 'integer', 'exists:category_contents,id'],
            'publication_date' => ['nullable', 'date'],
            'author' => ['nullable', 'string', 'max:120'],
            'source' => ['nullable', 'string', 'max:120'],
            'link' => ['nullable', 'string', 'max:200', 'url'],
            'galery' => ['nullable', 'boolean'],
            'archive_highlight' => ['nullable', 'image', 'mimes:jpg,jpeg,png,gif,webp', 'max:20000'],
            'archives' => ['nullable', 'array'],
            'archives.*' => ['file', 'mimes:jpg,jpeg,png,bmp,gif,rar,zip,doc,docx,pdf,odt,xls,xlsx,ppt,pptx,mp4'],
        ];
    }
}
```

Equivalência com o legado: `title` obrigatório ≤120 (regra `default` do
`ContentRequest`), `category_contents_id` obrigatório, `archive_highlight`
≤20 MB com mimes de imagem, `archives.*` com a lista de mimes original.

## 5. Policies e autorização

```php
// (ilustrativo) app/Policies/ServiceUnitPolicy.php
class ServiceUnitPolicy
{
    public function viewAny(User $user): bool { return $user->hasPermissionTo('unidades-de-atendimento.view'); }
    public function create(User $user): bool { return $user->hasPermissionTo('unidades-de-atendimento.create'); }
    public function update(User $user, ServiceUnit $u): bool { return $user->hasPermissionTo('unidades-de-atendimento.edit'); }
    public function delete(User $user, ServiceUnit $u): bool { return $user->hasPermissionTo('unidades-de-atendimento.delete'); }
    public function deleteAny(User $user): bool { return $user->hasPermissionTo('unidades-de-atendimento.delete'); }
}
```

- Substitui as strings `has_access(['unidades-de-atendimento.create'])` etc.
- `super-admin` continua com acesso total (equivalente ao `Sentinel::inRole('super-admin')`).
- Manter os **mesmos nomes de permissão** evita retrabalho de configuração de
  perfis; em especial, padronizar `unidades_de_atendimento.*` (underscore) para
  `unidades-de-atendimento.*` (kebab-case), corrigindo a inconsistência A4.

## 6. Endpoints REST/JSON sugeridos

```php
// (ilustrativo) routes/web.php
Route::get('/unidades-de-atendimento', [ServiceUnitController::class, 'index'])->name('unidades-de-atendimento.index');
Route::get('/unidades-de-atendimento/{slug}', [ServiceUnitController::class, 'show'])->name('unidades-de-atendimento.show');

// routes/api.php
Route::apiResource('service-units', ServiceUnitApiController::class)->middleware('auth:sanctum');
```

| Endpoint | Retorno |
|---|---|
| `GET /api/service-units?page=1` | Lista paginada (12 por página, `meta` de paginação). |
| `GET /api/service-units/{slug}` | Detalhe com imagem de destaque e galeria. |
| `POST/PUT/DELETE /api/service-units` | CRUD administrativo (policy `unidades-de-atendimento.*`). |

## 7. API Resources

```php
// (ilustrativo) app/Http/Resources/ServiceUnitResource.php
class ServiceUnitResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'slug' => $this->slug,
            'text' => $this->text,
            'publication_date' => $this->publication_date?->toIso8601String(),
            'author' => $this->author,
            'source' => $this->source,
            'gallery' => (bool) $this->galery,
            'highlight' => $this->whenLoaded('highlightArchive', fn ($a) => $a?->url),
            'archives' => ArchiveResource::collection($this->whenLoaded('archives')),
        ];
    }
}
```

## 8. Controllers (Laravel moderno)

```php
// (ilustrativo) app/Http/Controllers/Portal/ServiceUnitsController.php
class ServiceUnitsController extends Controller
{
    public function index(Request $request)
    {
        $category = CategoryContent::where('slug', 'unidades-de-atendimento')->firstOrFail();

        $contents = Content::query()
            ->whereNull('deleted_at')
            ->where('category_contents_id', $category->id)
            ->with('archives')
            ->orderByDesc('publication_date')
            ->orderByDesc('id')
            ->paginate(config('portal.service_units.per_page', 12));   // default 12

        return view('portal.layouts.' . config('portal.layout') . '.unidades-de-atendimento.index', compact('contents', 'category'));
    }
}
```

- `firstOrFail()` corrige a armadilha A12.
- O tamanho da página vai para `config/portal.php` com **default 12**, preservando
  o comportamento.
- Manter as **mesmas views por layout** ou unificar em componentes Blade
  `<x-portal.service-unit.card>`.

## 9. Views Blade modernas

| Legado | Moderno |
|---|---|
| 6 cópias de `index.blade.php` (modelo1..6) | 1 componente `<x-portal.content-card>` reutilizado pelos layouts. |
| `str_limit($content->title, 50)` | `Str::limit($content->title, 50)`. |
| `{!! $content->text !!}` | `{!! \Illuminate\Support\Str::sanitizeHtml($content->text) !!}` ou purifier na gravação. |
| `date('d.m.Y', strtotime($content->publication_date))` | `->format('d.m.Y')` com cast `datetime`. |
| `@if ($content->galery === true)` | `@if ($content->galery)` (cast booleano). |
| Galeria com lightgallery | Componente Blade + Alpine/lightgallery, consumindo a coleção de imagens já filtrada por extensão. |
| DataTable Yajra + JS solto | Livewire/Inertia ou Alpine + endpoint JSON; ou manter Yajra para menor risco. |

Componente de galeria **ilustrativo** (substitui o include
`portal.components.galerias.index`, que hoje recebe `$archives`):

```blade
{{-- (ilustrativo) resources/views/components/gallery.blade.php --}}
@props(['images' => []])

<ul class="list-unstyled text-center" id="lightgallery">
    @foreach ($images as $image)
        <li class="d-inline-block"
            data-thumb="{{ Storage::url($image->path) }}"
            data-src="{{ Storage::url($image->path) }}">
            <img src="{{ Storage::url($image->path) }}"
                 alt="{{ $image->description }}"
                 title="{{ $image->description }}" class="d-none">
        </li>
    @endforeach
</ul>
```

Chamada na view de detalhe: `<x-gallery :images="$galleryImages" />`, onde
`$galleryImages` são os `archives` sem `highlight` e com extensão de imagem
(`jpg, jpeg, png, gif, bmp, tiff, svg, webp, ico, heic, raw`, conforme
`v6/resources/views/portal/components/galerias/index.blade.php:23`).

## 10. Fila, cache e storage

| Aspecto | Recomendação |
|---|---|
| Cache | Cachear a listagem por página (`Cache::remember`, 5 min) invalidados ao salvar/excluir uma unidade. O legado não cacheia. |
| Fila | Processamento de imagens (thumbnails/webp) em job `queued` — hoje síncrono no `store`/`update`. |
| Storage | Manter `storage/content/unidades-de-atendimento/{id}/destaque/{nome}`; padronizar com `Storage::disk('public')` e `File::delete` no lugar do conteúdo. |
| Sessão/APCu | Não usado pelo módulo. |

## 11. Riscos e decisões de equivalência funcional

| Risco | Mitigação |
|---|---|
| Mudar a página de 12 sem avisar | Manter default 12 e documentar a configuração. |
| Filtrar por `status`/`published_at` no portal | Não filtrar sem aprovação do negócio (mudaria o que já está publicado). |
| Migrar para tabela própria e esquecer os `archives` | Script de migração deve mover `archives` e `links` junto, ou manter `contents_id`. |
| Deletar a view SQL sem recriar a DataTable | Substituir a view por uma query Eloquent no Resource/Query do CMS. |
| Trocar as URLs | Manter `/unidades-de-atendimento` e `/unidades-de-atendimento/{slug}`; se a URL mudar, criar redirecionamento 301 do slug antigo. |
| Herdar a view SQL com `row_number()` | Substituir por `RowNumber` do Query Builder ou por índice real do banco. |
| Perder as permissões | Manter os nomes `unidades-de-atendimento.*` ao criar as permissões no seed da v7. |
| Injetar HTML no `text` | Sanear na escrita (allowlist de tags) — decisão de segurança que não altera o conteúdo legítimo. |
