Pular para o conteúdo
⬇ Baixar .md

Documentação técnica

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. 101110 cria os quartos 101..110, com zero à esquerda preservado se o inicial tiver — 0110 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 + 0103 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 EventoAuditoriaREVOKE 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: AGENDADAPREPARANDOEFETUADA, 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 (criadoEmatendidoEm) — o único que existia antes da fase de finalização.
  • FINALIZACAO: tempo entre dar ciência e finalizar (atendidoEmfinalizadoEm) — 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 (atendidoEmfinalizadoEm).

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 (searchParamsresolverPeriodo) 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:

ModelPapel
PostoUm posto físico de Enfermagem, Copa ou Rouparia (tipo) — login e fila próprios. Único por (tipo, nome).
QuartoUm quarto/leito do hospital; numero único (URL /chamar/[numero]), até 3 postos opcionais — um por setor (postoEnfermagemId/postoCopaId/postoRoupariaId).
UsuarioConta de equipe (papel: ENFERMARIA|COPA|ROUPARIA|SUPERVISOR|ADMINISTRADOR), senha com hash bcrypt; postoId para ENFERMARIA/COPA/ROUPARIA (posto do tipo correspondente ao papel).
SupervisorEscopoLista 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.
DispositivoUm botão IoT vinculado a um quarto; autentica via chaveHash (bcrypt). CRUD em /admin/dispositivos.
ChamadoO 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).
EventoAuditoriaTrilha imutável (append-only) de cada transição de um Chamado.
SlaConfigLimite 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.
PushSubscriptionInscrições de Web Push por usuário/navegador.
RegistroRondaChecklist 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).

RotaMétodoAuthDescrição
/api/chamadosPOSTpúblicaCria chamado via QR code ou botão de emergência da tela. Body: { numeroQuarto, tipo } (tipo inclui EMERGENCIA).
/api/chamadosGETsessãoLista 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]/atenderPOSTsessãoMarca EM_ATENDIMENTO.
/api/chamados/[id]/finalizarPOSTsessãoMarca FINALIZADO.
/api/chamados/[id]/cancelarPOSTsessãoMarca CANCELADO. Body opcional: { motivo }.
/api/chamados/emergenciaPOSTchave de dispositivoCria chamado EMERGENCIA via botão/cordão IoT físico. Body: { numeroQuarto, deviceId, apiKey }.
/api/relatoriosGETsessão (SUPERVISOR)Números por setor + pendentes. Query opcional: ?desde=&ate= (ISO 8601).
/api/push/subscribePOSTsessãoRegistra uma inscrição de Web Push.
/api/push/unsubscribePOSTsessãoRemove uma inscrição.
/api/admin/postosGET/POSTsessão (ADMINISTRADOR)Lista/cria postos (qualquer setor — tipo no body).
/api/admin/postos/[id]PATCHsessão (ADMINISTRADOR)Renomeia/ativa/desativa um posto.
/api/admin/usuariosGET/POSTsessão (ADMINISTRADOR)Lista/cria usuários.
/api/admin/usuarios/[id]PATCHsessão (ADMINISTRADOR)Edita nome/login, ativa/desativa, troca senha, reatribui posto.
/api/admin/quartosGET/POSTsessão (ADMINISTRADOR)Lista/cria quartos.
/api/admin/quartos/[id]PATCHsessão (ADMINISTRADOR)Edita número/ala, reatribui posto (de qualquer setor), ativa/desativa.
/api/admin/quartos/lotePOSTsessão (ADMINISTRADOR)Cria vários quartos de uma vez por faixa numérica (número inicial/final, prefixo opcional).
/api/admin/quartos/[id]/qrcodeGETsessão (ADMINISTRADOR)PNG do QR Code do quarto (image/png).
/api/admin/dispositivosGET/POSTsessão (ADMINISTRADOR)Lista dispositivos / cria um novo (retorna a chave em texto puro uma única vez).
/api/admin/dispositivos/[id]PATCHsessão (ADMINISTRADOR)Edita descrição/quarto vinculado, ativa/desativa um dispositivo.
/api/admin/dispositivos/[id]/rotacionarPOSTsessão (ADMINISTRADOR)Gera uma nova chave (retorna em texto puro uma única vez).
/api/admin/slaGET/POSTsessão (ADMINISTRADOR)Lista limites de SLA / define (upsert manual) um limite global ou por posto, numa fase (ATENDIMENTO/FINALIZACAO).
/api/admin/sla/[id]DELETEsessão (ADMINISTRADOR)Remove uma substituição por posto (nunca o padrão global).
/api/quartos/[id]/acuidadePATCHsessãoAtualiza a acuidade do quarto. Mesma regra de acesso por posto/escopo dos chamados.
/api/quartos/[id]/rondaPOSTsessãoRegistra uma ronda no quarto (append-only).
/api/supervisor/escopoGET/PUTsessão (SUPERVISOR)Lê/define a lista de postos que o supervisor acompanha ("Meus setores").
/api/docs/[slug]GETpúblicaBaixa um documento da Central de Ajuda como .md (Content-Disposition: attachment).
/api/healthGETpública{ ok, db, realtime, timestamp }.
/api/auth/[...nextauth]GET/POSTRotas 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)

EventoQuando
chamado:novoChamado criado via QR code (setor normal).
chamado:emergenciaChamado de emergência (botão da tela ou IoT).
chamado:atualizadoAtendido ou finalizado.
chamado:escaladoJob de escalonamento marcou como ESCALADO.
chamado:escalado_criticoJob de escalonamento marcou escaladoCriticoEm (elo 2 — subiu pra Administração).
chamado:finalizacao_escaladaJob de escalonamento marcou finalizacaoEscaladaEm (SLA de finalização estourado — aviso único ao Supervisor, sem elo crítico).
chamado:desescaladoUm chamado escalado foi finalmente atendido.
chamado:canceladoChamado 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

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 servidoros.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

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.