# Fase 2 — a segunda fatia: a agenda

Escolhida em 05/09/2026. O roadmap dizia que escolher a próxima fatia era o
único passo que não dependia nem de decisão de quem manda nem de janela de
produção — este documento é essa escolha, com o que a sustenta.

**Os oito recursos do mapa estão de pé**, feitos em 05/09/2026: as duas
leituras e as seis escritas — marcar, check-in, desmarcar, remarcar, fechar e
reabrir.

---

## Por que a agenda

**Porque é o dia da barbearia.** O PDV é onde o dinheiro entra; a agenda é o que
faz o cliente aparecer. São ~800 clientes por dia em 32 unidades, e quase todos
passam pela agenda antes de passar pelo caixa.

E porque o obstáculo é o mesmo que a fatia do PDV nomeou, na mesma medida:

| | |
|---|---:|
| ações do `AgendasController` | 20 |
| que devolvem JSON | 13 |
| **que devolvem HTML dentro do JSON** (`retorno['tabela']`) | **8** |

Oito ações respondem `{"sucesso": true, "tabela": "<tr>..."}`. Uma tela nova não
tem o que fazer com isso — é exatamente a frase que abriu a fatia do PDV.

---

## O que eu achei que ia encontrar, e não era

A primeira leitura foi desanimadora: 853 linhas de controlador, 34 chamadas a
repositório, dois serviços. Cheguei a escrever que a agenda seria mais cara que o
PDV porque as regras estariam no controlador — e que o PDV tinha sido a exceção
por já ter serviços.

**Estava errado, e o próprio código diz.** `ISCadastro\Service\AgendaService`
existe, tem 221 linhas, e é onde as mutações moram:

```
agendar()   desmarcar()   checkin()   abrir()   fechar()
```

O controlador chama todas elas. As 853 linhas são montagem de requisição e
apresentação — inclusive o `getAgenda()`, que monta a tabela de horários.

A distância entre a primeira impressão e a segunda é a razão de este documento
existir antes do código.

---

## Três consumidores já dividem o mesmo serviço

`AgendaService` não é do ERP: é de três consumidores.

| quem | por onde |
|---|---|
| ERP | `AgendasController` |
| aplicativo, contrato congelado | `AplicativoController` (D5) |
| aplicativo, API nova | `V4\AgendamentosController` |

**Isso é um limite duro da fatia:** a API nova acrescenta uma face HTTP, e não
mexe no serviço. Qualquer mudança de comportamento ali sai pelo `/api*`, que
serve aparelho instalado que ninguém atualiza em conjunto.

A fatia do PDV não tinha essa restrição — `VendaPagamentoService` era do ERP.
Aqui, a suíte de contrato é a rede: dos 67 instantâneos, **dez** são de agenda —
selecionar, salvar em duas versões, desmarcar, e as variantes com e sem sessão.

---

## O fluxo, e o mapa

"Abrir o dia até o cliente chegar":

| # | Ação de hoje | Recurso novo |
|---|---|---|
| 1 | `Agendas::abrir` | `POST /adm/api/agendas/aberturas` |
| 2 | `Agendas::fecharValidar` + `fechar` | `POST /adm/api/agendas/fechamentos` |
| 3 | `Agendas::horariosDisponiveis` | `GET /adm/api/agendas/horarios-livres` |
| 4 | `Agendas::horariosMarcados` / `selecionar` | `GET /adm/api/agendamentos` |
| 5 | `Agendas::agendar` | `POST /adm/api/agendamentos` |
| 6 | `Agendas::remarcarSalvar` e `remarcarDragAndDropSalvar` | `PUT /adm/api/agendamentos/:id` |
| 7 | `Agendas::desmarcar` | `POST /adm/api/agendamentos/:id/cancelamento` |
| 8 | `Agendas::checkin` | `POST /adm/api/agendamentos/:id/checkin` |

**Nada disso substitui as ações atuais.** `/mod-cadastros/agendas/...` continua
servindo a tela de hoje, pelo mesmo motivo do PDV: trocar tela e API na mesma
jogada é o acoplamento que a fatia vertical existe para evitar.

### Duas ações viram uma, e isso é decisão

`remarcarSalvar` e `remarcarDragAndDropSalvar` fazem a mesma coisa por caminhos
diferentes — a segunda existe porque a tela tem arrastar-e-soltar. O recurso é o
mesmo: mover um agendamento. Quem arrasta e quem preenche formulário mandam o
mesmo `PUT`.

### O que fica de fora, e por quê

| ação | por quê |
|---|---|
| `bloqueios`, `bloqueiosRemover` | é outro recurso — bloqueio de horário, não agendamento |
| `clientesNovos`, `clientesSemRetorno` e as duas de mensagem | são **relatório** e disparo de mensagem; a segunda metade cai no WhatsApp/SMS, congelado por decisão |
| `atualizar` | é o *refresh* da tela: some quando a tela for React |
| `index` | a tela |

---

## O que a fatia mediu

A do PDV existiu para pôr as sete regras à prova, e nenhuma caiu. Esta tinha
outra pergunta:

**O que acontece quando o serviço é compartilhado com um contrato congelado?**

O PDV pôde mudar o que quisesse abaixo do HTTP. Aqui não podia. As saídas eram
três, e a fatia usou **duas**:

| saída | onde | resultado |
|---|---|---|
| **estender sem mudar o que existe** | a regra de agendamento saiu do controller para `RegraDeAgendamento`, devolvendo motivos com código; a tela antiga monta o HTML dela a partir dos mesmos motivos | a tela não mudou, e há oito casos comparando a frase palavra por palavra |
| **compor por fora** | o check-in: o serviço é interruptor, e o `POST` lê o estado antes para não desfazer | o serviço continua servindo à tela, e o endpoint é idempotente |
| **duplicar a regra** | — | **não foi preciso nenhuma vez** |

**Nenhuma linha de `AgendaService` foi tocada.** Era esse o risco escrito aqui
antes de começar, e ele não se realizou.

---

## O que esta fatia não resolveu

- **A autenticação por token.** Segue adiada pela decisão de 29/08; os
  endpoints exigem sessão do ERP e respondem 403, e viram 401 quando o token
  existir.
- **A tela React**, que continua não existindo neste repositório: a fatia é
  vertical até onde dá.

---

## Como ficou

