# Documentação técnica

## Stack

Next.js 16 (App Router, TypeScript) · servidor HTTP customizado
(`server.ts`) com Socket.IO · PostgreSQL 16 + Prisma 6 · Auth.js v5
(Credentials, sessão JWT) · `node-cron` · `web-push` (VAPID) · Tailwind CSS
v4 · Recharts · Vitest · Docker Compose + Caddy.

## Duas telas por setor (parede vs. atendimento)

Cada setor tem **duas rotas distintas**, mesmo login, propósitos
diferentes:

- **`/dashboard/*`** — tela de parede, fica no monitor fixo em frente ao
  posto/setor. Só visualização (sem botões de ação), tipografia grande
  para leitura à distância, som/push tentam ligar sozinhos ao carregar
  (`PainelVisualizacao`).
- **`/atendimento/*`** — tela interativa, pensada para o celular da
  equipe. Tem os botões "Estamos a caminho"/"Finalizar
  atendimento"/"Cancelar" (`PainelSetor`). É para onde o login redireciona
  por padrão.
- **Supervisão** não tem essa divisão: `/dashboard/supervisor` já é a
  visão em colunas (uma por fila) com ação disponível
  (`PainelSupervisorColunas`); `/atendimento/supervisor` só redireciona
  para lá.

**IMPORTANTE — `middleware.ts` fica em `src/middleware.ts`, não na raiz.**
O projeto usa `src/app/`; o Next.js só reconhece o middleware dentro de
`src/` nesse caso. Colocá-lo na raiz faz o Next **ignorá-lo
silenciosamente** (sem erro, sem aviso) — foi exatamente isso que
aconteceu numa fase anterior desta reconstrução: o controle de acesso por
papel simplesmente não rodava, e só foi percebido testando de verdade
contra um Postgres real. Se algum dia notar que a proteção de rota parou
de funcionar, esse é o primeiro lugar a checar.

## Postos (Enfermagem, Copa e Rouparia — multi-tenant dentro do hospital)

