# Licitantes Sancionados — Migração Moderna (parte 2): Frontend, Infra e Dados

> Complemento de `06-MIGRACAO-MODERNA.md`, que cobre schema, models, Form Requests,
> Policies, endpoints e API Resources. Este arquivo cobre as **views Blade
> modernas**, a **infraestrutura** (fila, cache, storage) e o **checklist de
> migração de dados**.

---

## 1. Views Blade modernas

### 1.1 Componentes de formulário (CMS)

```blade
{{-- resources/views/components/form/input.blade.php --}}
@props(['name', 'label', 'value' => null, 'type' => 'text', 'required' => false, 'maxlength' => null])

<div class="col-sm-{{ $col ?? 12 }}">
    <div class="form-group @error($name) has-error @enderror">
        <label for="{{ $name }}">
            {{ $label }}
            @if ($required)
                <strong class="text-red star"><i class="fa fa-asterisk"></i></strong>
            @endif
        </label>
        <input type="{{ $type }}" id="{{ $name }}" name="{{ $name }}"
               value="{{ old($name, $value) }}"
               @if ($maxlength) maxlength="{{ $maxlength }}" @endif
               @if ($required) required @endif
               {{ $attributes }}>
        @error($name)
            <span class="help-block text-red">{{ $message }}</span>
        @enderror
    </div>
</div>
```

> O componente da v5 (`v5/resources/views/components/inputs/input.blade.php`)
> compara `$mandatory == 'true'` e, por isso, **nunca** exibe o asterisco de campo
> obrigatório (ver B13 no `08-GAPS-E-DIVIDAS.md`). A v7 usa o booleano direto
> (`@if ($required)`), eliminando a divergência.

```blade
{{-- resources/views/components/sanctioned-bidder/form-fields.blade.php --}}
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
    <x-form.input name="year" label="Ano" type="number" :maxlength="4"
                  :value="old('year', $bidder?->year ?? now()->year)" required />

    <x-form.input name="sanction_number" label="Número da Sanção" :maxlength="50"
                  :value="old('sanction_number', $bidder?->sanction_number)"
                  placeholder="Ex: 002/2026" required />

    <x-form.input name="company_name" label="Nome / Razão Social" :maxlength="255"
                  :value="old('company_name', $bidder?->company_name)" required />

    <x-form.input name="tax_id" label="CPF / CNPJ" :maxlength="18"
                  :value="old('tax_id', $bidder?->tax_id)"
                  placeholder="000.000.000-00 ou 00.000.000/0000-00" required
                  x-mask="cnpj" />

    <x-form.date name="sanction_starts_at" label="Início da Sanção" format="d/m/Y"
                 :value="old('sanction_starts_at', $bidder?->sanction_starts_at?->format('d/m/Y'))"
                 required />

    <x-form.date name="sanction_ends_at" label="Fim da Sanção" format="d/m/Y"
                 :value="old('sanction_ends_at', $bidder?->sanction_ends_at?->format('d/m/Y'))"
                 required />
</div>

<x-form.attachments name="attachments" :existing="$bidder?->attachments ?? []" />
```

O componente de anexos (substitui o JS inline de ~40 linhas da v5):

```blade
{{-- resources/views/components/form/attachments.blade.php --}}
@props(['name' => 'attachments', 'existing' => [], 'accept' => '.pdf', 'maxSizeKb' => 10240])

<div class="form-group" x-data="attachmentsField(@js($existing))">
    <label>Arquivos</label>
    <div class="space-y-2">
        <template x-for="(file, index) in files" :key="index">
            <div class="flex items-center gap-2 border rounded p-2">
                <span class="flex-1 text-sm" x-text="file.description || file.name"></span>
                <input type="text" :name="`${name}_desc[]`" x-model="file.description"
                       placeholder="Informe a descrição" class="form-control">
                <button type="button" @click="remove(index)" class="btn btn-flat btn-danger">
                    <i class="fa fa-trash"></i>
                </button>
            </div>
        </template>
    </div>

    <input type="file" :name="`${name}[]`" accept="{{ $accept }}" multiple
           @change="add($event)" class="form-control mt-2">
    <small class="text-muted">PDF, até {{ number_format($maxSizeKb / 1024, 0) }} MB por arquivo.</small>

    @error($name)
        <span class="help-block text-red">{{ $message }}</span>
    @enderror
</div>
```

Existing attachments (modo edição) são exibidos com download/visualizar/excluir, e a
remoção passa a chamar o endpoint de exclusão de anexo (que, na v7, **apaga o arquivo
do storage** — corrigindo B8).

### 1.2 Portal