| ação de hoje | recurso novo | o mapa acertou? |
|---|---|---|
| `Agendas::horariosMarcados` / `selecionar` | `GET /adm/api/agendamentos` | sim |
| `Agendas::horariosDisponiveis` | `GET /adm/api/agendas/horarios-livres` | sim |
| `Agendas::agendar` | `POST /adm/api/agendamentos` | sim |
| `Agendas::checkin` | `POST /adm/api/agendamentos/:id/checkin` | sim |
| `Agendas::desmarcar` | `DELETE /adm/api/agendamentos/:id` | **não** — o mapa dizia `POST .../cancelamento` |
| `Agendas::remarcarSalvar` e `remarcarDragAndDropSalvar` | `POST /adm/api/agendamentos/:id/remarcacao` | **não** — o mapa dizia `PUT .../:id` |
| `Agendas::fechar` (com `fecharValidar`) | `POST /adm/api/agendas/fechamentos` | sim |
| `Agendas::abrir` | `DELETE /adm/api/agendas/fechamentos/:id` | sim |
| `Agendas::abrir` com `acao: pesquisar` | `GET /adm/api/agendas/fechamentos/:id` | acrescentado em 26/09/2026 -- a consulta antes de reabrir |

**Oito recursos certos, dois verbos errados** — e os dois erros têm a mesma
causa: o mapa foi desenhado por analogia com REST, e o verbo depende do que o
código faz, não do que o nome sugere. Desmarcar **apaga a linha**; remarcar
**cria outra e apaga esta** — e, desde 23/09/2026, apaga esta e grava outra,
numa transação: ver "A remarcação refeita", no fim.

O diário abaixo é a ordem em que as coisas aconteceram, e por quê. Quem quiser
só o resultado pode parar aqui.

---

## O diário da construção

### O primeiro endpoint, e o que ele já ensinou

`GET /adm/api/agendamentos?data=AAAA-MM-DD` devolve os agendamentos do dia da
unidade **da sessão** — não se pede unidade na query, porque são 32 e a tela de
hoje também mostra só a de quem está logado. Sem `data`, hoje.

Peças: `ISCadastro\Api\AgendamentoJson`, `Controller\Adm\AgendamentosController`,
`AgendaRepository::selecionarPorUnidadeData` e a rota com verbo declarado. O
medidor de `/adm/api` passou de 11 para 12 endpoints.

### Ele passou na medição sem nunca ter rodado

O medidor chama o endpoint sem parâmetro; o padrão é *hoje*; e o cenário de
fixtures não tem agendamento hoje. **Coleção vazia, status 200, formatador nunca
chamado.** Dentro dele havia dois defeitos:

| o que eu escrevi | o que a coluna é |
|---|---|
| `checkin`/`checkout` tratados como instante — `?->format()` sobre eles é erro fatal | **booleanos** |
| um mapa de quatro estados numerados, copiado do PDV | `status` é **booleano**, e "desmarcado" não existe: desmarcar **remove** a linha |

E a consulta não filtrava `age.status = true`, que é o que **todas** as outras do
repositório fazem — a API nova mostraria o que a tela velha esconde.

Os três saíram por leitura do domínio, não por teste vermelho: o teste veio
depois, para que não voltem. São sete casos, e o que eles guardam é a lição
inteira desta sessão aplicada ao próprio código novo — **verde por ausência de
dado não é verde**.

### O segundo: onde ainda cabe

`GET /adm/api/agendas/horarios-livres?colaborador=<id>&data=AAAA-MM-DD` devolve
os horários abertos — e, quando não há nenhum, **por quê**:

```json
{"colaborador": {"id": 12, "nome": "..."}, "data": "2026-12-24",
 "aberta": true, "motivo": null, "duracaoMinutos": 20,
 "horarios": ["08:00", "08:20", "08:40", "..."]}
```

A ação de hoje devolve isto:

```json
{"horarios": "<option value='09:00'>09:00</option>...",
 "servicos": "<option value='12'>Corte</option>..."}
```

Duas listas de `<option>`. **Os serviços não vêm junto** no recurso novo: são
outro recurso, e misturá-los faria a tela pedir horários para saber quais
serviços existem.

E `aberta`/`motivo` existem porque *"o dia encheu"* e *"o colaborador está de
férias"* são respostas diferentes — a tela de hoje já faz a distinção, escrevendo
"Agenda fechada" na coluna.

#### O ponto delicado: o formato da data

`DataHora::diasemana()` faz `explode('-')` e lê a **primeira** posição como dia.
Passar o ISO `2026-12-24` direto daria `mktime(0, 0, 0, 12, 2026, 24)` — mês 12,
dia 2026 — e a grade sairia com os horários de outro dia da semana, **sem erro
nenhum**. Resposta plausível e errada.

A conversão fica num lugar só, com o porquê ao lado, e o teste a cobre por
consequência: o cenário atende quinta das 08:00 às 20:00, e `2026-12-24` é uma
quinta. Se a conversão quebrar, o dia passa a parecer fechado e o teste cai.

#### A montagem saiu do controller, e o motivo é o endpoint anterior

Na primeira versão ela era um método privado, com o argumento de que não há duas
visões deste recurso para divergirem. O argumento era verdadeiro e insuficiente:
**ali dentro, só HTTP a exercitaria** — e o medidor chama o endpoint sem
`colaborador`, recebe 422 e nunca chegaria na conta. Foi assim que o endpoint
anterior passou na medição sem nunca ter rodado.

Ela mora em `ISCadastro\Api\HorariosLivresJson`. O controller decide quem pode
perguntar — sessão, permissão, unidade — e valida a entrada.

Cinco casos de teste, 23 asserções, nos dois bancos. A suíte foi de 246 para 251.

### A escrita começou pela regra, e não pelo endpoint

`POST /adm/api/agendamentos` ainda não existe. Antes dele veio o que ele
precisava, e o caminho até aí foi o exercício que esta fatia prometia.

`AgendaService::agendar()` **não valida nada**: recebe o pedido pronto e grava.
Quem impede um agendamento é uma função **privada** do `AgendasController`, com
três chamadores na própria tela, e ela monta HTML:

```php
$mensagem .= "- O horário " . $hora . " não está mais disponível<br/>";
```

E o que ela guarda é regra de negócio de verdade:

| regra | onde estava escondida |
|---|---|
| serviço de N períodos precisa de N horários | dentro de um `else` de um `else` |
| cada horário tem de estar livre | laço com consulta por hora |
| os horários têm de ser consecutivos | variável `$mensagem2` acumulada fora do laço |
| o passo é o `tempoAtendimento` **do colaborador** | `DateInterval` montado no meio da conferência |

As três saídas que este documento previu eram: acrescentar ao serviço, compor
por fora, ou duplicar. **Foi a primeira, feita do jeito que não mexe no que já
funciona:** a regra saiu inteira para `ISCadastro\Regra\RegraDeAgendamento`,
devolvendo motivos com código e mensagem; a função antiga continua existindo,
com a mesma assinatura, e monta o HTML dela a partir dos mesmos motivos.

O `AgendaService` **não foi tocado** — é ele que o contrato congelado
compartilha. O aplicativo tem regra própria (`AplicativoService::validarAgendamento`),
e ela também não foi tocada.

