# Fase 2 — os banners do aplicativo

20/10/2026, a fatia 21 da fila das telas que faltam (BANNERS-SERVIDOR). O desenho é
`docs/f3-telas-restantes/tela-banners.md`, a seção 2 e as fatias 1 e 2 da seção 3. A tela nova
é a fatia 22; esta fatia é só o servidor: a tela antiga consertada e a API nova.

**A tela nova** (a fatia 22, BANNERS-TELA, 20/10/2026) consome este recurso sem mudá-lo:
`frontend/src/api/banners.ts`, `telas/Banners.tsx`, `BannersLigado.tsx` e `formularioDoBanner.ts`,
em Configurações > Aplicativo > Banners. O que ela faz está na seção 7 do desenho.

O banner é o que o cliente vê ao abrir o aplicativo, **na rede inteira**: a tabela
`app_banners` não tem unidade, e o aplicativo de toda unidade mostra os mesmos, em ordem
alfabética da descrição (`/api/banners/selecionar`, congelado — o D5).

---

## O recorte

| endpoint | verbo | origem |
|---|---|---|
| `/adm/api/banners` | `GET` | `indexAction` (o estado, a busca, a página) |
| `/adm/api/banners` | `POST` (multipart: `descricao`, `imagem`) | `salvarAction` sem `id` → `AppBannerService::cadastrar` |
| `/adm/api/banners/:id` | `GET` | `editarAction`, leitura |
| `/adm/api/banners/:id` | `PUT` (JSON: `descricao`) | `salvarAction` com `id`, sem imagem → `AppBannerService::alterar` |
| `/adm/api/banners/:id/imagem` | `POST` (multipart: `imagem`) | `salvarAction` com `id` e imagem → `AppBannerService::alterar` |
| `/adm/api/banners/:id/desativacao` | `POST` | `ativarRemoverAction` com `estado=inativo` → `definirEstado` |
| `/adm/api/banners/:id/ativacao` | `POST` | `ativarRemoverAction` com `estado=ativo` |

**Fica de fora:**

| o quê | por quê |
|---|---|
| `DELETE` | apagar de vez é a pergunta 1 do desenho, sem resposta; segue-se a recomendação (a): só tirar e pôr no aplicativo. A ação antiga `remover` continua, só por `POST`, e nenhuma tela a chama |
| a coluna de ordem | a pergunta 2, sem resposta; segue-se a (a): a ordem é a alfabética, e a posição vai escrita no recurso |
| a recusa da proporção | a pergunta 3, sem resposta; segue-se a (a): fora de 2:1 (com 2% de tolerância) é só aviso, `proporcaoFora` |

---

## O recurso

```json
{
  "id": 13,
  "descricao": "Promoção de Natal",
  "url": "/banners/0f3c...e1.jpeg",
  "ativo": true,
  "posicao": 3,
  "largura": 1600,
  "altura": 740,
  "bytes": 254389,
  "proporcaoFora": true,
  "criadoEm": "2026-10-20T10:00:00-03:00",
  "alteradoEm": "2026-10-20T10:00:00-03:00",
  "vizinhos": {"anterior": {"id": 7, "descricao": "Barba"}, "seguinte": {"id": 2, "descricao": "Pacotes"}}
}
```

- **`posicao`** é a do aplicativo, e sai da mesma consulta que o aplicativo lê
  (`AppBannerRepository::selecionarBannersAtivos`, com o desempate pelo id): uma regra repetida
  diverge. O banner fora do aplicativo vem com `null`. `vizinhos` só vem no banner aberto.
- **As medidas** são lidas do arquivo guardado (`Armazenamento::paraLeitura`); `null` quando
  o arquivo não está lá ou não é JPEG. Com o S3 ligado (o D33), cada linha baixaria a imagem:
  nesse dia, as dimensões viram coluna.
- **`proporcaoFora`** é decidido no servidor (`RegraDoBanner::proporcaoFora`); a tela só o mostra.
- **`url`** vem do `Armazenamento::url()`. No disco local é `/banners/<nome>`, o caminho de
  sempre, que o nginx serve.
- **A coleção** leva, fora de `dados`, `noAplicativo` (quantos banners ativos) e
  `unidadesAtivas` — a frase do topo da tela: "o aplicativo das 32 unidades mostra os 5
  banners ativos".

---

## As guardas

Ler pede `configuracoes/appConfiguracoes/ler`; o resto, `escrever`. **Não há conferência de
unidade:** o banner é da rede, e só um administrador concede essa funcionalidade
(`RegraDoPrivilegio::DE_REDE`). Hoje, 0 concessões: na prática, só os administradores.
Escrever **não** exige ser administrador à parte, como o desenho decidiu para seguir; a seção 4.3
da fila pede o contrário no que é da rede, e a escolha foi ao dono (o D544).

## O envio de arquivo

A regra 8 de `docs/convencao-api.md`, que nasceu aqui: multipart só em `POST`; o teto (5 MB) e
o corpo acima do `post_max_size` como `422` no campo `imagem`; o `error` do envio lido; JPEG
pelo conteúdo; o nome físico aleatório; a ordem "guarda o novo, grava a linha, apaga o
antigo"; e o armazenamento que falha como `500 erro_interno`, com nada gravado.

## O que o aplicativo recebe não muda

`/api/banners/selecionar` continua dando `{sucesso, banners: [{descricao, url}]}`, só os ativos,
em ordem de descrição (agora com o desempate pelo id, o que não muda a ordem de hoje: as
descrições ativas são distintas), e a URL é `sistema.url` mais `banners/` mais a coluna. O
arquivo do banner cadastrado pela API nova vai para `banners/<nome>`, o mesmo caminho público.
`BannersPelaApiTest::testOAplicativoRecebeOQueSempreRecebeu` prende isso em processo, e o caso 21
de `scripts/ensaiar-banners-adm-api.php`, pelo HTTP — com o nginx servindo a imagem.

## Provado

- `tests/Caracterizacao/RegraDoBannerTest.php`, `TelasDosBannersDoErpTest.php` e
  `BannersPelaApiTest.php`, e a linha dos banners em `D199AtivarRemoverPorPostTest.php`, nos
  dois bancos.
- `scripts/ensaiar-banners-adm-api.php`, pelo HTTP, nos dois bancos de contrato — o único lugar
  em que o envio passa de verdade pelo `$_FILES`. Ele grava em `public/banners` e apaga, pelo
  nome, só o que é dele: o arquivo do cenário e os que as linhas dele apontam (a verificação
  de 20/10/2026; antes apagava tudo o que não estava lá no começo, também o que outra pessoa
  guardasse durante a rodada). O arquivo a mais que não é de linha do ensaio fica, e a rodada
  sai com 2.

## A tela antiga, depois da verificação (20/10/2026)

- **A recusa do salvar fica na página**, debaixo do formulário (`#banner-recusa`, com
  `role="alert"`), e não no aviso de 5 s do canto; o título é "O banner não foi salvo:", e não
  mais "Informe os campos obrigatórios:" sobre a imagem que foi informada. O
  `mod-configuracoes.js` vai com o `?cache=168`.
- **O corpo acima do `post_max_size`** diz o tamanho ("Imagem: A imagem passa de 5 MB."), e não
  "O formulário expirou": o PHP entrega o pedido vazio, sem o token. O 413 do nginx também vai
  para a página, com a frase do limite.
- **A tela antiga não dá o aviso de proporção**: o desenho o põe na tela nova ("a tela só o
  mostra"), e a antiga segue com o texto fixo das dimensões. Não é recusa que falta.
- As sete rotas em `scripts/ensaiar-permissao-adm-api.php`.