O hospital pode ter vários postos físicos por setor (ex.: Enfermagem "Ala
A"/"Ala B", Copa "Ala A"...), cada um com login próprio e fila própria.
Enfermagem, Copa e Rouparia funcionam exatamente do mesmo jeito — não há
mais um caminho especial para nenhum dos três:

- `Posto`: cadastrado em `/admin/postos`, com campo `tipo`
  (`ENFERMARIA`/`COPA`/`ROUPARIA` — nunca `EMERGENCIA`, que sempre pega
  carona no posto de Enfermagem do quarto). Único por `(tipo, nome)`, então
  "Ala A" pode existir como posto de Enfermagem e, separadamente, como
  posto de Copa. `andar` (texto livre, opcional) só ajuda a localizar o
  posto no cadastro — não entra no roteamento de chamados.
- `Quarto`: tem **três** campos de posto independentes —
  `postoEnfermagemId`, `postoCopaId`, `postoRoupariaId` — cada um define
  para qual posto os chamados daquele setor vão. Um quarto pode mandar
  Enfermagem para um posto e Copa para outro. Tem também `ala` e `andar`
  (ambos texto livre, opcionais) — usados no cadastro pra filtro/
  localização, sem efeito no roteamento. Configurado em `/admin/quartos`
  (inclusive cadastro em lote — ver abaixo).
- `Usuario.postoId`: relevante para `papel = ENFERMARIA`/`COPA`/`ROUPARIA`
  — a qual posto (do tipo correspondente ao papel) aquele login pertence
  — configurado em `/admin/usuarios`, com validação de que o tipo do
  posto bate com o papel escolhido.
- `Chamado.postoId`: carimbado a partir do posto do quarto (do tipo do
  chamado) no momento da criação — não muda depois, mesmo que o quarto
  seja reatribuído.
- **Fila "sem posto":** um quarto sem posto atribuído num setor não fica
  invisível — o chamado aparece para **todos** os postos daquele setor (e
  para a supervisão, numa coluna própria com ⚠️, uma por setor). Ver
  `salaPosto`/`salaSemPosto`/`salaDoChamado` em
  `src/lib/realtime/events.ts` e `usuarioPodeAgirNoChamado` em
  `src/lib/chamados/acesso.ts`.
- **Cadastro em lote por faixa numérica** (`/admin/quartos`, seção
  colapsável): número inicial + número final geram a sequência inteira
  (ex. `101`→`110` cria os quartos `101`..`110`, com zero à esquerda
  preservado se o inicial tiver — `01`→`10` gera `01`..`10`). Prefixo
  opcional é pra enfermarias com vários leitos por quarto físico: quando
  preenchido, cada número vira `{prefixo} Leito {NN}` (ex. prefixo
  `E101` + `01`→`03` cria `E101 Leito 01`, `E101 Leito 02`, `E101 Leito
  03`) em vez do número puro. Cada quarto/leito criado tem QR Code e fila
  próprios. Não é um conceito novo no modelo de dados — cada um é só mais
  um `Quarto` (`gerarSequenciaNumerica` + `criarLeitosEmLote` em
  `src/lib/admin/quartos.ts`).
- **Filtro da tabela de quartos** (`/admin/quartos`): busca por número +
  selects de ala/andar/status, inteiramente client-side sobre a lista já
  carregada (`GerenciarQuartos.tsx`) — sem round-trip ao servidor, porque
  a lista completa de quartos já está na página. Existe pra não perder a
  visão geral conforme o hospital cadastra mais quartos.
- **"Visualizar como"** (`/admin/visualizar`, só Administração): abre
  qualquer tela do sistema (por posto, de qualquer setor, mais Supervisão
  e a tela pública de cada quarto) sem precisar logar com outra conta —
  útil pra conferir cadastro/funcionamento rapidamente.
- **Edição em linha**: postos, quartos, usuários e dispositivos têm um
  botão **Editar** por linha nas respectivas telas de admin, que troca a
  célula por um campo editável (nome do posto; número/ala do quarto;
  nome/login/**Permissão**/posto do usuário; descrição/quarto do
  dispositivo). No formulário de usuário, o rótulo "Papel" virou
  "Permissão" (mesmo campo `Usuario.papel` por baixo). Trocar a
  Permissão de um usuário reavalia o posto: se a nova permissão exige
  posto (`ENFERMARIA`/`COPA`/`ROUPARIA`) e nenhum foi escolhido junto,
  `atualizarUsuario` (`src/lib/admin/usuarios.ts`) rejeita com 400; se
  não exige, zera o `postoId` — ver `PAPEIS_COM_POSTO` no mesmo arquivo.
  Todos reaproveitam as mesmas funções de `atualizarX` já usadas pelos
  toggles de ativar/desativar, só que agora aceitando mais campos.
  **Cuidado:** trocar o `numero` de um `Quarto` muda a URL do QR Code
  (`/chamar/<numero>`) — qualquer cartaz já impresso com o número antigo
  para de funcionar.
- **Política de senha** (`src/lib/validation/senha.ts`, função
  `validarForcaSenha`): mínimo 12 caracteres, maiúscula, minúscula,
  número e caractere especial, sem o nome da pessoa, o setor (rubrica em
  português do `papel` — ex. "Enfermagem" pra `ENFERMARIA` — e o valor
  cru do enum, os dois checados, normalizado sem acento/caixa) nem
  "Unimed"/"Norte Fluminense". Módulo sem dependência de Prisma/bcrypt de
  propósito — é reaproveitado tanto no client component
  (`gerenciar-usuarios.tsx`, feedback ao digitar) quanto no service layer
  (`criarUsuario`/`atualizarUsuario` em `src/lib/admin/usuarios.ts`, que
  é quem efetivamente barra a gravação com `ErroChamado` 400 — a
  validação do client é só UX, nunca a única barreira). Os schemas Zod
  (`criarUsuarioSchema`/`atualizarUsuarioSchema`) só checam o piso de 12
  caracteres; a regra completa (com nome/papel) só roda no service layer,
  que tem esse contexto — na edição, usa o nome/papel *finais* (novos, se
  estiverem sendo trocados na mesma chamada; senão os já salvos no
  banco). O seed (`prisma/seed.ts`) grava usuários via
  `prisma.usuario.upsert` direto, sem passar por `criarUsuario` — por
  isso os logins de teste continuam com a senha simples `teste123` sem
  violar a política (ela só vale pra usuários criados/editados pela
  tela).
- **Largura das telas de admin** (`src/app/admin/layout.tsx`): o `<main>`
  compartilhado por todas as telas de `/admin/*` usa `max-w-6xl` (antes
  `max-w-4xl`) — com o rótulo virando "Permissão" e a coluna Ações tendo
  3 botões por linha, o `max-w-4xl` antigo forçava rolagem horizontal na
  tabela de usuários mesmo sobrando espaço lateral no monitor. As células
  da tabela (`gerenciar-usuarios.tsx`) usam `whitespace-nowrap` — não
  quebram linha nunca; se algum dia o conteúdo voltar a estourar
  `max-w-6xl` (nomes muito longos, tela pequena), o wrapper
  `overflow-x-auto` ainda cobre isso com rolagem, mas não deveria
  acontecer em uso normal.
- **Decodificação do número do quarto em `/chamar/[numeroQuarto]`**: a
  rota de QR Code (`/api/admin/quartos/[id]/qrcode`) monta a URL com o
  construtor `URL`, que percent-encoda espaço/acento no número (ex.:
  "E101 Leito 01" → `.../chamar/E101%20Leito%2001`). Com o `server.ts`
  customizado (`handle(req, res)` sem `parsedUrl`), esse segmento chega
  ao Server Component ainda codificado — sem `decodeURIComponent`
  explícito, quartos com espaço no número (ex.: leitos em lote) davam
  404 mesmo existindo e ativos no banco. `page.tsx` agora decodifica o
  parâmetro (com try/catch pra sequência `%` inválida) antes de buscar o
  `Quarto`.

## Papéis

`ENFERMARIA` (por posto), `COPA`, `ROUPARIA`, `SUPERVISOR`,
`ADMINISTRADOR`. Administração faz o cadastro (postos, usuários, quartos)
em `/admin/*` — não tem acesso aos dashboards operacionais, é um papel de
retaguarda separado, sem o bypass que `SUPERVISOR` tem sobre os outros
setores.

## Tela inicial, Central de Ajuda e rodapé

- `/` é a própria tela de login (não existe mais `/login` separado — a
  rota antiga redireciona pra `/`, pra não quebrar favoritos/links já
  distribuídos).
- A logo Unimed no `CabecalhoApp` (`src/components/layout/CabecalhoApp.tsx`)
  é o próprio botão de Início — envolvida num `Link` pra `/inicio`, sem
  ícone 🏠 separado (removido).
- Botão **❓ Ajuda** no canto superior direito de toda tela (login e
  telas internas) abre `/ajuda`: documentação organizada por público
  (Visão geral / Equipe / Gestão e conformidade / Comercial / Treinamento
  em vídeo / TI e desenvolvimento), cada item com link pra ler no
  navegador (`/ajuda/[slug]`) e um botão **Baixar**. Catálogo em
  `src/lib/docs/catalogo.ts` — é uma lista branca (nunca lê arquivo por
  nome vindo do usuário, evita path traversal).
- `DocItem.tipo` distingue três formatos: `"markdown"` (padrão) renderiza
  `docs/*.md` via `react-markdown`, lido em runtime por
  `src/lib/docs/ler.ts` e baixado via `/api/docs/[slug]`; `"pdf"` e
  `"video"` apontam `arquivo` direto pra um caminho público (ex.:
  `/comercial/apresentacao-sistema-chamados.pdf`,
  `/treinamento/enfermagem.mp4`) servido estaticamente de `public/` —
  sem passar pelo leitor de Markdown nem pela rota `/api/docs/[slug]`,
  que seguem só pra texto. Usar `public/` em vez de reinventar
  streaming binário aproveita o suporte nativo do Next.js a HTTP Range
  (necessário pra permitir avançar/retroceder nos vídeos).
  - **Comercial**: apresentação em PDF do sistema (`public/comercial/`),
    gerada como slide deck HTML/CSS renderizado via Puppeteer —
    processo não versionado, é conteúdo de marketing, não código.
  - **Treinamento em vídeo**: 6 vídeos curtos (`public/treinamento/`),
    um por área — Paciente, Login, Enfermagem, Copa/Rouparia,
    Supervisão e Administração — gravados com captura de tela real do
    sistema (Puppeteer + `puppeteer-screen-recorder` + `ffmpeg-static`),
    com legenda sobreposta no vídeo em vez de narração/áudio. Processo
    de gravação também não versionado — os `.mp4` finais é que importam.
- **Bug real de deploy pego em produção:** `src/lib/docs/ler.ts` lê os
  `.md` do disco em runtime (`path.join(process.cwd(), "docs")`), mas o
  `Dockerfile` só copiava `prisma/`, `public/`, `src/` etc. pro estágio
  final — nunca `docs/`. Funcionava perfeito em `npm run dev` (roda
  direto da pasta do projeto) e quebrava com 500 só no container Docker
  (visualizar e baixar documento). `Dockerfile` agora copia `docs/`
  também, junto com `prisma`/`public`. Lição: qualquer pasta lida do
  disco em runtime (não só em build) precisa aparecer explicitamente no
  estágio `runner` do Dockerfile — o multi-stage build não copia nada
  "de graça".
- Rodapé com copyright (`src/components/layout/Rodape.tsx`) em toda
  tela, inclusive impressão (`print:hidden` no cabeçalho/rodapé/nav do
  admin — ver seção de QR Code abaixo).

## Prioridade em 3 níveis (NORMAL / ALTA / EMERGENCIA)

Além do botão vermelho **EMERGÊNCIA**, a tela do quarto tem um botão
intermediário **⚠️ Urgente** (sempre `tipo=ENFERMARIA`,
`prioridade=ALTA`) entre os botões normais de setor e o de emergência.
Cor/som distintos por nível — ver `src/lib/chamados/prioridade.ts`
(`nivelPrioridade`, usado tanto pelo `ChamadoCard` quanto pelos padrões
de bipe em `useAlarmeSonoro`: 1 bipe grave = normal, 2 médios = alta, 3
agudos = emergência/escalado).

## Acuidade do paciente por quarto

Sinalizador simples (`Quarto.acuidade`: `ESTAVEL`/`ATENCAO`/`CRITICO`,
independente de haver chamado aberto) que a Enfermagem atualiza direto
na tela de atendimento (painel colapsável "🩹 Acuidade dos pacientes por
quarto"). Aparece como selo no `ChamadoCard` quando não é `ESTAVEL`.
API: `PATCH /api/quartos/[id]/acuidade`, mesma regra de acesso por
posto/escopo que os chamados (`usuarioPodeGerenciarQuarto` em
`src/lib/chamados/acesso.ts`).

## Checklist de rondas por quarto

Registro leve e **imutável** (só `INSERT`, mesmo padrão de
`EventoAuditoria` — `REVOKE UPDATE, DELETE` aplicado em
`RegistroRonda` também, ver migração `rondas`) de que a equipe passou
no quarto, independente de chamado. `Quarto.ultimaRondaEm` é o resumo
rápido pra colorir o painel (verde dentro do intervalo, amarelo/vermelho
conforme passa do intervalo aplicável). API: `POST /api/quartos/[id]/ronda`.
Painel reaproveita o mesmo padrão visual de cartão+borda
colorida+botão de ação da fila de chamados
(`src/components/dashboard/PainelRondas.tsx`).

Só entram no checklist os quartos com `statusOcupacao = OCUPADO` — sem
paciente, não tem o que rondar (filtro em
`src/app/atendimento/enfermaria/page.tsx`, sobre o mesmo array de
quartos já usado pelos outros painéis). Quarto Liberado/Alta hospitalar
some do checklist automaticamente até voltar a Ocupado.

**Configuração por posto (`RondaConfig` + `RondaFaixaHorario`)** — liga/
desliga a ronda e exige (ou não) leitura de QR Code, por posto de
Enfermagem; o intervalo entre rondas pode variar por faixa de horário
(ex.: 30min à noite, 60min de dia) porque uma única linha não dá conta
disso — cada `RondaConfig` tem uma lista de `RondaFaixaHorario`
(`horaInicio`/`horaFim` em minutos desde a meia-noite, com wraparound
pra faixas que cruzam a meia-noite). `postoId = null` é a config
**padrão global** — mesma pegadinha de índice único `NULLS NOT DISTINCT`
do `SlaConfig` (só uma linha global permitida). Resolução em
`src/lib/rondas/config.ts` (`resolverConfigRonda` + `resolverIntervaloMinutos`,
funções puras, sem Prisma — reaproveitadas tanto no server quanto no
client, que recalcula o intervalo aplicável a cada render). UI em
`/admin/rondas`, mesmo padrão "padrão global + linhas por posto" de
`/admin/sla`. `RONDA_INTERVALO_MINUTOS` (env var, padrão 60) só entra
como fallback quando nenhuma faixa cobre o horário atual.

**Validação por QR Code** (`RondaConfig.exigirQrCode`): reaproveita o
mesmo QR Code já impresso pro paciente (`/api/admin/quartos/[id]/qrcode`,
que codifica `/chamar/{numero}`) — a equipe escaneia esse código pra
provar que está fisicamente no quarto. Leitura via API nativa do
navegador `BarcodeDetector` (Chrome/Edge/Android — sem dependência nova;
ver `src/components/dashboard/ScannerQrCode.tsx`), com aviso + botão
"Continuar sem QR" quando o navegador não suporta, pra nunca bloquear o
registro de uma ronda de verdade por incompatibilidade de navegador. O
servidor **sempre revalida** o número decodificado contra o quarto de
verdade (`registrarRonda` em `src/lib/rondas/service.ts`) — nunca confia
numa flag vinda do cliente. `RegistroRonda.validadoPorQrCode` guarda se
aquela ronda específica foi confirmada por QR.

## Medicação agendada por quarto (alerta 5min antes)

Agenda recorrente **diária** por quarto — nome do medicamento + horário
do dia (`MedicacaoAgendada.horario`, minutos desde a meia-noite, sem
data: repete todo dia). Estado do ciclo atual (`status`: `AGENDADA` →
`PREPARANDO` → `EFETUADA`, com os botões "Preparando medicação" /
"Medicação administrada" na tela de atendimento — rótulo de UI só;
o enum interno continua `EFETUADA`, sem migração de dado nenhuma)
fica denormalizado na própria linha — como um quarto pode ter vários
medicamentos, esse estado não cabe em `Quarto` (diferente de
`ultimaRondaEm`). Cada transição gera uma linha imutável em
`RegistroMedicacao` (mesmo padrão de `RegistroRonda`), inclusive o
reset automático diário.

**Nº de registro do enfermeiro** (`RegistroMedicacao.
numeroRegistroEnfermeiro`, `String?`): capturado só na transição pra
`EFETUADA`, quando `MedicacaoConfig.numeroRegistroHabilitado` está
ligado pro posto — mesma semântica de `medicamentoHabilitado`: o toggle
só controla se o campo aparece no formulário (inline, antes de
confirmar "Medicação administrada" em `PainelMedicacao.tsx`), nunca é
exigido no servidor. Não é o login de quem agiu (isso já é
`usuarioId`/`efetuadoPorId`) — é um texto livre pro registro
profissional (ex.: COREN) de quem fisicamente administrou, útil porque
o login costuma ser por posto/turno, não por pessoa.

**Reset diário sem job à parte:** `cicloReferenciaEm` (meia-noite local
do dia a que o ciclo atual se refere) é carimbado tanto quando o alerta
dispara quanto quando a equipe age antes dele. O job de verificação
(`src/lib/medicacao/job.ts`, `verificarMedicacoes`, roda a cada
`MEDICACAO_INTERVALO_SEGUNDOS`, padrão 30s) reseta pra `AGENDADA`
qualquer linha cujo `cicloReferenciaEm` é de um dia anterior, na mesma
varredura que checa alertas — cobre tanto "foi efetuada ontem" quanto
"o aviso disparou ontem e ninguém agiu", sem precisar de um segundo job
rodando à meia-noite.

**Aviso "5 minutos antes":** candidatas são `ativo=true`,
`status=AGENDADA`, `alertaEnviadoEm=null` (guarda de idempotência **na
própria query**, não um `if` em JS — diferente do padrão de
`escalonamento/job.ts`) com o horário a ≤5min de distância (com
wraparound de virada de dia). Ao disparar: marca `alertaEnviadoEm`,
emite `medicacao:alerta` (mesmas salas de posto/Supervisão já usadas
pros eventos de chamado) e chama `notificarPapel("ENFERMARIA", ...)`
como reforço. `notificarPapel` (`src/lib/notificacoes/push.ts`) foi
generalizada nessa mudança — o payload deixou de exigir um `Chamado`
(`chamado: Pick<...>`) e passou a aceitar `dados?: Record<string,
string>` livre, pra caber tanto chamado quanto medicação.

**Tela de parede:** `AlertaMedicacaoBanner` (plugado em
`PainelVisualizacao`) recebe o estado inicial de alertas já ativos via
`listarAlertasMedicacaoAtivos` — sem isso, uma tela recarregada NO MEIO
de um alerta nunca saberia dele (o evento de socket só dispara uma vez,
no instante em que o job roda). Dali em diante, `medicacao:atualizada`
faz o banner sumir sozinho quando a medicação é preparada/efetuada/
resetada. `PainelMedicacao` (tela de atendimento) segue o mesmo
princípio pro destaque visual do card.

**Recorrência** (`TipoAgendamentoMedicacao`: `HORARIO_FIXO` |
`INTERVALO` | `UMA_VEZ`):
- `HORARIO_FIXO` — horário fixo diário original, com reset automático
  via `cicloReferenciaEm`.
- `INTERVALO` — repete "de N em N horas" (`intervaloHoras`, 1-24).
  Nesse modo não existe "dia" — o campo `horario` só é usado na
  criação, pra calcular a `proximaOcorrenciaEm` inicial (hoje nesse
  horário se ainda não passou, senão amanhã); dali em diante quem manda
  é `proximaOcorrenciaEm`, um timestamp absoluto avançado direto em
  `marcarEfetuada` (`agora + intervaloHoras`, reabrindo pra `AGENDADA`
  na hora, sem esperar o job).
- `UMA_VEZ` — administração única. Usa a mesma matemática de
  minuto-do-dia de `HORARIO_FIXO` pro aviso "5 minutos antes" (mesma
  consulta, `tipoAgendamento: { in: ["HORARIO_FIXO", "UMA_VEZ"] }`),
  mas ao ser efetuada é desativada (`ativo: false`, emitindo
  `medicacao:atualizada` como qualquer outra desativação) em vez de
  voltar pra `AGENDADA` — nunca entra na varredura de reset diário.

O job (`verificarMedicacoes`) faz três varreduras de alerta —
`HORARIO_FIXO`+`UMA_VEZ` por matemática de minuto-do-dia; `INTERVALO`
compara `proximaOcorrenciaEm - agora` direto — e a varredura de reset
diário (`cicloReferenciaEm`) filtra só `HORARIO_FIXO`, já que
`INTERVALO` e `UMA_VEZ` nunca "viram o dia".

**Configuração por posto** (`MedicacaoConfig`, `/admin/medicacao`) —
mesmo padrão exato de `RondaConfig` (padrão global + override por
posto, `NULLS NOT DISTINCT`): liga/desliga a Agenda de Medicação
inteira pro posto, e se o campo "Medicamento" **aparece** no formulário
de agendamento da Enfermagem (`medicamentoHabilitado`). O campo é
**sempre opcional** quando aparece — nunca obrigatório, em nenhum
posto; `medicamentoHabilitado` só controla visibilidade no formulário,
nunca é reforçado no servidor (diferente de `ativo`, que bloqueia
`criarMedicacaoAgendada` com 409). Desligado, a equipe agenda um
lembrete de horário sem remédio específico (`nomeMedicamento` é
`String?`) e o rótulo "Medicação sem nome" é usado tanto no card quanto
na notificação push (substituído centralmente em `paraDTO`,
`src/lib/medicacao/eventos.ts`).

## Status de ocupação do quarto

`Quarto.statusOcupacao` (enum `StatusOcupacaoQuarto`: `OCUPADO` |
`ALTA_HOSPITALAR` | `LIBERADO`, mesmo trio de campos
`*AtualizadoEm`/`*AtualizadoPorId` que `acuidade` já usa), alterado
pela Enfermagem em `/atendimento/enfermaria` (`PainelStatusQuarto.tsx`,
mesmo padrão de 3-botões-toggle de `PainelAcuidade`). Padrão
**`LIBERADO`** pra quarto novo (`@default(LIBERADO)`, migração
`quarto_default_liberado`) — o admin acabou de cadastrar, ainda não tem
ninguém ali. Quartos já existentes na migração **mantiveram o status
atual** (o `ALTER COLUMN ... SET DEFAULT` só afeta linhas novas, sem
`UPDATE` retroativo) — trocar em massa pra Liberado limparia a agenda de
medicação de quartos com paciente de verdade só por causa de uma
migração.

Marcar **Liberado** desativa (não apaga — `ativo: false`, preserva o
histórico em `RegistroMedicacao`) todas as `MedicacaoAgendada` ativas
daquele quarto, numa transação junto com a mudança de status
(`atualizarStatusOcupacao`, `src/lib/quartos/ocupacao.ts`) — o próximo
paciente não deve herdar a agenda do anterior. Emite
`medicacao:atualizada` com `ativo: false` por linha afetada, pra
qualquer `PainelMedicacao`/`AlertaMedicacaoBanner` aberto remover o
item na hora (não só marcar como "efetuada" — `AlertaMedicacaoDTO`
ganhou um campo `ativo` só pra essa distinção). A UI confirma com
`window.confirm` antes de mandar — é a única transição com efeito
colateral não óbvio só de olhar o botão.

## Visão por quarto (`/atendimento/enfermaria/quarto/[id]`)

Segunda forma de navegar pelos mesmos dados de acuidade/status/rondas/
medicação — em vez de 4 painéis "por tipo, todos os quartos", uma tela
própria por quarto com tudo daquele quarto junto. Duas peças:

- **`PainelQuartos.tsx`** (server-friendly, sem estado): lista de
  `<Link>`, um por quarto, mostrando só `quarto.numero` (que já inclui
  o leito quando for o caso, ex.: "E101 Leito 01" — não é reformatado,
  é o próprio valor salvo em `criarLeitosEmLote`). Cada link aponta pra
  `/atendimento/enfermaria/quarto/[id]`, preservando `?posto=` quando
  veio de "Visualizar como" (só pra ADMINISTRADOR).
- **`DetalhesQuarto.tsx`** (client, o grosso da lógica): renderizado
  pela página `src/app/atendimento/enfermaria/quarto/[id]/page.tsx`,
  que resolve o quarto com `obterQuartoVisivel` (mesma regra de acesso
  de `listarQuartosVisiveis` — nunca deixa abrir por URL um quarto de
  outro posto) e chama `notFound()` se não encontrar. Chama as
  **mesmas** rotas de API que `PainelAcuidade`/`PainelStatusQuarto`/
  `PainelRondas`/`PainelMedicacao` (`/api/quartos/[id]/acuidade`,
  `/status`, `/ronda`, `/api/quartos/[id]/medicacoes`,
  `/api/medicacoes/[id]/{preparando,efetuada}`) — os painéis por tipo
  continuam existindo em `/atendimento/enfermaria`; é só uma segunda UI
  sobre o mesmo estado do servidor, sem sincronização especial entre
  as duas.

Estado local do `DetalhesQuarto`: `quarto` (acuidade/status/
ultimaRondaEm, otimista com rollback em erro, mesmo padrão de
`PainelAcuidade`/`PainelStatusQuarto`) e `medicacoes` (com
`useMedicacaoSocket`, mesmo padrão de `PainelMedicacao` — filtra os
eventos por `quartoId` já que a página só se importa com um quarto). A
seção de rondas só aparece se `statusOcupacao === "OCUPADO"` (mesma
regra do painel de rondas por tipo), com a mesma cor por urgência
(verde/amarelo/vermelho, `nivelRonda`) de `PainelRondas.tsx`. A lista
de medicação é ordenada com `ordenarPorProximaAdministracao`
(`src/lib/medicacao/ordenacao.ts`, pura, com testes em
`tests/unit/medicacao.ordenacao.test.ts`) — item já `EFETUADA` no ciclo
vai pro fim (`Infinity`), os demais por horário absoluto da próxima
ocorrência (`HORARIO_FIXO`/`UMA_VEZ`: calculado a partir de `horario`,
mesmo cálculo de `calcularProximaOcorrenciaInicial` em
`medicacao/service.ts`; `INTERVALO`: direto de `proximaOcorrenciaEm`).
Reordena a cada render — qualquer mudança de estado (nova medicação
agendada, transição de status) já reflete na ordem sem lógica extra.

## SLA configurável por posto e por fase (atendimento/finalização)

`SlaConfig` é chave composta `(tipo, postoId, fase)`. `fase` (enum
`FaseSla`) separa dois limites **independentes** por tipo+posto:

- `ATENDIMENTO`: tempo até alguém dar ciência do chamado (`criadoEm` →
  `atendidoEm`) — o único que existia antes da fase de finalização.
- `FINALIZACAO`: tempo entre dar ciência e finalizar (`atendidoEm` →
  `finalizadoEm`) — novo, independente do de atendimento.

Uma linha com `postoId = null` é o **padrão global** daquele tipo+fase;
uma linha com posto definido é uma substituição só pra aquele posto
específico, numa fase de cada vez. Resolução em `resolverLimiteMinutos`
(`src/lib/chamados/sla.ts`, agora recebe `fase` como parâmetro): tenta o
posto primeiro, cai pro padrão global. UI em `/admin/sla`, com uma seção
"Padrão global" por fase e um seletor de Fase no formulário de
substituição por posto.

**Pegadinha de Postgres:** o índice único da chave composta precisou de
`NULLS NOT DISTINCT` (migrações `sla_por_posto` e
`sla_fase_finalizacao`) — sem isso, Postgres trata cada `NULL` como
distinto e permitiria duas linhas "padrão global" pro mesmo tipo+fase. E
o Prisma Client **não aceita `null` no `where` de uma busca por chave
composta** mesmo sendo opcional no schema — por isso
`definirLimiteSla`/o seed usam `findFirst` + `create`/`update` manual em
vez de `upsert` pro caso `postoId: null`.

## Cadeia de escalonamento (SLA de atendimento, dois elos) + aviso de finalização

SLA de **atendimento**, em dois elos:

1. **Enfermagem/Copa/Rouparia → Supervisor:** `ABERTO` que passa do
   limite de SLA (fase `ATENDIMENTO`) vira `ESCALADO`.
2. **Supervisor → Administração:** um `ESCALADO` que continua sem
   atendimento por mais um limite inteiro (2× o limite original) ganha
   `escaladoCriticoEm` e dispara push pro papel `ADMINISTRADOR` — cobre
   tanto "o supervisor viu e não deu conta" quanto "esse posto/setor não
   tem nenhum supervisor configurado" (sem isso esses casos ficavam sem
   ninguém de fato assistindo). Visão dedicada em `/admin/escalonamento`
   (todos os postos/setores, sem depender de escopo — Administração
   sempre tem acesso irrestrito).

SLA de **finalização** (independente, `src/lib/escalonamento/job.ts`):
um chamado `EM_ATENDIMENTO` que passa do limite da fase `FINALIZACAO`
ganha `finalizacaoEscaladaEm` e dispara um aviso **único** (som + push)
pro papel `SUPERVISOR` — decisão deliberada de **não** ter uma cadeia
crítica pra Administração aqui, diferente do SLA de atendimento. Visual:
selo âmbar "⏱️ Demorando para finalizar" no `ChamadoCard`. Evento de
auditoria: `FINALIZACAO_ESCALADA`.

## Relatórios por turno e carga por funcionário

Em `/dashboard/supervisor/relatorios`, além dos números por fila:

- **Por turno:** Manhã (06h–14h) / Tarde (14h–22h) / Noite (22h–06h),
  fuso fixo em UTC-3 (`OFFSET_HORAS_BRASIL` em
  `src/lib/relatorios/queries.ts` — projeto ainda não tem fuso
  configurável por hospital/usuário, decisão pragmática documentada
  ali).
- **Carga por funcionário:** quantos chamados cada um atendeu
  (`atendidoPorId`) no período e o tempo médio de atendimento
  (`atendidoEm` → `finalizadoEm`).

## Filtro de período compartilhado (`src/lib/relatorios/periodo.ts`)

`resolverPeriodo(preset, agora, personalizado?)` — puro, sem Prisma —
resolve um dos 11 presets (`24H`/`48H`/`72H`/`SEMANA_ATUAL`/
`SEMANA_PASSADA`/`15_DIAS`/`MES_ATUAL`/`MES_PASSADO`/
`ULTIMO_TRIMESTRE`/`ANO_ATUAL`/`ANO_PASSADO`) ou `PERSONALIZADO` (datas
informadas) pra um intervalo `{ desde, ate }` concreto. Rolantes (24h a
15 dias, "Último trimestre") contam N unidades pra trás de `agora`;
alinhados a calendário (semana/mês/ano) usam o mesmo offset fixo
`OFFSET_HORAS_BRASIL = -3` já documentado em `relatorios/queries.ts`
(duplicado localmente pra este módulo não depender de outro). **"Último
trimestre" = últimos 3 meses corridos** (dia 1º do mês de 3 meses atrás
até agora, mesmo estilo relativo de "Mês atual"/"Mês anterior") — não é
trimestre civil fixo (Jan-Mar/Abr-Jun/etc.). Semana começa segunda-feira.
`PERSONALIZADO` sem datas válidas (`desde > ate` ou ausentes) cai pro
padrão `MES_ATUAL` em vez de devolver um período inválido.