#### O que garante que a tela não mudou

Oito casos de teste, e os que mais importam comparam a frase **palavra por
palavra** — incluindo o `</br>` invertido do cabeçalho, que já estava assim e
não é assunto desta mudança. Trocar a mensagem passa a ser uma decisão, e não um
efeito colateral.

A suíte foi de 251 para 259 testes. As 153 telas continuam sem erro.

### `POST /adm/api/agendamentos`, e as duas perguntas que a leitura respondeu

```json
{"cliente": 12, "servico": 7, "colaborador": 3,
 "data": "2026-12-24", "horas": ["10:00"], "observacao": ""}
```

`horas` é lista porque serviço de dois períodos ocupa dois horários seguidos. Os
motivos de recusa saem como **códigos**, não como `<br/>`:

```json
{"mensagem": "O agendamento não pôde ser feito.",
 "campos": {"horario-ocupado": "O horário 09:00 não está mais disponível"}}
```

Eu disse que ia parar para perguntar duas coisas. **Nenhuma das duas precisou de
decisão, e nenhuma precisou tocar em código compartilhado:**

| o que parecia decisão | o que a leitura mostrou |
|---|---|
| `agendar()` grava `origem = "web"` fixo | `web` é o que o ERP escreve — 3 milhões de linhas contra 1 milhão de `app`. Esta API **é** a face nova do ERP |
| `agendar()` devolve a próxima hora livre, e não o recurso | conveniência da tela. As linhas existem depois de gravadas, e `selecionarHorariosFilhos()` as devolve pelo `idPai` — sem tocar no serviço |

O `AgendaService` continua intocado, e era esse o risco escrito no plano.

Quatro casos de teste guardam a costura: `origem` gravada, os dois horários
ligados por `idPai`, o recurso devolvido ser o agendamento **e não** a próxima
hora, e o horário ocupado não chegar a gravar. A suíte foi de 259 para 263, e o
medidor de `/adm/api` de 13 para 14.

### `POST /adm/api/agendamentos/:id/checkin`, e a diferença entre botão e pedido

`AgendaService::checkin()` faz `setCheckin(!getCheckin())`. É um **interruptor**,
e serve à tela de hoje, onde o mesmo botão marca e desmarca a chegada.

**Um `POST` não pode ser interruptor.** Quem reenvia — por rede ruim, por dois
cliques, por retentativa — não espera desfazer o que acabou de fazer. O endpoint
lê o estado antes e só chama o serviço quando há o que mudar.

É a **segunda** das três saídas que este documento previu: compor por fora, sem
tocar no que é compartilhado. O serviço continua interruptor porque a tela
depende disso; a idempotência é do adaptador.

Desfazer terá de ser outro pedido — `DELETE .../checkin` —, e aí o verbo diz o
que acontece. Ainda não existe.

O aviso de WhatsApp que o serviço enfileira ("Seu Cliente Chegou!") continua
sendo do serviço: o endpoint chama o mesmo método que a tela chama, e WhatsApp
está parado por decisão.

Três casos guardam as duas coisas ao mesmo tempo — que o serviço **é**
interruptor, e que a composição não desfaz. Se alguém simplificar o endpoint
chamando o serviço direto, o segundo cai.

**Este foi o primeiro endpoint da fatia que o medidor exercitou de verdade**: o
`:id` de fixture existe, a unidade bate, e a resposta foi 200 — não 422 de
validação. A suíte foi de 263 para 266, e `/adm/api` de 14 para 15.

### `DELETE /adm/api/agendamentos/:id` — o plano dizia outra coisa

Este documento previa `POST .../cancelamento`. **O código desmentiu:**
`AgendaService::desmarcar()` chama `remove()`, e a linha sai da tabela. Não há
agendamento cancelado para a tela riscar — não há agendamento nenhum.

Cancelamento sugere um recurso que continua existindo com outro estado. Quando
ele deixa de existir, o verbo é `DELETE`. Chamar de cancelamento o que é remoção
faria a tela nova esperar uma linha riscada onde não há linha.

**O histórico não se perde**, e isso é do serviço: antes de remover, ele copia a
linha para `agendas_exclusoes`, com `usuario_exclusao` e `data_exclusao`. Há uma
exceção escrita lá — agendamento preso a um fechamento de dia é removido **sem**
cópia. Não é assunto desta fatia; fica anotado para quem for mexer.

**O que ele devolve não segue o PDV, e o motivo é a diferença entre os dois
casos.** Lá, remover um item devolve a comanda: o recipiente continua existindo
e mudou. Aqui não há recipiente — o dia é uma consulta, não um recurso. Então
sai o retrato do que deixou de existir, tirado **antes** da remoção, que é o que
a tela precisa para saber quais horários ficaram livres.

Três casos guardam isso, e o terceiro existe para impedir uma "simplificação"
que mova o retrato para depois: ali ele vem vazio.

#### O medidor mostrou que os endpoints não são independentes

Com o `DELETE` na lista, o check-in — que respondia **200** — passou a responder
**404**: a lista sai em ordem alfabética, `DELETE` vem antes de `POST`, e o
agendamento de fixture já não está lá quando o check-in é chamado.

Não é defeito de nenhum dos dois. É o preço de medir escrita num banco só, e
agora está escrito no cabeçalho do medidor — junto com o aviso de que o banco
fica mexido depois da corrida, o que custou três testes vermelhos até eu
perceber.

A suíte foi de 266 para 269, e `/adm/api` de 15 para 16 endpoints.

### `POST /adm/api/agendamentos/:id/remarcacao` — e o plano errou de novo

O mapa previa `PUT /adm/api/agendamentos/:id`. **`PUT` promete substituir o
recurso naquele endereço: o id sobrevive.** Aqui não sobrevive — remarcar é
`agendar()` e depois `desmarcar()`, e o agendamento que sai tem outro id.

É a **segunda correção do mesmo tipo** nesta fatia, e a causa é a mesma das
duas: o mapa foi desenhado antes de ler o serviço. Vale registrar como padrão, e
não como acidente — um plano feito por analogia com REST acerta o recurso e erra
o verbo, porque o verbo depende do que o código faz, e não do que o nome sugere.

| o plano dizia | o código fazia | ficou |
|---|---|---|
| `POST .../cancelamento` | `remove()` — a linha sai | `DELETE .../:id` |
| `PUT .../:id` | cria outro e apaga este | `POST .../:id/remarcacao` |

**A ordem é criar depois apagar**, e ela é do serviço: se o horário novo estiver
ocupado, a regra recusa antes de mexer em qualquer coisa, e o cliente nunca fica
sem agendamento nenhum. O que não há é transação em volta das duas — se a
remoção falhasse depois da criação, sobrariam dois. Não é assunto desta fatia; o
endpoint chama o mesmo serviço que a tela chama, e fica escrito.