```blade
{{-- resources/views/portal/licitantes-sancionados/index.blade.php --}}
<x-portal.layout :title="$title">
    <x-portal.breadcrumb
        :items="[
            ['label' => 'Início', 'url' => route('home')],
            ['label' => 'Licitantes Sancionados'],
        ]" />

    <h1 class="text-2xl font-semibold uppercase text-primary">Licitantes Sancionados</h1>
    <hr class="border-primary mb-6">

    <x-sanctioned-bidder.filters
        :years="$years"
        :selected-year="$anoSelecionado"
        :search="$busca"
        :from="$dtInicio"
        :to="$dtFim" />

    <x-sanctioned-bidder.table :rows="$result" />

    @if ($result->isEmpty())
        <p class="text-center mt-4">Nenhuma informação encontrada!</p>
    @endif

    {{ $result->appends(request()->query())->links() }}
</x-portal.layout>
```

```blade
{{-- resources/views/portal/licitantes-sancionados/show.blade.php --}}
<x-portal.layout :title="$sancionado->company_name">
    <x-portal.breadcrumb
        :items="[
            ['label' => 'Início', 'url' => route('home')],
            ['label' => 'Licitantes Sancionados', 'url' => route('licitantes-sancionados.index')],
            ['label' => $sancionado->company_name],
        ]" />

    <h1 class="text-2xl font-semibold uppercase text-primary">Licitante Sancionado</h1>
    <hr class="border-primary mb-6">

    <dl class="grid grid-cols-1 md:grid-cols-2 gap-y-3">
        <div><dt class="font-semibold">Ano</dt><dd>{{ $sancionado->year }}</dd></div>
        <div><dt class="font-semibold">Número da Sanção</dt><dd>{{ $sancionado->sanction_number }}</dd></div>
        <div><dt class="font-semibold">Nome / Razão Social</dt><dd>{{ $sancionado->company_name }}</dd></div>
        <div><dt class="font-semibold">CPF / CNPJ</dt><dd>{{ $sancionado->tax_id }}</dd></div>
        <div><dt class="font-semibold">Início da Sanção</dt><dd>{{ $sancionado->sanction_starts_at->format('d/m/Y') }}</dd></div>
        <div><dt class="font-semibold">Fim da Sanção</dt><dd>{{ $sancionado->sanction_ends_at->format('d/m/Y') }}</dd></div>
    </dl>

    @if ($sancionado->attachments->isNotEmpty())
        <div class="card mt-4">
            <h2 class="font-semibold mb-3">Arquivos para Download</h2>
            <ul class="space-y-2">
                @foreach ($sancionado->attachments as $attachment)
                    <li>
                        <a href="{{ $attachment->downloadUrl() }}" target="_blank" class="text-primary">
                            <i class="far fa-file-archive"></i>
                            {{ $attachment->description ?: basename($attachment->path) }}
                        </a>
                    </li>
                @endforeach
            </ul>
        </div>
    @endif

    <div class="text-center mt-6">
        <a href="{{ route('licitantes-sancionados.index') }}" class="text-secondary">Voltar</a>
    </div>
</x-portal.layout>
```

### 1.3 Ganhos concretos sobre a v5

| Ganho | Antes (v5) | Depois (v7) |
|---|---|---|
| Views do portal | 19 arquivos (3 × 6 templates + 1 componente) | 2 arquivos + 2 componentes |
| Filtros de data | Ausentes no `modelo4`; "Limpar campos" varia entre templates | Componente único, idêntico em todos os temas |
| Máscara de CPF/CNPJ | JS inline duplicado em cada `form.blade.php` | Atributo `x-mask` + um único script |
| Bloco de anexos | JS inline (~40 linhas) + HTML | Componente Alpine `attachmentsField` |
| Campo obrigatório | Asterisco nunca exibido | `required` nativo do HTML + asterisco |
| Download do anexo | `url('storage/' . $path)` (depende de `storage:link`) | Rota de download com Policy ou URL assinada |

## 2. Fila, cache e storage

| Aspecto | Decisão |
|---|---|
| **Storage** | Disk `public`, pasta `sanctioned-bidders/{id}/`; `storage:link` no deploy (corrige B16) |
| **Nomes de arquivo** | Preservar o **nome original** em `original_name`; gravar com nome seguro (`Str::slug` + `uniqid`) — corrige B9 |
| **Download** | Rota do CMS com Policy (`downloadAttachment`) ou `Storage::temporaryUrl`; nada de URL pública de disco |
| **Fila** | Upload síncrono (o arquivo é pequeno e não há processamento). Se houver geração de PDF de ficha, usar `ShouldQueue` |
| **Cache** | `Cache::remember('sanctioned-bidders.years', 3600, fn () => ...)` para a lista de anos; invalidar no `saved`/`deleted` |
| **Rate limiting** | `throttle:60,1` na API pública, para conter raspagem |
| **Log de atividade** | `spatie/laravel-activitylog` no model (`LogsActivity`) — fecha a lacuna A8 |
| **Busca textual** | `pg_trgm` + índice GIN em `company_name` e `tax_id`, ou índice `tsvector` do PostgreSQL, quando o volume crescer |
| **Observabilidade** | `Log::channel('auditoria')` nas operações de sanção, como complemento do log de atividade |
| **E-mail** | Opcional: notificar o setor jurídico no evento `created` |
| **Console** | Comando `sanctioned-bidders:rebuild-slugs` para regenerar slugs em lote após a migração |

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