O componente `SeletorPeriodo` (`src/components/relatorios/SeletorPeriodo.tsx`,
client) é compartilhado entre "Relatórios e SLA" e "Taxa de ocupação de
quartos" (cada um com seu próprio estado de período — não é sincronizado
entre as duas telas). Visual inspirado no seletor de período do Zabbix
7: um botão colapsado (`🕐 <rótulo do período atual>`) que abre um painel
— campos **De**/**Até** personalizados de um lado, atalhos rápidos
agrupados em 3 colunas do outro (Recentes: 24h–72h/15 dias/último
trimestre; Período atual: esta semana/mês/ano; Período anterior:
semana/mês/ano anterior) — em vez da fileira fixa de 12 botões sempre
visível da versão anterior. Clicar num atalho aplica na hora e fecha o
painel; um botão "fantasma" cobrindo a tela inteira (`fixed inset-0`,
atrás do painel) fecha ao clicar fora, sem depender de biblioteca de
popover. Troca o preset via `router.replace` atualizando `?periodo=...`
(`&desde=&ate=` se personalizado), o que reexecuta o Server Component da
página com os dados já filtrados (sem fetch duplicado no cliente).
`periodoQuerySchema` (`src/lib/validation/schemas.ts`) valida os
`searchParams`; as páginas usam `.safeParse` (não `.parse`) — uma URL
editada à mão com um preset inválido cai pro padrão em vez de derrubar a
página.

## Taxa de ocupação de quartos

`Quarto.statusOcupacao` só guarda o estado **atual** — não dá pra saber
"quanto tempo cada quarto ficou ocupado" num período sem histórico. O
model `HistoricoOcupacaoQuarto` guarda isso como uma sequência de
**intervalos** (um por status): `inicioEm`/`fimEm` (null = intervalo
ainda aberto = é o status atual). `atualizarStatusOcupacao`
(`src/lib/quartos/ocupacao.ts`) fecha o intervalo aberto e abre um novo
a cada troca de status, dentro da mesma transação que já existia;
`criarQuarto`/`criarLeitosEmLote` (`src/lib/admin/quartos.ts`) já criam
o quarto com o intervalo aberto inicial (`LIBERADO`, mesmo padrão de
`Quarto.statusOcupacao`). Quartos que já existiam antes desta feature
foram backfilled em `prisma/seed.ts`
(idempotente): um intervalo aberto por quarto ativo sem histórico
nenhum, usando `statusOcupacaoAtualizadoEm ?? criadoEm` como início.
Sem `REVOKE UPDATE/DELETE` — histórico operacional, não trilha de
auditoria regulatória (mesmo tratamento de `RegistroMedicacao`/
`RegistroRonda`, diferente de `EventoAuditoria`).

`calcularTaxaOcupacao` (`src/lib/relatorios/ocupacao.ts`):
1. Busca os quartos ativos do escopo (filtra por `postoEnfermagemId` —
   ocupação é feature exclusiva de Enfermagem, mesmo critério de
   `PainelStatusQuarto`/`PainelAcuidade`).
2. Busca os intervalos que **sobrepõem** a janela filtrada
   (`inicioEm < ate AND (fimEm IS NULL OR fimEm > desde)`).
3. `somarDuracaoPorStatus` (pura, testada isoladamente) recorta cada
   intervalo pra dentro de uma janela `[inicio, fim]` e soma
   milissegundos por status — usada tanto pro total do período/por
   quarto quanto por bucket da série temporal.
4. **Série temporal com granularidade adaptativa**: ≤3 dias vira
   hora a hora, ≤~95 dias (15 dias/mês/trimestre) vira dia a dia, mais
   que isso (ano) vira semana a semana — mantém o gráfico legível em
   qualquer período. **Linha de tendência**: regressão linear simples
   (mínimos quadrados) sobre os pontos de % ocupado da série
   (`calcularTendencia`), sobreposta no mesmo `LineChart`
   (`GraficoOcupacao.tsx`) como uma segunda linha tracejada — a curva
   real usa `type="monotone"` ("curva de variação"), a tendência usa
   `type="linear"` reta.

UI: `CartoesOcupacao` (3 números — % Ocupado/Alta hospitalar/Liberado),
`GraficoOcupacao` (série + tendência) e `GraficoOcupacaoPorQuarto`
(barras por quarto), compostos em `RelatorioOcupacao`
(`src/components/relatorios/RelatorioOcupacao.tsx`) — tela própria,
separada de `RelatorioSla.tsx` (números de SLA/turno/carga/pendentes) a
pedido do usuário. As duas telas de destino
(`/dashboard/supervisor/relatorios/{sla,ocupacao}` e
`/admin/relatorios/{sla,ocupacao}`) são páginas finas que só resolvem o
período (`searchParams` → `resolverPeriodo`) e renderizam o componente
compartilhado correspondente com o `escopo` certo (supervisor restrito,
admin `undefined` — antes só alcançável de forma indireta via
"Visualizar como", agora tem aba própria no nav do admin). Um "hub"
(`.../relatorios/page.tsx`, mesmo estilo de cartão de `/inicio`) fica na
frente das duas: um botão por relatório, sem conteúdo combinado numa
página só.

## Áudio bidirecional paciente-posto (WebRTC)

Botão **📞 Falar com a Enfermagem** na tela do quarto, ao lado dos
botões de chamado normais — canal de voz direto, **não** cria um
`Chamado` (é uma conversa, não um ticket).

- **Sinalização** via o mesmo Socket.IO do resto do app
  (`EVENTOS_AUDIO` em `src/lib/webrtc/eventos.ts`): o servidor só
  repassa SDP/ICE (offer/answer/candidatos), nunca vê nem processa o
  áudio — depois de conectado, o áudio vai direto entre os dois
  navegadores (P2P).
- **Conexão anônima deliberada:** a tela do quarto não tem login, então
  `server.ts` aceita um handshake `{ tipo: "paciente", numeroQuarto }`
  sem JWT — mas valida o número contra um `Quarto` ativo de verdade
  antes de aceitar, e esse socket nunca entra em nenhuma sala de
  chamados (só serve pra sinalização de áudio daquele quarto).
- **Fluxo:** paciente cria oferta → servidor retransmite pra sala do
  posto do quarto (mesma sala que a Enfermagem já assina) + sala da
  Supervisão → primeiro da equipe que clicar "Atender" manda a resposta
  (relay direto por `socketId`, não broadcast) → troca de candidatos
  ICE → `RTCPeerConnection` conecta. Hooks:
  `useChamadaAudioPaciente`/`useChamadaAudioStaff`
  (`src/hooks/`), componentes `BotaoChamadaAudio`/`ChamadaAudioStaff`.
- **Só STUN, sem TURN** (`stun:stun.l.google.com:19302`) — uma chamada
  entre redes muito restritas (NAT simétrico, firewall corporativo
  agressivo) pode não conseguir conectar direto. Gap de infraestrutura
  conhecido, não só de código — adicionar TURN (ex.: coturn) é o
  próximo passo se isso virar problema real em produção.
- **Bug real pego testando de ponta a ponta:** a primeira versão do
  branch de conexão anônima em `server.ts` tinha um `return` antecipado
  que pulava o registro dos handlers de `EVENTOS_AUDIO` pro socket do
  paciente — a oferta nunca era retransmitida. Só apareceu testando com
  duas abas reais contra o servidor rodando; nem TypeScript nem lint
  pegam esse tipo de erro de fluxo de execução.

## Chat de texto paciente-equipe (por chamado)

Diferente do áudio (canal efêmero, sem ticket), o chat **é** vinculado a
um `Chamado` específico — abre junto com ele (Enfermagem, Copa, Rouparia
e também Emergência, que segue o mesmo caminho de código) e fecha quando
ele vira `FINALIZADO`/`CANCELADO`. Aparece no cartão verde de sucesso da
tela do quarto (`BotaoChamado.tsx`, junto com o protocolo) e no
`ChamadoCard` de cada painel de atendimento (botão **💬 Chat**, com
contador de não lidas — só destaque visual, sem som novo).

**Abertura automática do lado da equipe:** `useChatEquipe` só permite um
chamado "aberto" por vez (decisão de produto). Quando chega
`chat:nova_mensagem` de um `PACIENTE` e **nenhum** chat está aberto
ainda, o hook abre esse chamado sozinho (mesmo caminho de
`abrirChat`, via `abrirChatRef` pra não prender uma closure velha no
handler do socket montado uma vez só) em vez de só somar no contador
de não lidas — a equipe vê a primeira mensagem do paciente na hora,
sem precisar clicar em "Estamos a caminho" nem em "Chat". Se já tinha
outro chat aberto, o comportamento antigo (só contador) continua —
não rouba o foco de uma conversa em andamento.

**Volta automática pro início do lado do paciente:** `BotaoChamado.tsx`
recebe `chat:encerrado` (chamado finalizado/cancelado) via
`onEncerrado` em `useChamadoChatPaciente`/`ChatPaciente`, e agenda a
volta pra tela inicial do quarto (`VOLTAR_AO_INICIO_MS`, 5s — tempo
pra ler "concluído"/"conversa encerrada" antes de sumir sozinho, mesmo
valor de `useChamadaAudioPaciente`). Sem isso, o paciente ficava preso
na tela de sucesso até clicar manualmente em "Fazer outro chamado".

- **Modelo**: `MensagemChamado` (`chamadoId`, `autorTipo`
  `PACIENTE`/`EQUIPE`, `autorUsuarioId` nulo pro paciente,
  `texto`, `criadoEm`). Histórico imutável — `REVOKE UPDATE, DELETE`
  pra `chamados_app`, mesmo tratamento de `EventoAuditoria` (ver
  migração `*_mensagens_chamado`).
- **Realtime**: `EVENTOS_CHAT` (`src/lib/chat/eventos.ts`) numa sala
  exclusiva por chamado (`salaChatChamado`, `CHAT:<id>`) — distinta da
  sala do posto inteiro (`salaDoChamado`), pra um paciente nunca ver
  chamado de outro paciente. A equipe entra/sai dessa sala
  dinamicamente ao abrir/fechar o painel (`chat:entrar`/`chat:sair` em
  `server.ts`, com o mesmo `usuarioPodeAgirNoChamado` que já gate
  atender/finalizar/cancelar); o paciente entra direto no handshake do
  socket, que agora aceita um `chamadoId` opcional — validado contra o
  `numeroQuarto` antes de aceitar (não dá pra forjar o id de outro
  quarto e ler a conversa alheia).
- **Autorização em duas portas**: diferente das outras ações de
  chamado (que deixam a checagem só na rota de API), o envio de
  mensagem checa permissão dentro do próprio `src/lib/chat/service.ts`
  — porque tem duas portas de entrada (rota REST e o handler de socket
  `chat:entrar`), e centralizar evita duplicar a regra pela terceira
  vez.
- **Rotas**: `/api/chamados/[id]/mensagens` (equipe, sessão) e
  `/api/chamados/[id]/mensagens/paciente` (público, valida
  `numeroQuarto` — mesmo espírito do `POST /api/chamados` já público).
- **Fechamento**: `finalizarChamado`/`cancelarChamado`
  (`src/lib/chamados/service.ts`) chamam `emitirChatEncerrado` — só a
  sala do chat recebe esse evento (a equipe já sabe que fechou via
  `chamado:atualizado`/`chamado:cancelado`); é o único jeito do
  paciente saber, já que o socket dele nunca está na sala do posto.

## QR Code por quarto (visualizar/imprimir/salvar PDF)

Em `/admin/quartos`, botão **🔗 QR Code** por linha abre
`/admin/quartos/[id]/qrcode`: cartaz com o número do quarto e o QR Code
(`/api/admin/quartos/[id]/qrcode`, gerado com o pacote `qrcode`,
codifica `/chamar/<numero>` a partir do `origin` da própria requisição
— não depende de variável de ambiente de URL base). Botão **🖨️
Imprimir/Salvar PDF** chama `window.print()` — o "Salvar como PDF" vem
do próprio diálogo de impressão do navegador, sem biblioteca de PDF no
projeto. Cabeçalho/nav/rodapé do admin ficam `print:hidden`, então a
impressão mostra só o cartaz.

## Botão de emergência na tela do paciente

Além do botão/cordão IoT físico (`origem = BOTAO_FISICO`), a própria
página `/chamar/[numeroQuarto]` tem um botão vermelho "EMERGÊNCIA"
(`origem = QRCODE`, mesmo `tipo = EMERGENCIA`, mesma prioridade máxima e
mesmo roteamento ao posto do quarto). Ver `criarChamado` em
`src/lib/chamados/service.ts` — o parâmetro `tipo` aceita `EMERGENCIA`
tanto quanto os três setores normais.

## Cadastro de dispositivos de emergência

`/admin/dispositivos`: CRUD dos botões/cordões IoT (`Dispositivo`) — antes
só existiam via `prisma/seed.ts`, agora com tela própria. Ao criar um
dispositivo (vinculado a um quarto + descrição), a chave (`apiKey`) é
gerada aleatoriamente (`crypto.randomBytes`) e mostrada **uma única vez**
em um aviso destacado (com botão copiar) — só o hash (bcrypt) fica salvo,
igual ao padrão de senha de `Usuario`. Botão "Gerar nova chave" rotaciona
(mesmo aviso de "só aparece agora"); ativar/desativar sem apagar o
registro. Lógica em `src/lib/admin/dispositivos.ts`.

## Estrutura do projeto

```
server.ts                        bootstrap: HTTP + Next + Socket.IO + cron
src/middleware.ts                protege /dashboard, /atendimento, /admin por papel
prisma/schema.prisma              modelo de dados
prisma/migrations/                 histórico de migrações (incl. REVOKE de auditoria, postos, SLA por fase, mensagens de chat)
prisma/seed.ts                     dados de exemplo (postos de cada setor, quartos, usuários)
src/app/page.tsx                     tela inicial = login (raiz "/")
src/app/login                       redireciona pra "/" (compatibilidade)
src/app/ajuda/*                      Central de Ajuda (ver docs por navegador + download)
src/app/chamar/[numeroQuarto]      página pública do QR code + emergência + chamada de áudio
src/app/dashboard/*                  telas de parede (só visualização) por setor
src/app/atendimento/*                telas interativas (celular) por setor
src/app/dashboard/supervisor/relatorios  hub (2 botões) -> .../relatorios/sla e .../relatorios/ocupacao
src/app/admin/relatorios             mesmo hub acima, sem restrição de escopo (Administração)
src/app/admin/*                      cadastro + SLA + escalonamento (só ADMINISTRADOR)
src/app/admin/quartos/[id]/qrcode    cartaz de QR Code pra imprimir
src/app/admin/dispositivos           CRUD dos dispositivos IoT (chave one-time reveal)
src/app/admin/visualizar             "Visualizar como" — abre qualquer tela sem trocar de login
src/app/api/chamados/*               API REST (criar, listar, atender, finalizar, cancelar, emergência)
src/app/api/admin/*                  API REST de cadastro + SLA + QR Code + dispositivos
src/app/api/admin/quartos/lote       API REST de cadastro em lote de leitos
src/app/api/quartos/*                API REST de acuidade/ronda por quarto
src/app/api/supervisor/escopo        API REST de "Meus setores" (autoatendimento do supervisor)
src/app/api/relatorios               API de relatórios
src/app/api/push/*                   inscrição/cancelamento de Web Push
src/app/api/docs/*                   download dos documentos da Central de Ajuda
src/app/estilo                       guia de estilo vivo (protótipo visual)
src/lib/chamados/service.ts          regras de negócio dos chamados
src/lib/chamados/auditoria.ts        único ponto de escrita da trilha de auditoria
src/lib/chamados/acesso.ts           controle de acesso por setor/posto/papel (chamados e quartos)
src/lib/chamados/sla.ts              cálculo de tempo de espera/SLA + resolução por posto e fase
src/lib/chamados/prioridade.ts       nível visual/sonoro (normal/alta/emergência)
src/lib/quartos/acuidade.ts          leitura/escrita de acuidade por quarto
src/lib/rondas/service.ts            checklist de rondas + resolução de config aplicável
src/lib/rondas/config.ts             funções puras de resolução de RondaConfig/faixas de horário
src/lib/admin/rondas.ts              CRUD de RondaConfig (admin)
src/components/dashboard/ScannerQrCode.tsx  leitura de QR Code (BarcodeDetector nativo) pra validar ronda
src/lib/medicacao/service.ts         agenda de medicação por quarto + transições Agendada/Preparando/Efetuada + recorrência por intervalo
src/lib/medicacao/ordenacao.ts       ordenação pura por próxima administração (usada pela visão por quarto)
src/lib/medicacao/job.ts             job de alerta "5min antes" (HORARIO_FIXO + INTERVALO) + reset de ciclo diário
src/lib/medicacao/eventos.ts         nomes dos eventos + emissão pra sala do posto/Supervisão
src/lib/admin/medicacao.ts           CRUD de MedicacaoConfig (admin)
src/lib/quartos/ocupacao.ts          status de ocupação do quarto + limpeza da agenda ao Liberar
src/components/dashboard/PainelStatusQuarto.tsx   painel de status do quarto (Ocupado/Alta hospitalar/Liberado)
src/components/dashboard/PainelQuartos.tsx        lista de atalhos por quarto (link -> tela própria do quarto)
src/app/atendimento/enfermaria/quarto/[id]/page.tsx  tela própria de um quarto (busca dados + acesso)
src/components/dashboard/DetalhesQuarto.tsx       acuidade+status+rondas+medicação de um único quarto
src/hooks/useMedicacaoSocket.ts      socket de alertas/atualizações de medicação (com callbacks pra evitar cascata de renders)
src/components/dashboard/PainelMedicacao.tsx      painel da tela de atendimento (agendar + Preparando/Efetuada)
src/components/dashboard/AlertaMedicacaoBanner.tsx banner da tela de parede (com estado inicial + tempo real)
src/lib/webrtc/eventos.ts            nomes dos eventos de sinalização WebRTC
src/hooks/useChamadaAudioPaciente.ts  WebRTC lado do paciente
src/hooks/useChamadaAudioStaff.ts     WebRTC lado da equipe
src/lib/chat/service.ts              chat de texto por chamado (envio/histórico, autorização)
src/lib/chat/eventos.ts              nomes dos eventos + sala exclusiva do chat por chamado
src/hooks/useChatEquipe.ts           chat lado da equipe (não lidas, abrir/fechar por chamado)
src/hooks/useChamadoChatPaciente.ts  chat lado do paciente
src/components/chamar/ChatPaciente.tsx        chat na tela do quarto (cartão de sucesso)
src/components/dashboard/ChatChamadoPainel.tsx chat no ChamadoCard das telas de atendimento
src/lib/escalonamento/job.ts         escalonamento automático (SLA de atendimento em 2 elos + aviso de finalização)
src/lib/postos/queries.ts             leitura de postos, qualquer setor (usado fora do admin)
src/lib/admin/                        regras de negócio do cadastro (postos/usuários/quartos/SLA/dispositivos)
src/lib/supervisor/escopo.ts          escopo de supervisão (lista de postos que o supervisor acompanha)
src/lib/docs/                         catálogo + leitura dos documentos da Central de Ajuda
src/lib/realtime/                     Socket.IO (singleton + eventos + salas por posto)
src/lib/notificacoes/push.ts          envio de Web Push
src/lib/relatorios/queries.ts         consultas agregadas de relatório (setor/turno/funcionário)
src/lib/relatorios/periodo.ts         filtro de período compartilhado (presets -> intervalo real)
src/lib/relatorios/ocupacao.ts        taxa de ocupação de quartos (histórico de intervalos)
firmware/esp32-botao-emergencia       firmware de referência do botão sem fio
deploy/                                scripts e configuração de deploy (inclui entrypoint.sh de migração automática)
docs/                                   esta documentação
tests/unit, tests/integration           testes automatizados
```

## Modelo de dados

Ver `prisma/schema.prisma` para a fonte da verdade. Resumo:

| Model | Papel |
|---|---|
| `Posto` | Um posto físico de Enfermagem, Copa ou Rouparia (`tipo`) — login e fila próprios. Único por `(tipo, nome)`. |
| `Quarto` | Um quarto/leito do hospital; `numero` único (URL `/chamar/[numero]`), até 3 postos opcionais — um por setor (`postoEnfermagemId`/`postoCopaId`/`postoRoupariaId`). |
| `Usuario` | Conta de equipe (`papel`: `ENFERMARIA`\|`COPA`\|`ROUPARIA`\|`SUPERVISOR`\|`ADMINISTRADOR`), senha com hash bcrypt; `postoId` para `ENFERMARIA`/`COPA`/`ROUPARIA` (posto do tipo correspondente ao papel). |
| `SupervisorEscopo` | Lista de postos (de qualquer setor) que um login `SUPERVISOR` escolheu acompanhar — autoatendimento em "Meus setores"; sem nenhuma linha, o supervisor não vê fila nem relatório nenhum. |
| `Dispositivo` | Um botão IoT vinculado a um quarto; autentica via `chaveHash` (bcrypt). CRUD em `/admin/dispositivos`. |
| `Chamado` | O registro central: `tipo`, `prioridade` (`NORMAL`/`ALTA`/`EMERGENCIA`), `status`, `origem`, `postoId`, timestamps de cada transição e quem a fez, incl. `escaladoCriticoEm` (elo 2 do escalonamento de atendimento) e `finalizacaoEscaladaEm` (aviso do SLA de finalização). |
| `EventoAuditoria` | Trilha imutável (append-only) de cada transição de um `Chamado`. |
| `SlaConfig` | Limite de minutos por `(TipoChamado, postoId, FaseSla)` — posto nulo é o padrão global; fase separa o limite de atendimento (ciência) do de finalização. Usado no escalonamento e nos relatórios de SLA. |
| `PushSubscription` | Inscrições de Web Push por usuário/navegador. |
| `RegistroRonda` | Checklist de rondas — imutável (append-only), mesmo padrão de `EventoAuditoria`. `Quarto.ultimaRondaEm` é o resumo denormalizado. |

`Quarto` também carrega `acuidade` (`ESTAVEL`/`ATENCAO`/`CRITICO`) e
`ultimaRondaEm`/`ultimaRondaPorId`, ambos atualizados fora do ciclo de
vida do `Chamado`.

**Máquina de estados de `Chamado.status`:**

```
ABERTO ──atender──▶ EM_ATENDIMENTO ──finalizar──▶ FINALIZADO
  │                      │
  └──(SLA excedido)──▶ ESCALADO ──atender──▶ EM_ATENDIMENTO (de-escalona)
  │                      │
  └───────────cancelar───┴──────────────────▶ CANCELADO
```

`FINALIZADO` e `CANCELADO` são estados terminais — nenhuma outra transição
é permitida a partir deles (`ErroChamado` 409 se tentado).

**O elo de escalonamento (SLA de atendimento) só considera `ABERTO`,
nunca `EM_ATENDIMENTO`.** O tempo de espera (`sla.ts#tempoEsperaMs`) fica
congelado no momento em que `atendidoEm` é gravado — se esse elo também
escaneasse `EM_ATENDIMENTO`, um chamado atendido depois do limite ficaria
sendo reescalonado para sempre, mesmo já estando sendo cuidado. Foi um
bug real encontrado testando o sistema de ponta a ponta. O SLA de
**finalização** é o oposto: só olha `EM_ATENDIMENTO` (usa
`sla.ts#tempoAtendimentoMs`, contado a partir de `atendidoEm`) — os dois
elos não se sobrepõem.

## API

Todas as rotas retornam JSON. Erros seguem `{ erro: string, detalhes?: ... }`
com o status HTTP apropriado (`src/lib/http/erros.ts`).

| Rota | Método | Auth | Descrição |
|---|---|---|---|
| `/api/chamados` | `POST` | pública | Cria chamado via QR code ou botão de emergência da tela. Body: `{ numeroQuarto, tipo }` (`tipo` inclui `EMERGENCIA`). |
| `/api/chamados` | `GET` | sessão | Lista a fila do próprio papel (enfermagem/copa/rouparia: só o próprio posto + fila "sem posto" do setor). `SUPERVISOR`/`ADMINISTRADOR` podem pedir `?setor=`. |
| `/api/chamados/[id]/atender` | `POST` | sessão | Marca `EM_ATENDIMENTO`. |
| `/api/chamados/[id]/finalizar` | `POST` | sessão | Marca `FINALIZADO`. |
| `/api/chamados/[id]/cancelar` | `POST` | sessão | Marca `CANCELADO`. Body opcional: `{ motivo }`. |
| `/api/chamados/emergencia` | `POST` | chave de dispositivo | Cria chamado `EMERGENCIA` via botão/cordão IoT físico. Body: `{ numeroQuarto, deviceId, apiKey }`. |
| `/api/relatorios` | `GET` | sessão (`SUPERVISOR`) | Números por setor + pendentes. Query opcional: `?desde=&ate=` (ISO 8601). |
| `/api/push/subscribe` | `POST` | sessão | Registra uma inscrição de Web Push. |
| `/api/push/unsubscribe` | `POST` | sessão | Remove uma inscrição. |
| `/api/admin/postos` | `GET`/`POST` | sessão (`ADMINISTRADOR`) | Lista/cria postos (qualquer setor — `tipo` no body). |
| `/api/admin/postos/[id]` | `PATCH` | sessão (`ADMINISTRADOR`) | Renomeia/ativa/desativa um posto. |
| `/api/admin/usuarios` | `GET`/`POST` | sessão (`ADMINISTRADOR`) | Lista/cria usuários. |
| `/api/admin/usuarios/[id]` | `PATCH` | sessão (`ADMINISTRADOR`) | Edita nome/login, ativa/desativa, troca senha, reatribui posto. |
| `/api/admin/quartos` | `GET`/`POST` | sessão (`ADMINISTRADOR`) | Lista/cria quartos. |
| `/api/admin/quartos/[id]` | `PATCH` | sessão (`ADMINISTRADOR`) | Edita número/ala, reatribui posto (de qualquer setor), ativa/desativa. |
| `/api/admin/quartos/lote` | `POST` | sessão (`ADMINISTRADOR`) | Cria vários quartos de uma vez por faixa numérica (número inicial/final, prefixo opcional). |
| `/api/admin/quartos/[id]/qrcode` | `GET` | sessão (`ADMINISTRADOR`) | PNG do QR Code do quarto (`image/png`). |
| `/api/admin/dispositivos` | `GET`/`POST` | sessão (`ADMINISTRADOR`) | Lista dispositivos / cria um novo (retorna a chave em texto puro uma única vez). |
| `/api/admin/dispositivos/[id]` | `PATCH` | sessão (`ADMINISTRADOR`) | Edita descrição/quarto vinculado, ativa/desativa um dispositivo. |
| `/api/admin/dispositivos/[id]/rotacionar` | `POST` | sessão (`ADMINISTRADOR`) | Gera uma nova chave (retorna em texto puro uma única vez). |
| `/api/admin/sla` | `GET`/`POST` | sessão (`ADMINISTRADOR`) | Lista limites de SLA / define (upsert manual) um limite global ou por posto, numa fase (`ATENDIMENTO`/`FINALIZACAO`). |
| `/api/admin/sla/[id]` | `DELETE` | sessão (`ADMINISTRADOR`) | Remove uma substituição por posto (nunca o padrão global). |
| `/api/quartos/[id]/acuidade` | `PATCH` | sessão | Atualiza a acuidade do quarto. Mesma regra de acesso por posto/escopo dos chamados. |
| `/api/quartos/[id]/ronda` | `POST` | sessão | Registra uma ronda no quarto (append-only). |
| `/api/supervisor/escopo` | `GET`/`PUT` | sessão (`SUPERVISOR`) | Lê/define a lista de postos que o supervisor acompanha ("Meus setores"). |
| `/api/docs/[slug]` | `GET` | pública | Baixa um documento da Central de Ajuda como `.md` (`Content-Disposition: attachment`). |
| `/api/health` | `GET` | pública | `{ ok, db, realtime, timestamp }`. |
| `/api/auth/[...nextauth]` | `GET`/`POST` | — | Rotas internas do Auth.js (login/logout/sessão). |

Ações de setor (`atender`/`finalizar`/`cancelar`) exigem
`usuarioPodeAgirNoChamado` (`src/lib/chamados/acesso.ts`): o papel bate
com o setor do chamado (e o posto também bate, para Enfermagem/Copa/
Rouparia — ou o chamado está na fila "sem posto" daquele setor), ou o
usuário é `SUPERVISOR` (dentro do próprio escopo)/`ADMINISTRADOR`.

## Eventos em tempo real (Socket.IO, path `/socket.io`)

| Evento | Quando |
|---|---|
| `chamado:novo` | Chamado criado via QR code (setor normal). |
| `chamado:emergencia` | Chamado de emergência (botão da tela ou IoT). |
| `chamado:atualizado` | Atendido ou finalizado. |
| `chamado:escalado` | Job de escalonamento marcou como `ESCALADO`. |
| `chamado:escalado_critico` | Job de escalonamento marcou `escaladoCriticoEm` (elo 2 — subiu pra Administração). |
| `chamado:finalizacao_escalada` | Job de escalonamento marcou `finalizacaoEscaladaEm` (SLA de finalização estourado — aviso único ao Supervisor, sem elo crítico). |
| `chamado:desescalado` | Um chamado escalado foi finalmente atendido. |
| `chamado:cancelado` | Chamado cancelado. |

Payload: `ChamadoDTO` (`src/lib/realtime/events.ts`) — o mesmo formato
retornado pela API, com datas serializadas como string ISO.

**Sinalização WebRTC** (`audio:oferta`, `audio:resposta`,
`audio:candidato`, `audio:encerrar` e as respectivas variantes
`*_recebida`/`*-recebida`) é uma família de eventos à parte, ver seção
"Áudio bidirecional" acima e `src/lib/webrtc/eventos.ts`.

**Salas:** uma por posto, de qualquer setor (`POSTO:<id>`, ver `salaPosto`
em `src/lib/realtime/events.ts`), mais uma sala "sem posto" **por tipo**
(`POSTO:sem-posto:ENFERMARIA`/`COPA`/`ROUPARIA`, ver `salaSemPosto`) —
todo login de Enfermagem/Copa/Rouparia assina a sala do próprio posto (se
tiver) mais a "sem posto" do próprio tipo. `SUPERVISOR` assina as salas
dos postos escolhidos em "Meus setores" (nenhuma, se não tiver escopo
configurado). `ADMINISTRADOR` assina só `SUPERVISOR` (sala à parte,
recebe todo evento por transmissão — acesso irrestrito sem precisar
entrar em cada sala individualmente). `EMERGENCIA` usa a mesma sala de
posto (ou "sem posto") do setor Enfermagem do quarto.

**Singleton do Socket.IO entre `server.ts` e as API routes:** `server.ts`
roda via `tsx` fora do bundler do Next, enquanto as rotas de API rodam
dentro do grafo de módulos compilado pelo Next — `src/lib/realtime/io.ts`
guarda a instância em `globalThis` (via `Symbol.for`), não numa variável
de módulo comum, porque uma variável `let` ficaria presa só na instância
de `server.ts` e as rotas nunca veriam o `io` já configurado. Também foi
um bug real pego testando contra um servidor de verdade.

**Causa raiz real do "Reconectando…" que nunca voltava:**
`secureCookie` em `getToken()` (server.ts, middleware `io.use()`)
estava fixo em `!dev` — mas quem decide se o Auth.js usa o prefixo
`__Secure-` no cookie de sessão é o protocolo de `AUTH_URL`/
`NEXTAUTH_URL` (`url.protocol === "https:"`, ver
`@auth/core/lib/init.js`), não `NODE_ENV`. Este projeto aponta
`NEXTAUTH_URL` pro domínio https de produção mesmo em dev (DDNS fixo),
então o cookie real é sempre `__Secure-authjs.session-token`, mas
`getToken()` com `secureCookie: false` (dev) procurava
`authjs.session-token` sem prefixo — nunca achava, `token` sempre
`null`, todo socket autenticado (chamados, chat, áudio, medicação)
caía em "Não autenticado" pra sempre. Nenhuma lógica de reconexão
resolve isso: o handshake falha de novo a cada tentativa, com o mesmo
erro. Corrigido calculando `secureCookieAuth` a partir do protocolo
real de `AUTH_URL`/`NEXTAUTH_URL`, não de `NODE_ENV`. Diagnosticado
reproduzindo o handshake do Engine.IO manualmente (`POST .../socket.io/
?...` com o pacote `40`) e comparando o `44{"message":"Não
autenticado"}` de erro contra o cookie realmente presente no
navegador.

**`forceNew` em todo hook de socket:** os 6 hooks que abrem conexão
(`useChamadosSocket`, `useChatEquipe`, `useChamadaAudioStaff`,
`useMedicacaoSocket`, `useChamadaAudioPaciente`,
`useChamadoChatPaciente`) passam `forceNew: true` pro `io()`. Sem isso,
o socket.io-client reaproveita silenciosamente uma única conexão pra
todo hook que chama `io()` com a mesma URI+path na mesma página — um
deles desmontando e chamando `.disconnect()` derruba a conexão de
todos os outros, e o socket.io não reconecta sozinho depois de um
`disconnect()` explícito (só depois de queda de rede). Bug real, mas
secundário ao de `secureCookie` acima — sem a autenticação corrigida,
nenhuma reconexão jamais teria funcionado mesmo assim.

**Reconexão ativa em `src/lib/realtime/reconexao.ts`:** mesmo com
`forceNew`, uma aba em segundo plano ou um celular com tela bloqueada
por tempo suficiente pode suspender o próprio socket **e os timers de
reconexão automática do socket.io-client** (dependem do event loop
rodando) — a conexão fica presa em "desconectado" mesmo depois da aba
voltar ao primeiro plano. `vincularReconexaoAtiva(socket)` força
`socket.connect()` nos eventos `visibilitychange` (aba volta a ficar
visível), `online` e `focus`; todo hook de socket chama essa função
dentro do mesmo `useEffect` que abre a conexão. Como último recurso,
`ConexaoStatus` (`src/components/dashboard/ConexaoStatus.tsx`) mostra
um botão "Recarregar" depois de 15s desconectado — dá uma saída manual
pra quando mesmo isso não resolver (rede real fora do ar).

## Variáveis de ambiente

Ver `.env.example` para a lista completa e comentada. Destaques:

- `DATABASE_URL` vs `DATABASE_URL_APP`: a primeira é a role de migração
  (schema owner), a segunda é a role de runtime do app (privilégio
  reduzido). **Nunca aponte `DATABASE_URL_APP` para a role de migração.**
- `SLA_LIMITE_MINUTOS_*` (fase atendimento) e
  `SLA_FINALIZACAO_LIMITE_MINUTOS_*` (fase finalização, opcional — cai
  pro dobro do limite de atendimento do mesmo tipo se ausente): valores
  usados só no `prisma/seed.ts` para popular `SlaConfig` na primeira
  execução — depois disso, os limites reais ficam no banco, editáveis
  pelo administrador em `/admin/sla`.
- `VAPID_*` / `NEXT_PUBLIC_VAPID_PUBLIC_KEY`: gere com
  `npm run vapid:generate`.

## Rodando localmente

```bash
cp .env.example .env
npm install
docker compose up -d db          # ou um Postgres já existente (crie as roles manualmente — ver deploy/postgres-init/01-roles.sql)
npx prisma migrate deploy        # aplica schema + REVOKE de auditoria
npm run seed
npm run dev                      # http://localhost:3000
```

Ver `deploy/LEIA-ME.md` para deploy completo (VM + Docker + HTTPS).

### Testar pelo celular (IP da rede local) em `npm run dev`

`next dev` bloqueia (403) os assets de `/_next/*` e o WebSocket de HMR
quando a origem da requisição não é `localhost` — a página SSR carrega,
mas o JS nunca hidrata e nenhum botão responde (foi exatamente o sintoma
reportado: tela do quarto abria, mas os botões não faziam nada).
`next.config.ts` resolve isso sozinho: `allowedDevOrigins` é calculado
chamando `os.networkInterfaces()` toda vez que o servidor sobe, então
acompanha o IP do notebook mudando por DHCP sem precisar editar nada —
foi por isso mesmo que o valor apareceu fixo numa primeira versão deste
arquivo e parou de bater quando a rede trocou de `10.117.2.168` para
`192.168.69.63` no meio de uma sessão de testes.

Essa restrição só existe no `next dev`; o build de produção (`next
start`, incluindo o container Docker da porta 3001) nunca bloqueia por
origem. Se a interface de rede mudar (trocar de Wi-Fi, plugar cabo,
VPN…) só é preciso **reiniciar o servidor** — `os.networkInterfaces()`
só é lido na hora que `next.config.ts` carrega, não fica escutando
depois.

Isso tudo é só pra testar durante o desenvolvimento — pra um endereço
estável de verdade (sem depender do IP local nem reiniciar nada quando a
rede muda), a stack principal (`docker-compose.yml`, com Caddy + HTTPS)
já suporta um domínio público fixo, incluindo DNS dinâmico gratuito tipo
No-IP. Ver "Alternativa sem domínio comprado: DDNS" em
`deploy/LEIA-ME.md`.

## Testes

```bash
npm test              # unitários (sempre) + integração (só se DATABASE_URL_APP acessível)
npm run test:coverage
npm run typecheck
npm run lint
```

- `tests/unit/`: lógica pura e camada de serviço com Prisma mockado — não
  precisam de banco, sempre rodam.
- `tests/integration/`: contra um Postgres real (prova que o `REVOKE` de
  auditoria funciona de fato, e o ciclo de vida completo de um chamado).
  Pulam automaticamente (não falham) se `DATABASE_URL_APP` não estiver
  acessível. Rodam de verdade no CI (`.github/workflows/ci.yml`), que sobe
  um Postgres de serviço.
- Vale reforçar: os dois bugs descritos acima (middleware fora de `src/`,
  singleton do Socket.IO duplicado, reescalonamento infinito) **não**
  foram pegos por testes automatizados nem por tipo/lint — só apareceram
  testando o app de verdade, com login real, contra um Postgres real. Ao
  mexer em auth/middleware/realtime/escalonamento, testar manualmente
  vale mais do que confiar só em `npm test`/`npm run build`.

## Acessibilidade (WCAG 2.1)

- `lang="pt-BR"` no `<html>`, link "pular para o conteúdo" em todo layout.
- Foco visível customizado (`:focus-visible`) — nunca `outline: none` sem
  substituto.
- Contraste: paleta com texto/fundo pensados para ≥4.5:1 (verificar de
  novo quando a paleta placeholder for trocada pela oficial da Unimed).
- `role="status"`/`aria-live="polite"` nas listas de chamados e mensagens
  de confirmação/erro, para leitores de tela serem avisados de mudanças.
- Alvos de toque ≥44px na página do paciente (`/chamar/[numeroQuarto]`).
- A página do paciente usa `h-dvh` + `overflow-hidden` no container (em
  vez de `min-h-screen`) e espaçamento/tamanho de botão reduzidos abaixo
  do breakpoint `sm` — cabe inteira em telas de celular comuns (~375×667)
  sem precisar rolar pra ver os botões de chamado, importante numa tela
  usada em emergência. `overflow-y-auto` no `<main>` fica só como válvula
  de segurança (fonte do sistema aumentada, tela muito pequena).
- `prefers-reduced-motion` respeitado (`globals.css`).
- Não testado formalmente com leitor de tela real nem auditoria
  automatizada (axe-core/Lighthouse) como parte desta reconstrução —
  recomendado antes de operar em produção.

## Modo escuro

Classe `.dark` no `<html>`, alternada por `ThemeToggle`
(`src/components/ui/ThemeToggle.tsx`, via `useSyncExternalStore` lendo o
estado real do DOM — evita flash e mismatch de hidratação). Preferência
persistida em `localStorage`, com fallback para
`prefers-color-scheme` do sistema operacional no primeiro acesso
(`ScriptTemaInicial`, script inline que roda antes do primeiro paint).

## Som e push ativados por padrão

`useAlarmeSonoro` tenta habilitar o áudio assim que a página carrega e,
se o navegador bloquear (política de autoplay), habilita silenciosamente
no primeiro clique/toque/tecla em qualquer lugar da página.
`usePushSubscription` pede a permissão de notificação automaticamente ao
carregar (ou já inscreve direto, se a permissão já tiver sido concedida
antes). Nos dois casos existe um botão manual como retaguarda, mostrado só
se a tentativa automática não colou — são limites reais da plataforma
(autoplay/permissão exigem gesto do usuário na maioria dos navegadores),
não uma escolha de design deste projeto.

**Estado `bloqueado`:** se a permissão de notificação já foi negada
antes (`Notification.permission === "denied"`), `requestPermission()`
falha em silêncio pra sempre — nem mostra o prompt do navegador de
novo, nem dá erro. Sem distinguir isso de `desativado` (ainda não
decidido), o botão "Ativar notificações push" clicava e parecia não
fazer nada, indefinidamente. `usePushSubscription` detecta esse caso
(no mount e dentro de `ativar()`) e usa um estado `bloqueado` à parte,
que troca o botão por um aviso explicando que só dá pra reverter nas
configurações do site do próprio navegador.

## Gaps conhecidos / não incluídos nesta reconstrução

- Testes de carga e de segurança formais.
- Auditoria de acessibilidade com ferramenta automatizada + leitor de tela
  real.
- Alta disponibilidade provisionada de fato (ver `docs/arquitetura.md`).
- Paleta visual é placeholder — trocar quando a Unimed enviar o manual de
  marca oficial (`src/app/globals.css`, bloco `@theme`).
- Servidor TURN pro áudio WebRTC (só STUN hoje — ver seção "Áudio
  bidirecional" acima).
- Áudio bidirecional só na tela de atendimento da Enfermagem (não em
  Copa/Rouparia) — mesma decisão de escopo de acuidade/rondas/prioridade
  "Urgente", que também são específicas de Enfermagem.
- Fuso horário fixo (UTC-3) no relatório "por turno" — não configurável
  por hospital.