> **Deixou de ser assim em 23/09/2026.** A ordem "segura" tinha um defeito que
> este parágrafo não viu: com a família velha na tabela, o agendamento não podia
> andar sobre o próprio lugar. Ver "A remarcação refeita", no fim.

**Cliente e serviço vêm do agendamento antigo.** Trocar o cliente não é
remarcar: é desmarcar um e marcar outro, e são dois pedidos.

Três casos guardam isso, e o primeiro é o que derruba o `PUT`: o id muda.

A suíte foi de 269 para 272, e `/adm/api` de 16 para 17 endpoints.

### O que a leitura do `fechar()` mudou no recurso, antes de qualquer endpoint

Fui ler `AgendaService::fechar()` para escrever o próximo endpoint, e o que
encontrei corrigiu o que **já estava no ar**.

**Fechar a agenda não apaga horário: cria agendamento.** Para cada horário livre
do período, e para cada colaborador, o serviço grava uma linha em `agendas` com
`fechamento` apontado e sem cliente nem serviço.

Ou seja: o `GET /adm/api/agendamentos` que eu tinha entregue devolvia esses
bloqueios como se fossem marcações de cliente. A única pista era `cliente: null`
— quem desenhasse a grade a partir dali mostraria **um horário marcado sem
nome**, onde a tela de hoje escreve "agenda fechada".

O recurso passou a dizer:

```json
{"estado": "bloqueado", "cliente": null,
 "bloqueio": {"id": 4, "motivo": "Feriado"}}
```

`bloqueado` é estado próprio de propósito: chamar de `marcado` faria a tela
contar horário fechado como atendimento por vir.

**Isto não veio de teste vermelho nem de medição** — veio de ler o serviço antes
de usá-lo. É o terceiro achado da fatia que a leitura produziu e o instrumento
não produziria: o medidor via 200, e o teste que eu tinha escrito só conhecia
agendamentos de verdade.

Dois casos novos guardam a distinção. A suíte foi de 272 para 274.

### Fechar e reabrir: os dois últimos, e o que eles ensinaram

`POST /adm/api/agendas/fechamentos` e `DELETE /adm/api/agendas/fechamentos/:id`
completam os oito recursos do mapa.

**Três decisões, e nenhuma delas é enfeite:**

| decisão | por quê |
|---|---|
| a validação é do servidor | a tela valida no **navegador** e o `fechar()` aceita o que vier (**D111**) |
| "há N agendamentos no período" é **409**, não 422 | o pedido está correto; o estado é que conflita. O segundo vem com `"confirmar": true` |
| o período tem teto de 92 dias | fechar grava uma linha por horário livre, por colaborador, por dia — um pedido de um ano com trinta colaboradores escreveria centenas de milhares |

O teto está escrito como o que é: **guarda de quem atende o pedido, e não regra
do negócio.** A tela é operada por gente que fecha feriado e férias; um cliente
de API erra a data e pede século.

#### A barra que custou três testes vermelhos

O mesmo serviço lê data de dois jeitos: `agendar()` recorta por posição e aceita
traço ou barra; `fechar()` usa `createFromFormat("d/m/Y")` e **só** aceita
barra — devolvendo `false`, sem lançar, quando não casa. O sintoma foi
`Call to a member function format() on bool` três quadros dentro do serviço.

A tela nunca viu porque o formulário manda barra nos dois casos. Está anotado no
conversor, no teste e no D111.

#### O que a fatia mostrou, no fim

O mapa desenhado no começo acertou **os oito recursos** e errou **dois dos oito
verbos**, e as duas correções vieram da mesma coisa: ler o serviço em vez de
supor pelo nome. Nenhuma linha de `AgendaService` foi tocada — as três saídas
previstas eram estender, compor por fora ou duplicar, e **duplicar não foi
preciso nenhuma vez**.

A suíte foi de 274 para 278, e `/adm/api` de 17 para 19 endpoints.

---

## O nono recurso: a grade do dia — 16/09/2026

Os oito recursos de 05/09 respondiam "o que está marcado" e "onde ainda cabe".
Nenhum respondia **a pergunta que o balcão faz**: *como está a agenda hoje*. A
tela React precisava da grade inteira, e as duas saídas eram copiar a receita ou
extraí-la.

`GET /adm/api/agendas/grade?data=AAAA-MM-DD` — uma coluna por colaborador.

### A receita saiu do controller legado, e ele passou a chamá-la de lá

Até aqui a montagem era `ISCadastro/Controller/AgendasController::getAgenda()`,
um método **privado**: só a própria tela alcançava. Copiar criaria a segunda
verdade sobre a agenda — duas montagens com as mesmas quatro peças, divergindo
no primeiro conserto que alguém fizesse num lado só.

A receita virou `GradeDaAgendaService`, e o controller legado passa a chamá-la.
**Provado byte a byte**: A/B com `git stash`, os dois lados com 297.746 bytes e
o mesmo sha1 `0b656ca7`.

O serviço **não lê sessão**. Quem decide se a pessoa vê a grade inteira ou só a
própria coluna é o chamador, por `$colaboradorId` — é pergunta sobre a sessão,
não sobre a agenda, e serviço que lê sessão só se exercita por HTTP.

### O vocabulário de estado é o da API, e não o da tela

| a API diz | a tela desenha |
|---|---|
| `livre` | `livre` |
| `marcado` | `agendado` |
| `em-atendimento` | `checkin` |
| `atendido` | `checkout` |
| `bloqueado` | `fechada` |

Os da esquerda são os de `AgendamentoJson`, congelados por `AgendaApiTest`.
Alinhar o servidor à tela criaria um segundo vocabulário dentro do mesmo
servidor. A tradução custa cinco linhas em `frontend/src/api/agendas.ts` — é o
lado barato de pagar. `livre` é o único que só existe aqui: horário livre não é
linha em `agendas`, é o buraco entre elas.

### Três decisões que o corpo carrega

**`balcao` é a ausência de origem.** A coluna tem `web` (2.998.076), `app`
(1.000.580) e string vazia (1.428) — nenhuma linha com `balcao`. A tela de hoje
trata o resto como balcão por exclusão, num `else`; aqui a regra sai explícita.

**A cor do colaborador viaja na grade.** O D168 deixou as duas listagens de
usuário sem `cor`; a única que a publica é `completo()`, por id. Sem o par na
coluna, desenhar o cabeçalho de 16 colaboradores custaria 16 idas ao servidor
para buscar duas cores. Já a cor da CÉLULA não vem pronta: saem os quatro fatos
que a decidem (`estado`, `primeiraVisita`, `origem`), e a paleta é de quem
desenha.

