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/supervisorjá é a visão em colunas (uma por fila) com ação disponível (PainelSupervisorColunas);/atendimento/supervisorsó 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 campotipo(ENFERMARIA/COPA/ROUPARIA— nuncaEMERGENCIA, 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émalaeandar(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 parapapel = 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/salaDoChamadoemsrc/lib/realtime/events.tseusuarioPodeAgirNoChamadoemsrc/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→110cria os quartos101..110, com zero à esquerda preservado se o inicial tiver —01→10gera01..10). Prefixo opcional é pra enfermarias com vários leitos por quarto físico: quando preenchido, cada número vira{prefixo} Leito {NN}(ex. prefixoE101+01→03criaE101 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 umQuarto(gerarSequenciaNumerica+criarLeitosEmLoteemsrc/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.papelpor 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 opostoId— verPAPEIS_COM_POSTOno mesmo arquivo. Todos reaproveitam as mesmas funções deatualizarXjá usadas pelos toggles de ativar/desativar, só que agora aceitando mais campos. Cuidado: trocar onumerode umQuartomuda 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çãovalidarForcaSenha): mínimo 12 caracteres, maiúscula, minúscula, número e caractere especial, sem o nome da pessoa, o setor (rubrica em português dopapel— ex. "Enfermagem" praENFERMARIA— 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/atualizarUsuarioemsrc/lib/admin/usuarios.ts, que é quem efetivamente barra a gravação comErroChamado400 — 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 viaprisma.usuario.upsertdireto, sem passar porcriarUsuario— por isso os logins de teste continuam com a senha simplesteste123sem 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/*usamax-w-6xl(antesmax-w-4xl) — com o rótulo virando "Permissão" e a coluna Ações tendo 3 botões por linha, omax-w-4xlantigo forçava rolagem horizontal na tabela de usuários mesmo sobrando espaço lateral no monitor. As células da tabela (gerenciar-usuarios.tsx) usamwhitespace-nowrap— não quebram linha nunca; se algum dia o conteúdo voltar a estourarmax-w-6xl(nomes muito longos, tela pequena), o wrapperoverflow-x-autoainda 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 construtorURL, que percent-encoda espaço/acento no número (ex.: "E101 Leito 01" →.../chamar/E101%20Leito%2001). Com oserver.tscustomizado (handle(req, res)semparsedUrl), esse segmento chega ao Server Component ainda codificado — semdecodeURIComponentexplícito, quartos com espaço no número (ex.: leitos em lote) davam 404 mesmo existindo e ativos no banco.page.tsxagora decodifica o parâmetro (com try/catch pra sequência%inválida) antes de buscar oQuarto.
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/loginseparado — 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 numLinkpra/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 emsrc/lib/docs/catalogo.ts— é uma lista branca (nunca lê arquivo por nome vindo do usuário, evita path traversal). DocItem.tipodistingue três formatos:"markdown"(padrão) renderizadocs/*.mdviareact-markdown, lido em runtime porsrc/lib/docs/ler.tse baixado via/api/docs/[slug];"pdf"e"video"apontamarquivodireto pra um caminho público (ex.:/comercial/apresentacao-sistema-chamados.pdf,/treinamento/enfermagem.mp4) servido estaticamente depublic/— sem passar pelo leitor de Markdown nem pela rota/api/docs/[slug], que seguem só pra texto. Usarpublic/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.mp4finais é que importam.
- Comercial: apresentação em PDF do sistema (
- Bug real de deploy pego em produção:
src/lib/docs/ler.tslê os.mddo disco em runtime (path.join(process.cwd(), "docs")), mas oDockerfilesó copiavaprisma/,public/,src/etc. pro estágio final — nuncadocs/. Funcionava perfeito emnpm run dev(roda direto da pasta do projeto) e quebrava com 500 só no container Docker (visualizar e baixar documento).Dockerfileagora copiadocs/também, junto comprisma/public. Lição: qualquer pasta lida do disco em runtime (não só em build) precisa aparecer explicitamente no estágiorunnerdo 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:hiddenno 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 viacicloReferenciaEm.INTERVALO— repete "de N em N horas" (intervaloHoras, 1-24). Nesse modo não existe "dia" — o campohorariosó é usado na criação, pra calcular aproximaOcorrenciaEminicial (hoje nesse horário se ainda não passou, senão amanhã); dali em diante quem manda éproximaOcorrenciaEm, um timestamp absoluto avançado direto emmarcarEfetuada(agora + intervaloHoras, reabrindo praAGENDADAna hora, sem esperar o job).UMA_VEZ— administração única. Usa a mesma matemática de minuto-do-dia deHORARIO_FIXOpro aviso "5 minutos antes" (mesma consulta,tipoAgendamento: { in: ["HORARIO_FIXO", "UMA_VEZ"] }), mas ao ser efetuada é desativada (ativo: false, emitindomedicacao:atualizadacomo qualquer outra desativação) em vez de voltar praAGENDADA— 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 emcriarLeitosEmLote). 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áginasrc/app/atendimento/enfermaria/quarto/[id]/page.tsx, que resolve o quarto comobterQuartoVisivel(mesma regra de acesso delistarQuartosVisiveis— nunca deixa abrir por URL um quarto de outro posto) e chamanotFound()se não encontrar. Chama as mesmas rotas de API quePainelAcuidade/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:
- Enfermagem/Copa/Rouparia → Supervisor:
ABERTOque passa do limite de SLA (faseATENDIMENTO) viraESCALADO. - Supervisor → Administração: um
ESCALADOque continua sem atendimento por mais um limite inteiro (2× o limite original) ganhaescaladoCriticoEme dispara push pro papelADMINISTRADOR— 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_BRASILemsrc/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):
- Busca os quartos ativos do escopo (filtra por
postoEnfermagemId— ocupação é feature exclusiva de Enfermagem, mesmo critério dePainelStatusQuarto/PainelAcuidade). - Busca os intervalos que sobrepõem a janela filtrada
(
inicioEm < ate AND (fimEm IS NULL OR fimEm > desde)). 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.- 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 mesmoLineChart(GraficoOcupacao.tsx) como uma segunda linha tracejada — a curva real usatype="monotone"("curva de variação"), a tendência usatype="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_AUDIOemsrc/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.tsaceita um handshake{ tipo: "paciente", numeroQuarto }sem JWT — mas valida o número contra umQuartoativo 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 →RTCPeerConnectionconecta. Hooks:useChamadaAudioPaciente/useChamadaAudioStaff(src/hooks/), componentesBotaoChamadaAudio/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.tstinha umreturnantecipado que pulava o registro dos handlers deEVENTOS_AUDIOpro 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,autorTipoPACIENTE/EQUIPE,autorUsuarioIdnulo pro paciente,texto,criadoEm). Histórico imutável —REVOKE UPDATE, DELETEprachamados_app, mesmo tratamento deEventoAuditoria(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:sairemserver.ts, com o mesmousuarioPodeAgirNoChamadoque já gate atender/finalizar/cancelar); o paciente entra direto no handshake do socket, que agora aceita umchamadoIdopcional — validado contra onumeroQuartoantes 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 socketchat: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, validanumeroQuarto— mesmo espírito doPOST /api/chamadosjá público). - Fechamento:
finalizarChamado/cancelarChamado(src/lib/chamados/service.ts) chamamemitirChatEncerrado— só a sala do chat recebe esse evento (a equipe já sabe que fechou viachamado: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_URLvsDATABASE_URL_APP: a primeira é a role de migração (schema owner), a segunda é a role de runtime do app (privilégio reduzido). Nunca aponteDATABASE_URL_APPpara a role de migração.SLA_LIMITE_MINUTOS_*(fase atendimento) eSLA_FINALIZACAO_LIMITE_MINUTOS_*(fase finalização, opcional — cai pro dobro do limite de atendimento do mesmo tipo se ausente): valores usados só noprisma/seed.tspara popularSlaConfigna 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 comnpm 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 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
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 oREVOKEde auditoria funciona de fato, e o ciclo de vida completo de um chamado). Pulam automaticamente (não falham) seDATABASE_URL_APPnã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ó emnpm 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) — nuncaoutline: nonesem 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-hiddenno container (em vez demin-h-screen) e espaçamento/tamanho de botão reduzidos abaixo do breakpointsm— 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-autono<main>fica só como válvula de segurança (fonte do sistema aumentada, tela muito pequena). prefers-reduced-motionrespeitado (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.