O que **não pode mudar** (contrato com o cidadão e com o CMS):

| # | Comportamento a preservar | Motivo |
|---|---|---|
| E1 | `/licitantes-sancionados` (listagem) e `/licitante-sancionado/{slug}` (ficha) | URLs já divulgadas |
| E2 | Filtros por ano, busca textual e intervalo de datas | Uso operacional |
| E3 | Paginação de 15 registros por página | Padrão do portal v5 |
| E4 | Formato `d/m/Y` das datas no formulário do CMS | O operador digita em dd/mm/aaaa |
| E5 | Anexos em PDF, até 10 MB, com descrição opcional | Regra do CMS |
| E6 | Exclusão lógica (o registro sai do portal, o histórico permanece) | Rastreabilidade |
| E7 | Slug legível derivado do nome + id | URL amigável e busca |
| E8 | Card "Arquivos para Download" com a descrição como rótulo | UX do portal |
| E9 | Migalha Home > Licitantes Sancionados > Nome | Navegação do portal |

O que **pode** (e deve) mudar:

| # | Mudança | Ganho |
|---|---|---|
| M1 | Slug único e **estável** (não recalculado no update) | Elimina links quebrados (B7) |
| M2 | Índice único em `slug` | Integridade garantida pelo banco (B1) |
| M3 | Busca textual parametrizada, sem `translate()` do PostgreSQL | Portabilidade (MySQL) e correção do bug de multi-palavra (B2) |
| M4 | `fim >= início` validado | Coerência temporal |
| M5 | Anexos em tabela 1:N, com nome original e limpeza do arquivo ao excluir | Rastreabilidade e higiene do storage (B8, B9) |
| M6 | Log de atividade | Rastreabilidade de agentes públicos (A8) |
| M7 | Resource `sanctioned-bidders` + permissões | Módulo operável por perfis autorizados (B3) |
| M8 | API pública read-only | Integração e transparência |
| M9 | Exportação (XLSX/PDF/CSV) no portal, que a v5 não tinha | Requisito usual de transparência |
| M10 | Paginação server-side no CMS | Escala |
| M11 | Filtro por **vigência** (início ≤ ref AND fim ≥ ref) | Responde "quem está impedido hoje" (B10) |

## 4. Checklist de migração de dados

1. Criar as tabelas `sanctioned_bidders` e `sanctioned_bidder_attachments`.
2. Migrar `licitantes_sancionados` → `sanctioned_bidders`:
   `ano→year`, `numero_sancao→sanction_number`, `nome_razao_social→company_name`,
   `cpf_cnpj→tax_id`, `inicio_sancao→sanction_starts_at`, `fim_sancao→sanction_ends_at`.
3. **Normalizar o slug**: escolher **uma** fonte (recomendado
   `Str::slug($company_name . '-' . $id)`), resolver duplicatas e só então criar o
   índice único. Registros com `slug` NULL devem ser preenchidos.
4. Migrar `arquivo` (JSON) → uma linha por item em `sanctioned_bidder_attachments`,
   preservando ordem e descrição; tratar também o formato legado em que `arquivo` é
   uma string simples.
5. Copiar os arquivos de `storage/app/public/docs/` para
   `storage/app/public/sanctioned-bidders/{id}/` e reescrever `path`.
6. Validar que a contagem de itens do JSON na v5 corresponde ao número de anexos
   migrados e reportar arquivos órfãos.
7. Rodar `php artisan storage:link` e validar os downloads do portal.
8. Registrar o resource `sanctioned-bidders` e vincular as permissões aos perfis
   autorizados (fecha B3).
9. Validar a listagem e a ficha do portal com o layout ativo e com cada tema da v7.

## 5. Ordem de execução sugerida

| Etapa | Entrega | Depende de |
|---|---|---|
| 1 | Migrations + model + factory + seeder | — |
| 2 | Policies + resource/permissões | 1 |
| 3 | API Resources + endpoints JSON | 1, 2 |
| 4 | CMS (controllers + Form Requests + views) | 1, 2 |
| 5 | Portal (controllers + views + componentes) | 1 |
| 6 | Script de migração de dados + validação | 1 |
| 7 | Testes (unit, feature) | 1–6 |
| 8 | Exportação no portal + API pública read-only | 3, 5 |