**O motivo da coluna vazia é código, não frase.** `ferias` e `agenda-fechada`,
com `feriasAte` em ISO à parte — as mesmas constantes de `HorariosLivresJson`. A
frase ("De férias até 04/01/2026") nasce no front, em `falhaDaColuna`.

### O recorte de privacidade herda a assimetria da tela de hoje

Quem tem `agendas/gerenciar/escrever` vê nome completo e **telefone**, e não
sofre o ajuste `agenda_primeiro_nome`; quem só lê sofre o ajuste e nunca vê
telefone. É o que `tabela.phtml:43` faz contra o `:62`.

O recurso **reproduz** a assimetria e não a corrige: corrigir aqui mudaria, em
silêncio, quem vê o telefone de quem. O que ele garante é nunca publicar mais do
que a tela de hoje publica.

### O que foi medido, em 16/09/2026

Contra a cópia anonimizada, unidade 1:

| | |
|---|---|
| `?data=2026-06-04` | 200 · 14 colunas · 313 horários |
| estados exercitados | os cinco, em três datas |
| origens exercitadas | `web`, `app` e `balcao` |
| colunas sem horário | `agenda-fechada` e `ferias`, com `feriasAte` em ISO |
| ordem | as fechadas vão para o fim; as de férias ficam no lugar |
| `?data=2026-02-30` | 422 |
| sem sessão | **401** — era 403 até o D191, em 21/09/2026 |
| **POST na rota** | **404** — o verbo declarado na rota guarda |

E no navegador, em `localhost:5173`: 14 colunas, 299 horários, "Agenda de
16/09/2026", a cor de cada colaborador no cabeçalho e "Agenda fechada" na coluna
que não abriu.

**E levou 22 segundos.** Não é da fatia nova — o formatador custa 0,001s. Está
tudo em `selecionarPorColaboradorData`.

**Medido em 21/09/2026, e o diagnóstico desta linha estava pela metade.** A causa
é `DATE(age.data)` em volta da coluna, mas a fatura é outra: sem índice que
contenha `data`, o plano lê as **241.218 linhas** que os 14 colaboradores têm na
tabela para devolver no máximo 189. E os 22 segundos são deste ambiente — o
buffer pool aqui é o padrão de fábrica de 128 MB contra 546 MB de tabela; com
2 GB, o mesmo código custa 0,90 s. Ver **D189**, que tem a matriz inteira, o
contrafactual do pushdown e a medição no PostgreSQL.

---

## A remarcação refeita — 23/09/2026

A tela nova ganhou "Remarcar", e antes de ligar o botão o endereço foi lido de
novo. Ele nunca tinha rodado por HTTP — nenhum ensaio o chamava —, e a lista do
que ele deixava passar virou o **D216**: horário fechado, fora da grade, férias,
hora malformada, hora repetida, a família de tamanho diferente do serviço, e a
chegada perdida de quem já estava na cadeira.

### A ordem virou apagar, conferir, marcar

`ISCadastro\Service\RemarcacaoDeAgendamento` faz as três coisas numa transação:
trava a raiz da família e o colaborador de destino, apaga a família velha,
confere o destino, e só então grava a nova. A família velha fora da tabela é o
que deixa o serviço de dois horários andar um horário sobre si mesmo — o caso
que a ordem antiga recusava como "ocupado". E se a conferência recusa, o
rollback devolve a família e as linhas que o serviço gravou no arquivo de
exclusões.

O que só a API exige mora em `ISCadastro\Regra\RegraDaRemarcacao`, sem banco nem
sessão. `AgendaService` e `RegraDeAgendamento` não mudaram: são da tela antiga e
do contrato congelado do aplicativo.

### As respostas

| o que houve | HTTP | `codigo` |
|---|---|---|
| sem sessão / sem `agendas/gerenciar/escrever` | 401 / 403 | `nao_autenticado` / `sem_permissao` |
| agendamento que não existe, de outra unidade, ou linha de agenda fechada | 404 | `nao_encontrado` |
| colaborador de destino fora da unidade, inativo ou fora da agenda | 404 | `nao_encontrado` |
| data, hora ou observação malformada | 422 | `validacao`, com `data`, `horas` ou `observacao` |
| quem já chegou ou saiu; família de tamanho diferente do serviço; agendamento sem serviço | 409 | `conflito`, sem `detalhes` |
| destino ocupado, fechado, fora da grade, férias, dia sem agenda | 422 | `validacao`, com o código do motivo em `campos` |
| o mesmo lugar | 200 | nada é gravado |
| remarcado | 200 | `{ agendamentos, removidos }` — os ids mudam |

### A tela

O painel do horário ganhou "Remarcar", ao lado de "Registrar chegada". Ele
aparece onde a chegada apareceria — permissão, agendamento de verdade, ainda
"agendado" — e só se a família inteira estiver agendada; e as duas dividem a
trava: uma ação no ar trava as outras, porque a chegada registrada no meio de
uma remarcação cairia em linhas que o servidor está apagando. A trava é da aba;
entre caixas, quem trava é o servidor (D219, e "A trava das escritas", abaixo). A janela é de teclado primeiro — duas listas nativas, o
colaborador e o início —, porque o arrastar do desenho não tem teclado nem
leitor de tela. Ela só oferece os inícios em que a família inteira cabe, no
passo do colaborador de destino, e diz quando a duração muda de minutos.

Remarcado, a janela e o painel fecham, o aviso diz para onde o agendamento foi,
e o foco segue o bloco novo quando a grade recarregada chega — a não ser que
quem usa já tenha ido para outro lugar. Sem o bloco na tela, ou com a recarga
falhando, o foco vai ao título do dia.

**A tela não sabia quantos horários o agendamento tem.** A grade não desenha
horário fora do passo de hoje, e a família que tem um horário assim parecia
menor: o pedido levava horários a menos, e o servidor recusava com `periodos`.
Desde 24/09/2026 a grade publica as horas da família inteira em cada horário
dela (`familia`, D216), e a janela e o arrasto as levam.

**Nem se a família ainda se movia.** O "só se a família inteira estiver
agendada" olhava os horários desenhados; a chegada registrada num horário fora
do passo de hoje não aparecia, o botão era oferecido e o servidor recusava com
409. Desde 05/10/2026 a grade publica também `remarcavel` em cada horário de
agendamento — nenhum horário da família, desenhado ou não, chegou nem saiu, a
mesma pergunta do servidor (`RegraDaRemarcacao::oQueOAtendimentoImpede`) —, e o
Remarcar, o Desmarcar e o arrasto obedecem a ela (`seMove`). É só a pergunta do
atendimento: a família de tamanho diferente do que o serviço pede hoje sai
`remarcavel` (ela se desmarca), e o remarcar dela segue recusado com 409, com a
frase na janela.

### O que ficou para o dono

Remarcar para outro dia, gravar ao soltar o bloco arrastado, o passado, a
origem que volta a "web", a
lista de serviços do colaborador de destino, a comanda aberta (D221) e o
registro de remarcação no arquivo (D218) — as perguntas estão no roadmap de
23/09/2026.

### A trava das escritas — 24/09/2026

A remarcação foi a primeira a travar, e a revisão adversarial mostrou quanto
isso valia sozinho: nada contra as outras escritas da própria API. O check-in,
o desmarcar e o marcar esperavam a trava soltar e gravavam o que tinham lido
antes da espera (D219). Agora as quatro pegam a mesma trava — `TravaDaAgenda` —
e só leem e conferem depois dela (`EscritasDaAgenda`):

| escrita | trava | quando outro pedido chegou antes |
|---|---|---|
| marcar | o colaborador | confere o horário depois dele: recusa se ficou ocupado |
| check-in | a raiz do agendamento | 404 se ele foi apagado; só registra quem ainda não chegou |
| desmarcar | a raiz do agendamento | 404 se ele foi apagado; nada se arquiva de novo; 409 se alguém chegou (desde 24/09) |
| remarcar | a raiz, depois o colaborador | 404 se ele foi apagado; 409 se alguém chegou |

O check-in também deixou de desfazer chegada: o serviço alterna cada horário
que recebe, e a API mandava a família inteira. E a tela trata o 404 da chegada
como o da remarcação — fecha o painel, relê a grade e avisa. A tela antiga e o
aplicativo continuam gravando sem trava nenhuma.

### O arrastar — 24/09/2026

O arrastar é **atalho para a janela**, e não um segundo caminho até o servidor:
soltar chama `abrirRemarcacao` com o colaborador e o início escolhidos, e daí em
diante é o caminho do botão — a mesma trava por raiz, os mesmos destinos
(`destinosPara`, que a janela e o arrasto dividem), o mesmo pedido. Gravar ao
soltar é a decisão 7 do roadmap.

| peça | onde | o que garante |
|---|---|---|
| o gesto | `AgendaDeHoje` | só mouse, só o botão principal, depois de 6px; ouvintes no documento, que nascem e morrem com ele |
| o alvo | `trilhaNoPonto` | o que está VISÍVEL sob o mouse (`elementFromPoint`): cabeçalho preso, régua, nome fixo e o que a rolagem escondeu não são trilha |
| o minuto | `minutoNaTrilha` | a trilha diz de que minuto a que minuto desenha (`data-inicio`, `data-fim`, `data-deitado`), e a caixa dela dá a fração |
| o encaixe | `lerAlvo` | o início mais perto entre os horários da coluna; o próprio lugar é clique que tremeu |
| a sombra | `AlvoNaTrilha` | o período escrito, e a duração quando muda; "não cabe" onde não cabe — preenchimento e texto, não só cor |
| o fim | `aoSubir` | o alvo é relido onde o botão sobe; o clique que vem junto não abre o painel; o bloco de volta ao próprio lugar abre o painel dali, porque o clique do navegador cairia na trilha |
| o que se arrasta | `AgendaLigada` | permissão, família inteira agendada, nenhuma ação dela no ar, nenhuma janela aberta |

O gesto desiste com Esc, com o sistema cancelando o ponteiro, com a janela
perdendo o foco, com a tela mudando no meio (o modo compacto) e com o botão que
subiu sem o `pointerup` chegar. Enquanto se arrasta, os blocos deixam de receber
o mouse: o `:hover` acenderia o horário sob o ponteiro, e o `elementFromPoint`
passa direto por eles.

A recusa da remarcação (409 ou 422) relê a grade — pelos dois caminhos.

### O desmarcar — 24/09/2026

O `DELETE` existia desde 16/09, e nenhuma tela o chamava. O painel ganhou
"Desmarcar" ao lado de "Remarcar", com a mesma regra — permissão, e a família
inteira ainda marcada —, e uma pergunta antes, porque desmarcar apaga a linha.

| peça | onde | o que garante |
|---|---|---|
| a pergunta | `DesmarcarAgendamento` | quem, o quê, com quem e das quantas às quantas — a família inteira (`horasDoAgendamento`, D216), com a volta da meia-noite; o foco abre no "voltar"; o botão que apaga é o `.destrutiva` |
| o pedido | `enviarDesmarcacao` | pela raiz; a mesma trava por agendamento da chegada e da remarcação (`comecar`/`terminar`); no ar, a janela não fecha |
| a resposta | idem | 200: janela e painel fecham, aviso, grade relida, foco no horário livre; 404: janela e painel fecham, aviso, grade relida, foco na data; 409: a janela fica com a frase, e a grade é relida; o resto: a janela fica |
| o servidor | `EscritasDaAgenda::desmarcar` | relê a família depois da trava e recusa (409) quem já chegou ou já foi atendido — o atendimento finalizado primeiro, com a frase do fim |

**O 409 é novo, e veio da revisão adversarial.** A tela confere a chegada na
grade que tem, e a grade pode ser de um minuto atrás: outro caixa registrava a
chegada com a pergunta aberta, e o Desmarcar apagava o cliente que estava na
cadeira, com a chegada junto. A remarcação já recusava isso no servidor; o
desmarcar, não. A regra entrou em `EscritasDaAgenda`, e não no `AgendaService`:
a tela antiga e o contrato congelado do aplicativo (D5) continuam desmarcando
quem chegou. Se deve ser assim é a decisão 13 do roadmap.

### O marcar confere o destino — 24/09/2026 (D224, primeira parte)

`EscritasDaAgenda::marcar` passou a chamar `RegraDaRemarcacao::conferirDestino`
depois da trava do colaborador e ANTES da `RegraDeAgendamento`: férias, dia sem
agenda, horário fora da grade, fechado ou tomado por linha sem cliente recusam
sozinhos, e a regra antiga — que lê cada hora com `new DateTime` — só vê horário
da grade. O dia é lido uma vez (`FamiliaRecemGravada::linhasDoDia`), e as mesmas
linhas separam depois a família nova (`idsDe`).

| peça | formato do dia |
|---|---|
| `GradeDaAgenda::getTempoAtendimento`, `Usuario::getFerias`, `getAgendaAberta` | `dd-mm-aaaa` |
| `AgendaRepository::selecionarPorColaboradorData` | `aaaa-mm-dd` |

Trocar um pelo outro não dá erro: o `diasemana` lê outro dia da semana.

### O marcar confere o pedido — 25/09/2026 (D224, segunda parte)

O controller confere o corpo inteiro ANTES da trava, e devolve todos os campos
recusados de uma vez (422, `erroValidacao`):

| peça | confere |
|---|---|
| `AgendamentosController::estaNaAgenda` | o colaborador desta unidade, ativo e na agenda — o mesmo filtro da lista da tela antiga; senão, 404 |
| `RegraDaRemarcacao::conferirPedido` | as horas numa lista em HH:MM, em ordem e sem repetir; a observação até 255 caracteres e sem emoji (`agendas.observacao` é `utf8mb3`) |
| `RegraDaMarcacao::conferir` | o cliente existe; o serviço existe, é desta unidade, é do tipo `ser` e está ativo |
| `EscritasDaAgenda::marcar` e `RemarcacaoDeAgendamento` | sem tempo de atendimento, o dia não tem agenda: 422 `agenda-fechada`, antes da `RegraDeAgendamento` montar o `DateInterval` |

A `RegraDaMarcacao` não lê banco, como a da remarcação: o controller busca o
cliente e o serviço e passa o que achou. O id chega como número do JSON ou como
texto do formulário, e só o inteiro positivo passa (`idPositivo`).

### A lista do que se marca -- 25/09/2026 (o Marcar, etapa B)

`GET /adm/api/agendas/servicos?colaborador=<id>` devolve o que se marca na
agenda daquele colaborador: `{servicos: [{id, nome, periodos, valor}]}`, na
ordem do nome.

| peça | o que garante |
|---|---|
| a guarda | `agendas/gerenciar/escrever`, a de marcar: a lista só serve a quem marca, e a ação antiga que a entregava (`horariosDisponiveis`) é guardada do mesmo jeito |
| o colaborador | `RegraDaMarcacao::colaboradorRecebe` -- desta unidade, ativo e na agenda --, a mesma regra do marcar, que saiu do `AgendamentosController` para os dois perguntarem igual |
| a lista | `ProdutoRepository::popularComboboxServicos(unidade, colaborador)`, a do formulário antigo: serviços ativos da unidade, com área, estreitados pela lista de serviços do colaborador ou pelas áreas dele |
| a forma | `ServicosDaAgendaJson`: dinheiro em texto com duas casas; `periodos` é quantas horas o pedido de marcar traz; serviço sem período sai |

**A lista estreita, e o servidor não recusa.** Se o colaborador tem de fazer o
serviço para recebê-lo é a decisão 2 do roadmap: a tela oferece a lista dele, e
o `POST` aceita outro serviço da unidade, como a tela antiga.

**As fixtures medem o estreitamento sem montar nada:** o colaborador 90001000
só tem a área 3 e vê só a "Barba Teste"; o 90001003 não tem área e vê as duas,
por nome. Sem o estreitamento, os dois veriam as duas -- é o que o caso 82 do
ensaio e o `ServicosDaAgendaTest` pegam.

### O Marcar na tela -- 25/09/2026 (etapa C)

O horário livre abre o Marcar direto, para quem tem o bit das ações e com a
grade pronta; quem só lê continua vendo o painel "Horário livre.". O painel de
um horário livre também ganhou o botão "Marcar".

| peça | onde | o que garante |
|---|---|---|
| a janela | `MarcarAgendamento` | cliente, serviço, início e observação, na ordem do balcão; nada vem escolhido sozinho (o serviço e o início começam vazios); o Enter na busca busca, e não marca; o foco segue a escolha -- escolhido o cliente, vai ao serviço, e "trocar" o devolve à busca |
| a lista | `abrirMarcacao` | `GET /agendas/servicos` do colaborador; a resposta que chega depois de a janela mudar não entra nela (`vez`); a recusa vira frase na janela |
| a busca | `buscarCliente` | `GET /clientes?filtro=`; o telefone com máscara vai só com os dígitos (`termoDaBusca`), porque as tags o guardam assim; menos de duas letras não vai ao servidor; o cadastro-sentinela fica fora da lista e tem botão próprio ("cliente sem cadastro", id 2); ninguém achado leva ao cadastro do ERP, em outra aba |
| o início | `escolherServico` | o horário clicado vem escolhido se o serviço inteiro cabe nele; um início já escolhido fica, e a janela diz quando o serviço novo não cabe mais ali |
| o pedido | `enviarMarcacao` | as horas do serviço no passo da agenda de quem atende, a partir do início; a grade de agora confere o início antes de enviar |
| a resposta | idem | 201: a janela fecha, o aviso diz o que foi marcado, a grade é relida e o foco vai ao bloco novo; 404: a janela fecha, e o aviso e a recarga vêm do mesmo jeito; 409 e 422: a janela fica com a frase, e a grade é relida; o resto: a janela fica |
| a grade velha | `recemMarcado` | entre o 201 e a recarga, o horário marcado segue desenhado livre; clicá-lo não abre outro Marcar |

**O "sem cadastro" é um cliente, e não a falta dele.** O servidor aceita o id 2
(`ClienteJson::SENTINELAS`, 12,9% das marcações do balcão), e o aviso diz
"Agendamento sem cadastro marcado…", e não o nome da sentinela.

**A origem é "web", como na tela antiga.** O `POST` grava o que o
`AgendaService` sempre gravou, e a grade diz "marcado pela web" sobre o que o
balcão acabou de marcar. "web" é o ERP (3 milhões de linhas), e não o site; se a
legenda deve dizer outra coisa é pergunta, e está no roadmap.

**O que não entrou (C2):** encadear serviços para o mesmo cliente, que o balcão
faz em cerca de 15% das marcações, e o aviso de cadastro desatualizado que a
tela antiga dá ao escolher o cliente.

**A busca de cliente tem porta própria, e a da agenda não é nenhuma delas.** A
API abre a busca a quem cuida do cadastro (`clientes/cadastro/ler`) ou abre
comanda (`vendas/comandasNova/ler`); quem só marca na agenda recebe 403, e a
janela o manda ao "sem cadastro". Medido na cópia: dos 180 que marcam (sem
fixtures e sem administradores), 9 não têm nenhuma das duas portas, 7 deles
ativos nos últimos 90 dias. É o D226, corrigido no mesmo dia: a agenda
(`agendas/gerenciar/escrever`) virou a terceira porta, na forma mínima da do PDV
-- o nome, dos ativos, com termo e só a primeira página --, e a tela antiga
voltou a buscar pelo `autoComplite` (`scripts/ensaiar-busca-de-cliente-da-agenda.php`).

### A revisão adversarial do Marcar -- 25/09/2026

Quatro lentes (corridas, contrato com o servidor, teclado e leitor de tela,
testes), cada achado atacado por um cético: 19 achados, 17 confirmados, e o
contrato com o servidor sem nenhum. O que mudou na tela:

| achado | o que acontecia | agora |
|---|---|---|
| a busca sob o termo editado | a lista e o "nenhum cliente com …" ficavam sob o texto já corrigido, que nunca foi ao servidor -- e o caminho oferecido era cadastrar de novo quem existe | editar o termo desfaz a busca, e a resposta que ainda estava no ar deixa de valer |
| o recém-marcado só na hora clicada | clicado o 09:00 com o 09:30 recém-marcado, um serviço de dois horários vinha pré-escolhido às 09:00, e o pedido voltava 422 depois de preenchido | as horas recém-marcadas contam como tomadas nos inícios (`semORecemMarcado`) até a grade relida chegar; e, se a recarga falhou, o aviso diz "tente de novo", e não "está sendo relida" |
| a recusa do painel apagada | abrir o Marcar pela grade apagava a recusa do painel de OUTRO agendamento, que continuava aberto | só o botão do próprio painel a apaga |
| o foco do painel esquecido | o 201 esquecia quem abriu o painel mesmo quando ele era de outro horário e ficava na tela: o × mandava o foco ao título do dia | só o painel que fecha esquece |
| a busca calada | o leitor de tela não dizia nada depois do Enter: a lista, o "Mostrando" e o "nenhum" nasciam sem região viva | uma região `role=status` que mora na janela diz quantos vieram, ou que nenhum veio |

E nove lacunas de teste, fechadas com casos: o início trocado na lista (e não o
clicado) é o que vai; a trava do recém-marcado vale para as duas horas do
serviço e não para a coluna vizinha, e sai com a grade nova; a janela fechada
pelo navegador com o 201 ou a recusa no ar; o 403 da busca; o botão Marcar do
painel e o painel agendado sem ele. Dez mutações nos consertos, todas
vermelhas.

Os dois refutados: o foco ao desistir com o bloco trocado na recarga vai ao
título do dia -- o destino de reserva que o Remarcar e o Desmarcar também usam,
melhoria possível e não saída errada --; e o link do cadastro sem "abre em outra
aba" no nome acessível, que o parágrafo dele já diz (2.4.4, o propósito do link
no contexto).

### Marcar e continuar -- 26/09/2026 (etapa C2, primeira parte)

O balcão encadeia serviços para o mesmo cliente em cerca de 15% das marcações.
Na tela antiga, marcado um serviço, o modal fica aberto com o cliente, o início
passa à hora seguinte ao serviço (`agendar()` devolve o `proximo`) e o serviço
volta vazio com o foco nele.

Na tela nova, o "Marcar" continua fechando -- quem marca um serviço só não
paga clique nenhum a mais -- e o **"Marcar e continuar"** faz o que a tela
antiga faz: marca, fica com o cliente, zera o serviço e a observação, põe o
início na hora seguinte ao ÚLTIMO horário do serviço marcado (pré-escolhida se
o próximo serviço couber nela) e leva o foco ao serviço. A confirmação é dita
DENTRO da janela, numa região `role=status`: o aviso da página fica inerte por
trás da janela modal.

### O cadastro desatualizado -- 26/09/2026 (etapa C2, segunda parte)

A tela antiga, ao escolher o cliente no Marcar, abre o cadastro por cima quando
ele está desatualizado: nunca alterado, ou alterado há tantos dias quantos a
unidade pede (`unidades.clientes_atualizar`). **29 das 36 unidades ativas pedem**
(365 ou 730 dias), e **89% dos clientes ativos** (212.068 de 239.239) passam de
um ano sem alteração -- o modal abre em quase toda marcação.

| peça | onde | o que garante |
|---|---|---|
| a regra | `ClienteJson::desatualizado` | a do `autoComplite`: nunca alterado, ou `dias >= prazo`; sem prazo, só o "nunca"; sem a coluna na linha, nulo -- e não "desatualizado" |
| a resposta | `GET /adm/api/clientes` | `cadastroDesatualizado` nas duas formas, também na mínima (um sim ou não, e não dado da pessoa; o PDV velho já o recebia) |
| o prazo | `Adm/ClientesController` | o da unidade da sessão, como a tela antiga lê |
| a janela | `MarcarAgendamento` | um aviso abaixo do cliente, que DESCREVE o campo do serviço (`aria-describedby`) -- é lá que o foco chega depois da escolha |
| o fio | `ensaiar-busca-de-cliente-da-agenda.php`, fase `prazo` | a coluna na consulta e o prazo no controller, por HTTP: as fixtures não têm data nem prazo, e a fase grava os dois e os devolve no `finally` |

**Dito, e não imposto.** A marcação segue: a tela nova não abre o cadastro por
cima do Marcar. Se o aviso deve travar a marcação até a confirmação -- como a
tela antiga, que abre o modal --, é decisão sua (roadmap).

### Reabrir pela agenda nova -- 26/09/2026

A tela nova fechava a agenda e não reabria: o horário fechado mostrava o motivo,
e só. Agora o painel dele oferece **Reabrir** a quem tem o bit das ações.

**Reabrir apaga o fechamento INTEIRO**, e não um horário: um "Férias" de duas
semanas, de todos os colaboradores, volta a ficar livre com um clique num
horário só. A tela antiga consulta antes (o `abrirAction` legado com `acao:
pesquisar`) e pergunta "Deseja abrir a agenda no período X a Y?". A grade só
traz o id e o motivo de cada horário fechado -- então nasceu o GET.

| peça | onde | o que garante |
|---|---|---|
| o retrato | `FechamentoJson::retrato` | id, motivo, início e fim (ATOM), e o colaborador -- nulo é "todos" (o serviço só grava colaborador quando o pedido escolhe um); o mesmo no GET e no `reaberto` do DELETE |
| a consulta | `GET /adm/api/agendas/fechamentos/:id` | a mesma guarda do reabrir (`agendas/gerenciar/escrever`: a tela que chama, D138), e o fechamento de outra unidade é 404, e não 403 |
| a pergunta | `ReabrirFechamento` | o período e o alcance como o servidor os disse; enquanto consulta, "Consultando o fechamento…" e o Reabrir travado (`aria-disabled`); o foco abre no "voltar" |
| a resposta | `AgendaLigada` | 200: a pergunta e o painel fecham, o aviso diz o que reabriu, a grade relê, e o foco vai ao horário que ficou livre; 404 (na consulta ou no DELETE): fecha, avisa e relê; o resto: a pergunta fica, com a frase |

Os portões: a rota nova está na lista das que chegam sem identidade (a ação
recusa -- medido, 401 sem cookie), e a ação na lista das que TÊM de pedir a
guarda da agenda (`conferir-guarda-da-agenda.php`, de 5 para 6 ações). O ensaio
de permissão pede o GET e o DELETE com um id que não existe: se a guarda cair,
a resposta é 404, e nada é reaberto.
