Compare commits

..

48 Commits

Author SHA1 Message Date
VPS 4 Builder 2adc3095ac Merge remote-tracking branch 'origin/main' into feat/newwhats-satelite
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m6s
2026-07-10 06:33:28 +02:00
VPS 4 Builder 049afeef45 fix(migração): cria pacientes.dados_confirmados no boot (Feature C em prod)
A coluna era criada só via ALTER manual no dev; sem a migração no runMigrations()
a Feature C (check-in) quebraria em produção.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 06:10:57 +02:00
VPS 4 Builder 8ea8faa4c2 wip: inbox/agenda diversos (trabalho anterior à sessão)
Consolida WIP acumulado que não faz parte das features de hoje: melhorias do
inbox (WhatsChatDrawer, InputBar, MessageBubble, ProtocolPanel, StickerPanel,
LinkPreview, hooks, socketService, chatStore, nwClient), componentes novos
(ConversationSearch, ForwardModal, LabelManagerModal, roleMeta), AgendaPresence,
ComunicacaoInternaModal, Sidebar, GestaoEquipeView, SessionsView, appTime e o
asset chat-bg.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 06:08:07 +02:00
VPS 4 Builder 86cf253161 feat(agenda): pedidos (dia/horário), check-in com confirmação de dados
- PedidosPendentes: ajuste de DIA (mini-calendário) além do horário; abrir chat.
- ConfirmarDadosPaciente: modal no check-in (Atender) p/ pacientes cadastrados
  pela Secretária — confere nome/nascimento/CPF/telefone (número próprio) +
  grupo familiar, valida e marca confirmado.
- AgendaView: dispara o modal no Atender; backend.ts getPacienteById/confirmar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 06:07:20 +02:00
VPS 4 Builder 89588c2674 feat(wa-inbox): nome do paciente, fila, power por chat, agenda, secretária
- Nome do paciente (base) no lugar do número/pushname; multi-paciente → titular.
- Aba Fila (pedidos da Secretária) + selo pendente; botão de pedidos e overlay
  da agenda no header do chat.
- Power da Secretária: por sessão (fail-closed, só dono) e override por CHAT
  (ChatSecretariaPower); botão de pausa reflete o estado efetivo (AiPauseButton).
- secretariaApi/chatApiService: chat-power + handoff com sec_enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 06:07:13 +02:00
VPS 4 Builder 56443765d4 feat(agenda+secretaria): backend — validação, cadastro, nomes, check-in, notificar
- validacao.js: CPF/CNPJ (dígito verificador real) + data de nascimento.
- agenda-bridge: /paciente-contexto +inadimplente/em_tratamento/falta_recente;
  /pacientes valida CPF+data e grava dados_confirmados=false (revisão humana);
  /notificar (notifica a equipe da clínica).
- agenda-config: /nomes-por-telefone (nosso nome/titular p/ o inbox);
  /pedidos (fila da Secretária).
- proxy: só o dono liga/desliga a Secretária (gate owner nos *-power).
- server.js: GET /pacientes/:id + POST /pacientes/:id/confirmar-dados (check-in).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 06:07:02 +02:00
VPS 4 Builder d26fd80a28 feat(procedimentos+menu): aba Procedimentos em Horários + Secretária dentro do WhatsApp
- HorariosConfig: nova aba "Procedimentos" — lista marcável (atende/não atende); ao
  desmarcar, campos motivo + como falar. GET/PUT /clinica/:id/procedimentos-atende.
- Menu: "SECRETÁRIA IA" sai do menu lateral; agora é acessível por um botão (ícone Bot)
  no header do WhatsApp/Inbox, e o "voltar" da Secretária retorna ao inbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 08:43:08 +02:00
VPS 4 Builder b42257f584 feat(procedimentos): atende/não-atende + motivo + como falar (backend)
- Tabela agenda_procedimentos (clinica_id, nome, atende, motivo, como_falar).
- agenda-config GET/PUT /clinica/:id/procedimentos-atende (GET semeia do catálogo
  procedimentos — incl. globais clinica_id NULL; PUT só dono).
- /contexto (bridge) devolve procedimentos_nao_atende (nome+motivo+como_falar).

Verificado E2E: seed 4; PUT marca não-atende; /contexto entrega o script.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 08:34:05 +02:00
VPS 4 Builder 1d0de944e1 feat(agenda-bridge): /paciente-contexto (dentista responsável + tratamento)
Contexto interno do paciente p/ a Secretária dar continuidade: dentista responsável
(do orçamento aberto; fallback = agendamento mais recente) + em_tratamento. Por
paciente_id ou telefone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 08:12:13 +02:00
VPS 4 Builder 7cfd14ae09 feat(timezone): exibição no fuso de armazenamento — corrige o -1h (Fase 3)
start_time é naive; o node-pg o serializa no fuso do container (America/Sao_Paulo).
Exibir no fuso do NAVEGADOR (ex.: UTC-4) mostrava -1h (09:30 -> 08:30). Agora o
frontend formata SEMPRE em America/Sao_Paulo (o mesmo frame do armazenamento),
recuperando o wall-clock literal para qualquer navegador:
- utils/appTime.ts: STORAGE_TZ + fmtHora/fmtDataCurta/fmtDataHora.
- FullCalendar timeZone="America/Sao_Paulo".
- AgendaDetailModal (card "Data e hora" + histórico) e AgendaView (interesses) usam os helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 07:46:20 +02:00
VPS 4 Builder 6087846556 feat(agenda-bridge): /numeros-internos p/ guard anti-leak da Secretária
Lista os dígitos dos números de pessoa_comunicacao da clínica (dentistas/equipe) p/
o motor não tratar um número interno como paciente.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 07:15:31 +02:00
VPS 4 Builder 6f8bd4162f fix(sessions): avatar da conta em círculo (rounded-full)
O avatar/foto do número em /sessions estava rounded-2xl (quadrado arredondado);
passa a rounded-full (círculo), com a foto (object-cover) preenchendo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 07:04:25 +02:00
VPS 4 Builder 2f37944afa feat(timezone): /status no fuso da clínica + devolve timezone (Fase 2b)
/status calcula "hoje"/semana no fuso da clínica (getClinicaTZ) e passa a devolver
`timezone` — usado pelo motor p/ injetar a data/hora no fuso certo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 02:44:42 +02:00
VPS 4 Builder 27dd45efbd feat(timezone): bridge usa o fuso da clínica no "agora" (Fase 2a)
Helpers de TZ (partsNow/tzOffsetMin/zonedToUtc/getClinicaTZ; Brasil sem DST).
- Janelas de contato (contatoPermitidoAgora/proximaJanela) passam a receber o
  "agora" no fuso da clínica; proxima_janela_ts vira o instante REAL no fuso.
  Usado em /dentista-contato, /responsavel-contato, /contatos.
- /slots: "hoje"/"agora" (filtro de passado) no fuso da clínica, não do container.

Verificado E2E: clínica=Campo_Grande(UTC-4), container UTC-3 21h → janela às 20h
(hora da clínica) libera; às 21h (hora do container) bloqueia.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 02:35:48 +02:00
VPS 4 Builder 2d0396fcb1 feat(timezone): configuração de fuso horário por workspace (Fase 1)
Fundação do multi-fuso: cada clínica define seu timezone (Configurações → Perfil da
Unidade → Fuso horário). Migration clinicas.timezone (default America/Sao_Paulo);
GET/PUT /api/clinicas/:id/timezone (whitelist de fusos do Brasil; PUT só dono/admin).

Base para a Fase 2 (usar o fuso no cálculo de slots, no "agora" da Secretária e na
exibição da agenda) — que corrige o -1h observado (IA confirmou 09:30, agenda mostrou
08:30) causado por satélite=UTC-3, motor=UTC e navegador=UTC-4 com horário naive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 02:27:34 +02:00
VPS 4 Builder c8443a146a fix(nw): handoff é ação de inbox (não Secretária) + botão do dono p/ horário da clínica
1) 403 ao "desligar a IA no chat": guardAreaAccess tratava TODO /sec/* como config da
   Secretária (exige can_secretaria). Mas /sec/handoff é assumir/soltar o CHAT = INBOX.
   Reclassificado p/ can_inbox — a recep (can_inbox, sem can_secretaria) volta a assumir
   o chat. /sec/agent|nodes|slots e /secretaria/* seguem exigindo can_secretaria.
   (session-power global segue can_secretaria — o dono delega se quiser.)

2) Duplicação de horário (parte 2): o DONO agora tem, no modal, o card + botão p/
   configurar o "Horário da clínica" (clinicas_horarios) em Horários de Funcionamento
   (aba clínica). Completude do dono passa a exigir esse horário. Backend:
   comunicacaoCompleto(p, {ehDentista,jornadaOk,ehDono,horarioClinicaOk}); GET devolve
   horario_clinica_configurado. Verificado: dono da Consultt sem horário → gate exige.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:34:04 +02:00
VPS 4 Builder e9f8a24a93 fix(comunicacao-interna): remove duplicação de agenda — fonte única (jornada real)
A seção "Agenda/Horários de atendimento" do modal era uma 3ª fonte de horário que a
Secretária NÃO lia (ela agenda por clinicas_horarios + dentistas_horarios). Removida:
- Modal cuida só de contato (endereço + números + janelas de envio). Para dentista,
  mostra o STATUS da jornada real e um botão que abre "Horários de Funcionamento"
  (HorariosConfig, aba dentistas) por cima; ao fechar, recarrega.
- Completude do DENTISTA passa a exigir a jornada REAL configurada (dentistas_horarios),
  não mais a agenda do modal. GET recomputa `completo` ao vivo + devolve jornada_configurada.
- Backend: comunicacaoCompleto(p, ehDentista, jornadaOk) + helper jornadaConfigurada.

Verificado: dentista com jornada → completo; query bate com dentistas_horarios real.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:14:32 +02:00
VPS 4 Builder e8d1a5547e feat(agenda-bridge): /responsavel-contato p/ consultar o dono (com janela)
Resolve o contato do dono/responsável p/ decisões da clínica: por nome (pessoa da
lista de contatos — cobre o dono cujo número está na conta de dentista) ou por papel
(dono/responsável). Mesma forma do /dentista-contato (contato_agora, bloqueado_por_janela,
proxima_janela[_ts]). Verificado: por nome resolve o número compartilhado.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:00:39 +02:00
VPS 4 Builder b253feb4aa feat(comunicacao-interna): notificação de número compartilhado + agenda de contatos
Opção (b) — mesma pessoa/número servindo a papéis diferentes, roteando por situação:
- GET /api/comunicacao/numero-lookup: ao digitar um número, avisa se ele já é de outra
  conta/papel da clínica (ex.: número na conta dentista aparece ao editar a de dono).
  Modal mostra banner "pode compartilhar — descreva as situações; a Secretária roteia".
- Bridge GET /contatos: agenda de contatos internos (nome, papel, situações de cada
  número, janela atual) p/ a Secretária rotear por assunto. Não expõe o número cru.

Verificado E2E: lookup como dono acha o nº na conta dentista; /contatos lista papel+situações.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:48:42 +02:00
VPS 4 Builder eafc1578e3 fix(app): importa useRef — corrige tela branca do gate progressivo
O gate de comunicação interna usava useRef sem importá-lo, quebrando o render
inteiro do App (ReferenceError → tela branca).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:49:46 +02:00
VPS 4 Builder 8b9776f081 feat(comunicacao-interna): gate progressivo + agenda só p/ dentista
- Gate progressivo: modal ganha "Lembrar mais tarde" e reaparece a cada login;
  a partir da 4ª aparição vira bloqueio até preencher (contador em localStorage
  por clínica; ref evita contagem dupla no StrictMode).
- Agenda passa a ser exigida/exibida só para DENTISTA (quem atende). Backend:
  comunicacaoCompleto(p, ehDentista) via vinculos.role; GET devolve `role`.
  Não-dentista (dono/funcionário) completa só com endereço + números.

Verificado: dono completa sem agenda; dentista exige agenda; role no GET ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:40:54 +02:00
VPS 4 Builder 9fd078e36f feat(comunicacao-interna): pré-preenche endereço a partir da clínica
Perfil sem endereço próprio → GET /api/comunicacao sugere o endereço já cadastrado
da clínica (só confirmar, não redigitar) + flag endereco_da_clinica. Modal mostra
aviso "puxamos o endereço da clínica — é só conferir". Resolve a fricção do dono,
cuja clínica já tem endereço.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:18:01 +02:00
VPS 4 Builder 1e73a0c01f feat(agenda-bridge): proxima_janela_ts p/ agendar o envio diferido
/dentista-contato passa a devolver proxima_janela_ts (ISO do início da próxima janela)
além do label — o motor usa como fire_at do envio diferido.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:39:24 +02:00
VPS 4 Builder 727d0a245d feat(agenda-bridge): enforcement de janela ao contatar dentista
/dentista-contato passa a ler pessoa_comunicacao (via dentistas.usuario_id):
- Com perfil: só devolve número se a hora atual cair numa janela permitida
  (contato_agora); senão bloqueado_por_janela=true + proxima_janela ("sábado às 08:00").
- Sem perfil: back-compat — usa telefone legado e não bloqueia.
Helpers contatoPermitidoAgora/proximaJanela (TZ do container = America/Sao_Paulo).

Verificado E2E: fora da janela -> bloqueado; dentro -> libera nº; sem perfil -> ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:42:11 +02:00
VPS 4 Builder d33ebd3f76 feat(comunicacao-interna): entrada na Gestão de Equipe
Botão "Comunicação interna" em cada membro (gestores) abre o mesmo modal full-screen
para preencher/editar o perfil de contato daquele usuário (usuarioId = m.id).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:16:50 +02:00
VPS 4 Builder dcf33befec feat(comunicacao-interna): perfil de contato de não-pacientes + gate obrigatório
Base da "comunicação interna": cada não-paciente (dentista/funcionário) cadastra
endereço, MÚLTIPLOS números de WhatsApp e a disponibilidade de agenda.

Backend:
- Tabela pessoa_comunicacao (usuario_id+clinica_id): endereco/numeros/agenda JSONB + completo.
- GET/PUT /api/comunicacao/:usuarioId ('me' = usuário atual). Autoriza self OU gestor
  (dono/admin). Recalcula `completo` no PUT (regra: endereço + ≥1 número com descrição
  e ≥1 janela de horário + agenda decidida; se aberta, exige ≥1 janela).

Frontend:
- ComunicacaoInternaModal (full-screen): Endereço (com ViaCEP), Números (cada um com
  descrição + situações + janelas de horário permitido p/ a Secretária enviar msg),
  Agenda (abrir/fechar + horários de atendimento). Editor de janelas reutilizável.
- Gate no App.tsx: todo não-paciente com clínica ativa cujo perfil está incompleto vê
  o modal FORÇADO em todas as telas (espelha o gate de troca de senha) até completar.

Verificado E2E: PUT incompleto->completo=false; PUT completo->true; GET reflete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:13:14 +02:00
VPS 4 Builder 3096db150d feat(agenda-bridge): ciclo de vida da consulta p/ a Secretária IA
Novos endpoints server-to-server (auth x-nw-agenda-secret), presos ao clinica_id
e ao telefone do contato (posse verificada — impede mexer na consulta de terceiros):

- GET  /consultas  — consultas do telefone/família (futuras por padrão; incluir_passadas)
- POST /confirmar  — marca presença (status=confirmado)
- POST /cancelar   — cancela e libera o horário (status=cancelado + motivo em observacoes)
- POST /remarcar   — move para novo slot com anti-overbooking (findConflict exclui a própria)

Verificado E2E: GET /consultas -> 200 com dentista/procedimento/status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:38:36 +02:00
VPS 4 Builder 343ca560dc feat(salas): contrato de locação — modelo padrão + aceite na reserva
Backend:
- Nova coluna salas.contrato (TEXT, migration ADD COLUMN IF NOT EXISTS).
- INSERT/UPDATE de sala aceitam `contrato`; marketplace SELECT devolve s.contrato
  (minhas salas já via s.*).

Frontend (SalasPlugin):
- CONTRATO_MODELO: modelo padrão cuja cláusula 2 deixa TODOS os procedimentos sob
  inteira responsabilidade do locatário; o dono edita por sala.
- SalaForm ganha textarea "Contrato de locação" pré-preenchida com o modelo +
  botão "Restaurar modelo".
- Passo Pagamento: botão "Ler contrato de locação" abre modal com o texto
  (contrato da sala, ou o modelo) + checkbox de aceite. "Solicitar Reserva" só
  habilita após aceitar; o aceite é registrado nas observações da reserva.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:10:40 +02:00
VPS 4 Builder d6aa1cdeac feat(salas): modelo "Dia" com seleção de dias avulsos (não-consecutivos)
Antes o modelo Dia só permitia um intervalo consecutivo (início + N dias).
Agora permite escolher dias avulsos (ex.: 1 e 5, 3 e 20):

- Clique no calendário alterna (marca/desmarca) cada dia — estado diasMulti[].
- Card mostra chips dos dias selecionados (removíveis; dia ocupado fica âmbar) +
  dica "cada dia vira uma reserva (08h–18h)".
- Total = valor_diaria × nº de dias; resumo lista os dias.
- handleReserve envia uma reserva por dia; reporta quantas foram criadas e
  quantas falharam (ex.: já ocupado).
- Travas de navegação/botão passam a usar temSelecao (Dia não usa f.inicio).
- Semana/Mês seguem com stepper de quantidade; Hora/Período inalterados.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 04:25:49 +02:00
VPS 4 Builder c78c745f3f feat(salas): reserva de múltiplos dias/semanas/meses + controles visíveis
Calendário (passo 2):
- qtd vira "quantidade" genérica por modelo: Hora=horas (1–12), Dia=dias (1–30),
  Semana=semanas (1–8), Mês=meses (1–12). computeRange estende o fim conforme a
  quantidade; total = valor × quantidade (Turno segue unitário).
- O intervalo escolhido (diária/semanal/mensal) é realçado no calendário e some
  ao trocar de modelo (qtd volta a 1).
- Controles (Início/Duração/Período/Quantidade) que ficavam escondidos abaixo do
  calendário agora vivem num card destacado, com cabeçalho do dia/intervalo
  selecionado e auto-scroll (scrollIntoView) ao escolher o dia.
- Resumo ganha linha "Quantidade" e a nota do total é genérica (N un × valor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 04:14:50 +02:00
VPS 4 Builder 8ec1e6204d feat(salas): move o modelo de locação para o passo "Dados"
- Seletor de modelo (Hora/Período/Dia/Semana/Mês) sai do passo Calendário e
  passa a ficar no passo Dados — o usuário escolhe o modelo primeiro, depois
  o calendário/horário reagem à escolha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 04:02:24 +02:00
VPS 4 Builder 69a0bce4d7 fix(salas): renomeia modelos (Hora/Período/Dia/Semana/Mês) + calendário completo
- Rótulos dos modelos: Turno→Período, Diária→Dia, Semanal→Semana, Mensal→Mês
  (ids inalterados). Label do controle "Turno" → "Período".
- Calendário cortado no wizard: as células aspect-square deixavam a grade alta
  demais p/ a área do slide; troca p/ altura fixa h-9 — a grade fica compacta e
  aparece completa.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 03:51:26 +02:00
VPS 4 Builder f24a5a82e8 feat(salas): reserva vira wizard de 4 passos em slide
Modal de reservar sala reorganizado num wizard (formato slide, translateX) com
stepper clicável p/ voltar:
1) Dados — reservar como (pessoal/unidade) + profissional + observações.
2) Calendário — modelo (cartões c/ preço) + calendário mensal + horário/turno.
3) Resumo — resumo detalhado (sala, modelo, período, total) + histórico de
   reservas da sala.
4) Pagamento — total, forma de pagamento (PIX/Dinheiro/Transferência/Cartão) e
   como funciona; a forma escolhida vai nas observações do pedido.
Navegação Voltar/Próximo (Próximo bloqueado no calendário até escolher o dia).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 03:46:02 +02:00
VPS 4 Builder dab74de207 feat(salas): modal de reserva com calendário mensal + seleção de modelo por cartão
Substitui os dois datetime-local crus por um calendário mensal profissional
(navegação de mês, dia selecionável, dias passados desabilitados, dias ocupados
com marcador, destaque de intervalo p/ semanal/mensal). Modelo de locação vira
grade de cartões com o preço de cada (Hora/Turno/Diária/Semanal/Mensal, só os que
a sala oferece). Controles conforme o modelo: hora (início + duração), turno
(manhã/tarde/noite); diária/semanal/mensal calculam o período. Resumo do período
+ ocupados do dia selecionado. início/fim derivados do calendário (backend igual).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 03:34:36 +02:00
VPS 4 Builder 5a790ff000 fix(salas): alugar-salas ↔ minhas-salas não trocava o conteúdo (só a URL)
As duas rotas renderizam o mesmo SalasPlugin com prop `view` diferente; o `tab`
vinha do useState (só no mount) e não reagia à troca de `view`. Agora um efeito
reseta a aba ativa quando `view` muda.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 03:07:02 +02:00
VPS 4 Builder 29f9139b6c feat(ui): centraliza estilo de filtros/busca (components/filterStyles) + SearchInput
Fonte única para os campos de filtro (inputNew/labelNew) e um componente
SearchInput padrão (ícone + input arredondado, encaminha ref/props). Profissionais
e Salas passam a importar do módulo (dedupe). Aplica SearchInput na busca do
Orçamentos como 1ª adoção. Buscas que são autocomplete com dropdown (Pacientes,
Relatórios) ficam para conversão cuidadosa caso a caso (não quebrar o dropdown).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 02:36:17 +02:00
VPS 4 Builder e0b37797b9 feat(salas): filtros do alugar-salas iguais ao marketplace de profissionais + fix própria sala
- Área de filtros de /alugar-salas restilizada igual ao marketplace de
  profissionais: card com busca no topo, filtros recolhíveis (Ocultar/Mostrar),
  slider de raio com badge KM, barra de ações (Limpar + Buscar) e busca por
  texto (nome/cidade/bairro) sobre o resultado.
- Fix: o marketplace listava as PRÓPRIAS salas (e as da unidade ativa) — agora
  exclui owner_usuario_id do usuário e clinica_id da unidade ativa. Não se aluga
  a própria sala.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 02:26:22 +02:00
VPS 4 Builder 041803ebb3 feat(ui): padroniza abas no segmented control (componente SegmentedTabs)
Cria components/SegmentedTabs.tsx (controle segmentado padrão, responsivo, com
cor de destaque + ícone + badge) e converte as barras de aba que usavam sublinhado
ou pills soltos: Comissões, Glosas, Lab Clientes, Prótese (OS + cotações),
Contratos, Profissionais (marketplace) e HorariosConfig. Telas que já usavam o
visual (Gestão de Equipe, Notificações, RX, Salas) ficam como referência.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:26:28 +02:00
VPS 4 Builder 3ca89ab21c feat(salas): melhora visual e distribuição de Minhas Salas / Alugar / Marketplace
- Abas viram segmented control: no mobile ocupam a largura toda e distribuem igual;
  no desktop encolhem ao conteúdo (some quando há só 1 aba).
- "Cadastrar Sala" ganha toolbar (contagem à esquerda, botão à direita no desktop,
  largura cheia no mobile) em vez de botão solto.
- Filtros do marketplace: grid 2/3/6 colunas (distribui uniforme) e BUSCAR full-width
  no mobile.
- Cards de sala: 3 colunas em telas largas (xl).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:01:51 +02:00
VPS 4 Builder ffcace0133 fix(superadmin): item "Secretária IA" faltava no allowlist do menu do superadmin
O menuItems do Sidebar filtra por um allowlist de ids p/ o superadmin, e
'superadmin-secretaria' não estava incluído — por isso a página de motivos não
aparecia no menu. Adicionado ao allowlist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:55:44 +02:00
VPS 4 Builder 65244c983f feat(superadmin+wa-secretaria): motivos do botão "desativar para todos os chats"
Botão global no /wa-secretaria agora abre modal com textarea de motivo (obrigatório
p/ desativar) + histórico, em vez de um confirm. Página super admin "Secretária IA"
passa a listar os motivos por ESCOPO: "Todos os chats" (global) e "Uma sessão",
mesclando os dois logs do motor (/secretaria/power/log + /session-power/log).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:49:34 +02:00
VPS 4 Builder d8d8617a6c fix(wa-inbox): esvazia a lista de chats quando não há instância ativa
Complementa o fix anterior: no mount, loadChats podia rodar com o activeInstanceId
persistido (stale) antes de loadInstances validar, deixando os chats na tela mesmo
após o reset. Agora o efeito faz setChats([]) quando não há instância ativa.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:24:05 +02:00
VPS 4 Builder f343d8e7a7 fix(wa-inbox): não vazar sessão/chats de outro usuário (limpa estado local no logout)
O /wa-inbox persistia activeInstanceId + inboxCache:v1:* no localStorage e o logout
não limpava — então o próximo login (ex.: superadmin) herdava a instância e via os
chats antigos de outra sessão. Agora: clearInboxLocalState() no logout, na expiração
de sessão e na troca de workspace; e loadInstances reseta activeInstanceId se ele não
estiver na lista de instâncias acessíveis (guarda defensiva).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:21:25 +02:00
VPS 4 Builder b1ddfbf349 fix(superadmin): página Secretária IA mostra só motivo/ação/quando (sem dados da clínica)
Remove colunas Número e Quem (telefone/pessoa da clínica) da tabela — a página é
contexto GERAL para melhorar a IA, não para ver dados de uma clínica. Ajusta o texto.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:15:22 +02:00
ruicesar 8cac06e37c Merge pull request 'feat(superadmin): página Secretária IA (motivos de liga/desliga por sessão)' (#5) from feat/newwhats-satelite into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m0s
2026-07-06 20:14:08 +02:00
ruicesar 1e16edf2be Merge pull request 'feat/fix: dashboard scope + agenda UI + newwhats (encaixe, session-power, papel)' (#4) from feat/newwhats-satelite into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m0s
2026-07-06 19:42:57 +02:00
ruicesar 7a5f5cf0fa Merge pull request 'fix(newwhats): proxy.js/server.js do auto-provisionamento (corrige 502 em prod)' (#3) from fix/newwhats-proxy-exports into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 54s
2026-07-06 16:35:08 +02:00
ruicesar 30ea2f0e10 Merge pull request 'feat(secretaria): agente/cérebro por número na aba Números' (#2) from feat/newwhats-satelite into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 52s
2026-07-06 16:07:07 +02:00
63 changed files with 4354 additions and 526 deletions
+443 -11
View File
@@ -10,6 +10,7 @@
// POST /book — cria agendamento real (anti-overbooking + vínculo de paciente)
import express from 'express';
import { getConfig } from './config.js';
import { validarCpfCnpj, validarNascimento } from './validacao.js';
import { cautelasParaIA } from './checklist-defs.js';
import { resolverFeriado, feriadoNacionalNome } from './feriados.js';
@@ -123,6 +124,73 @@ async function findConflict(db, dentistaId, start, end, excludeId = null) {
return rows[0] || null;
}
// ── Fuso horário por clínica ─────────────────────────────────────────────────────
// Brasil não tem horário de verão → offset estável, dispensa lib de TZ.
const DOW_MAP = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
async function getClinicaTZ(pool, clinicaId) {
try { const { rows } = await pool.query('SELECT timezone FROM clinicas WHERE id=$1', [clinicaId]); return rows[0]?.timezone || 'America/Sao_Paulo'; }
catch { return 'America/Sao_Paulo'; }
}
// "Agora" no fuso da clínica → { year, month, day, hour, minute, dow, hm }.
function partsNow(tz, base = new Date()) {
const f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, weekday: 'short', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
const p = {}; for (const x of f.formatToParts(base)) p[x.type] = x.value;
const hour = p.hour === '24' ? 0 : +p.hour;
return { year: +p.year, month: +p.month, day: +p.day, hour, minute: +p.minute, dow: DOW_MAP[p.weekday], hm: hour * 60 + (+p.minute) };
}
// Offset do fuso (min) num instante.
function tzOffsetMin(tz, date) {
const f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
const p = {}; for (const x of f.formatToParts(date)) p[x.type] = x.value;
const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, (p.hour === '24' ? 0 : +p.hour), +p.minute, +p.second);
return Math.round((asUTC - date.getTime()) / 60000);
}
// Instante UTC de um wall-clock (y, mon, d, h, min) no fuso tz.
function zonedToUtc(y, mon, d, h, min, tz) {
const guess = Date.UTC(y, mon - 1, d, h, min, 0);
return new Date(guess - tzOffsetMin(tz, new Date(guess)) * 60000);
}
// ── Comunicação interna: janelas de horário permitido p/ contatar um número ──────
const toMin = (t) => { const [h, m] = String(t ?? '').split(':').map(Number); return (h || 0) * 60 + (m || 0); };
// clock = partsNow(tz) da clínica. Retorna o 1º número contactável AGORA ou null.
function contatoPermitidoAgora(numeros, clock) {
const dow = clock.dow, hm = clock.hm;
for (const n of (numeros || [])) {
const tel = String(n?.numero || '').replace(/\D/g, '');
if (tel.length < 10) continue;
for (const j of (n.janelas || [])) {
if (!Array.isArray(j.dias) || !j.dias.includes(dow)) continue;
if (hm >= toMin(j.inicio) && hm <= toMin(j.fim)) return { numero: tel, descricao: n.descricao || null, situacoes: n.situacoes || null };
}
}
return null;
}
// Próxima janela permitida → { label, ts:ISO } | null. ts é o instante REAL no fuso tz.
function proximaJanela(numeros, clock, tz) {
const nowDow = clock.dow, nowHm = clock.hm;
let best = null; // { offset, ini, dow, inicio }
for (const n of (numeros || [])) {
if (String(n?.numero || '').replace(/\D/g, '').length < 10) continue;
for (const j of (n.janelas || [])) {
if (!Array.isArray(j.dias)) continue;
for (const d of j.dias) {
let offset = (d - nowDow + 7) % 7;
const ini = toMin(j.inicio);
if (offset === 0 && ini <= nowHm) offset = 7; // hoje já passou → próxima semana
if (!best || offset < best.offset || (offset === best.offset && ini < best.ini)) best = { offset, ini, dow: d, inicio: j.inicio };
}
}
}
if (!best) return null;
const quando = best.offset === 0 ? 'hoje' : best.offset === 1 ? 'amanhã' : DIAS_NOME[best.dow];
// data-alvo = hoje (da clínica) + offset dias, resolvida como instante no fuso tz.
const td = new Date(Date.UTC(clock.year, clock.month - 1, clock.day)); td.setUTCDate(td.getUTCDate() + best.offset);
const [h, m] = String(best.inicio).split(':').map(Number);
const ts = zonedToUtc(td.getUTCFullYear(), td.getUTCMonth() + 1, td.getUTCDate(), h || 0, m || 0, tz).toISOString();
return { label: `${quando} às ${best.inicio}`, ts };
}
export function createAgendaBridge(pool) {
const router = express.Router();
@@ -166,7 +234,13 @@ export function createAgendaBridge(pool) {
// Folgas por data dos dentistas (férias/indisponibilidade).
const folgasBy = await carregarFolgas(pool, clinicaId);
const start0 = new Date(from); start0.setHours(0, 0, 0, 0);
// "Hoje" e "agora" no fuso da CLÍNICA (não do container). Os slots são rótulos
// wall-clock (fmt); comparamos contra o "agora" da clínica expresso no mesmo frame.
const tz = await getClinicaTZ(pool, clinicaId);
const cn = partsNow(tz);
const start0 = req.query.date ? new Date(`${req.query.date}T00:00:00`) : new Date(cn.year, cn.month - 1, cn.day);
start0.setHours(0, 0, 0, 0);
const nowClinic = new Date(cn.year, cn.month - 1, cn.day, cn.hour, cn.minute, 0, 0);
const end0 = new Date(start0); end0.setDate(end0.getDate() + days);
const { rows: busy } = await pool.query(
`SELECT dentistaid, start_time, end_time FROM agendamentos
@@ -176,7 +250,7 @@ export function createAgendaBridge(pool) {
const busyBy = new Map();
for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); }
const now = new Date();
const now = nowClinic;
const slots = [];
for (let i = 0; i < days && slots.length < CANDIDATE_CAP; i++) {
const day = new Date(start0); day.setDate(day.getDate() + i);
@@ -265,10 +339,11 @@ export function createAgendaBridge(pool) {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
try {
// "Hoje" no fuso de Brasília (o servidor pode rodar em UTC).
// "Hoje" no fuso da CLÍNICA (o servidor pode rodar em outro fuso).
const tz = await getClinicaTZ(pool, clinicaId);
const hojeBR = req.query.date
? String(req.query.date)
: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Sao_Paulo' }); // YYYY-MM-DD
: new Date().toLocaleDateString('en-CA', { timeZone: tz }); // YYYY-MM-DD
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
const dateAt = (str) => new Date(`${str}T12:00:00`); // meio-dia: evita borda de fuso
const addYmd = (str, k) => { const d = dateAt(str); d.setDate(d.getDate() + k); return ymd(d); };
@@ -310,7 +385,7 @@ export function createAgendaBridge(pool) {
if (nac) feriados.push({ data: str, nome: nac, origem: 'nacional' });
}
res.json({ hoje, proximo_aberto: proximo, semana, feriados_proximos: feriados.slice(0, 8) });
res.json({ hoje, proximo_aberto: proximo, semana, feriados_proximos: feriados.slice(0, 8), timezone: tz });
} catch (e) {
res.status(500).json({ error: e.message });
}
@@ -377,6 +452,12 @@ export function createAgendaBridge(pool) {
const { rows } = await pool.query(`SELECT texto FROM agenda_situacoes WHERE clinica_id = $1 AND ativo = 1 ORDER BY ordem, created_at`, [clinicaId]);
situacoes = rows.map((r) => r.texto);
} catch { /* tabela pode não existir ainda */ }
// Procedimentos que a clínica NÃO atende → a Secretária responde com o script.
let procedimentos_nao_atende = [];
try {
const { rows } = await pool.query(`SELECT nome, motivo, como_falar FROM agenda_procedimentos WHERE clinica_id = $1 AND atende = 0 ORDER BY ordem, nome`, [clinicaId]);
procedimentos_nao_atende = rows.map((r) => ({ nome: r.nome, motivo: r.motivo || null, como_falar: r.como_falar || null }));
} catch { /* tabela pode não existir ainda */ }
// Cautelas do checklist do dono (declaração × dados reais) → a IA não chuta.
let cautelas = [];
try {
@@ -392,6 +473,7 @@ export function createAgendaBridge(pool) {
especialidades: Array.isArray(d.especialidades) ? d.especialidades : (d.especialidade ? [d.especialidade] : []),
})),
situacoes,
procedimentos_nao_atende,
cautelas,
});
} catch (e) { res.status(500).json({ error: e.message }); }
@@ -408,11 +490,121 @@ export function createAgendaBridge(pool) {
if (req.query.dentista_id) { params.push(String(req.query.dentista_id)); where += ` AND id = $${params.length}`; }
else if (req.query.nome) { params.push(String(req.query.nome)); where += ` AND lower(nome) LIKE '%'||lower($${params.length})||'%'`; }
const { rows } = await pool.query(
`SELECT id, nome, telefone FROM dentistas WHERE ${where} AND ativo IS DISTINCT FROM 0 ORDER BY nome LIMIT 1`, params);
`SELECT id, nome, telefone, usuario_id FROM dentistas WHERE ${where} AND ativo IS DISTINCT FROM 0 ORDER BY nome LIMIT 1`, params);
if (!rows.length) return res.json({ encontrado: false });
const d = rows[0];
const tel = String(d.telefone || '').replace(/\D/g, '');
res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, telefone: tel || null });
const legacyTel = String(d.telefone || '').replace(/\D/g, '') || null;
// Perfil de comunicação interna (múltiplos números + janelas de horário permitido).
let numeros = [], temPerfil = false;
if (d.usuario_id) {
const { rows: pc } = await pool.query('SELECT numeros FROM pessoa_comunicacao WHERE usuario_id=$1 AND clinica_id=$2', [d.usuario_id, clinicaId]);
if (pc[0]) { temPerfil = true; numeros = Array.isArray(pc[0].numeros) ? pc[0].numeros : []; }
}
// Sem perfil → back-compat: usa o telefone legado e NÃO bloqueia (não quebra dentistas antigos).
if (!temPerfil) {
return res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, telefone: legacyTel, tem_perfil: false,
contato_agora: legacyTel ? { numero: legacyTel, descricao: null } : null, bloqueado_por_janela: false, proxima_janela: null });
}
// Com perfil → só permite contato dentro de uma janela; senão devolve a próxima.
const tz = await getClinicaTZ(pool, clinicaId); const clock = partsNow(tz);
const agora = contatoPermitidoAgora(numeros, clock);
const prox = agora ? null : proximaJanela(numeros, clock, tz);
res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, tem_perfil: true,
telefone: agora ? agora.numero : null, contato_agora: agora,
bloqueado_por_janela: !agora, proxima_janela: prox ? prox.label : null, proxima_janela_ts: prox ? prox.ts : null });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /responsavel-contato ────────────────────────────────────────────────────
// Resolve o contato do DONO/RESPONSÁVEL p/ uma decisão da clínica, com a janela.
// nome? = pessoa específica (da lista de contatos — cobre o caso do dono cujo número
// está na conta de dentista); sem nome = dono/responsável por papel. Mesma forma do
// /dentista-contato (contato_agora / bloqueado_por_janela / proxima_janela[_ts]).
router.get('/responsavel-contato', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
try {
let row = null;
if (req.query.nome) {
const { rows } = await pool.query(
`SELECT pc.usuario_id, pc.numeros, u.nome, v.role
FROM pessoa_comunicacao pc
LEFT JOIN usuarios u ON u.id = pc.usuario_id
LEFT JOIN vinculos v ON v.usuario_id = pc.usuario_id AND v.clinica_id = pc.clinica_id
WHERE pc.clinica_id = $1 AND lower(u.nome) LIKE '%'||lower($2)||'%' LIMIT 1`,
[clinicaId, String(req.query.nome)]);
row = rows[0] || null;
}
if (!row) {
const { rows } = await pool.query(
`SELECT pc.usuario_id, pc.numeros, u.nome, v.role
FROM pessoa_comunicacao pc
JOIN vinculos v ON v.usuario_id = pc.usuario_id AND v.clinica_id = pc.clinica_id
LEFT JOIN usuarios u ON u.id = pc.usuario_id
WHERE pc.clinica_id = $1 AND v.role IN ('donoclinica','donoconsultorio','admin')
ORDER BY v.role LIMIT 1`,
[clinicaId]);
row = rows[0] || null;
}
if (!row) return res.json({ encontrado: false });
const numeros = Array.isArray(row.numeros) ? row.numeros : [];
const tz = await getClinicaTZ(pool, clinicaId); const clock = partsNow(tz);
const agora = contatoPermitidoAgora(numeros, clock);
const prox = agora ? null : proximaJanela(numeros, clock, tz);
res.json({ encontrado: true, usuario_id: row.usuario_id, nome: row.nome, papel: row.role,
telefone: agora ? agora.numero : null, contato_agora: agora,
bloqueado_por_janela: !agora, proxima_janela: prox ? prox.label : null, proxima_janela_ts: prox ? prox.ts : null });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /numeros-internos ───────────────────────────────────────────────────────
// Só os dígitos dos números de pessoa_comunicacao da clínica (dentistas/equipe). O
// motor usa p/ NÃO tratar um número interno como paciente (anti-loop/leak).
router.get('/numeros-internos', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
try {
const { rows } = await pool.query('SELECT numeros FROM pessoa_comunicacao WHERE clinica_id = $1', [clinicaId]);
const set = new Set();
for (const r of rows) for (const n of (Array.isArray(r.numeros) ? r.numeros : [])) {
const d = String(n?.numero || '').replace(/\D/g, '');
if (d.length >= 8) set.add(d);
}
res.json({ numeros: [...set] });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /contatos ─────────────────────────────────────────────────────────────
// "Agenda de contatos internos" p/ roteamento por SITUAÇÃO. Lista todas as pessoas da
// clínica (dono/dentista/funcionário) com o papel, as situações de cada número e se pode
// ser contatada agora. A Secretária escolhe QUEM acionar pelo ASSUNTO (encaixe→dentista;
// decisão/desconto/financeiro→dono). Não expõe o número cru (o envio é via /dentista-contato).
router.get('/contatos', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
try {
const { rows } = await pool.query(
`SELECT pc.usuario_id, pc.numeros, v.role, u.nome
FROM pessoa_comunicacao pc
LEFT JOIN vinculos v ON v.usuario_id = pc.usuario_id AND v.clinica_id = pc.clinica_id
LEFT JOIN usuarios u ON u.id = pc.usuario_id
WHERE pc.clinica_id = $1`, [clinicaId]);
const tz = await getClinicaTZ(pool, clinicaId); const clock = partsNow(tz);
const contatos = [];
for (const r of rows) {
const nums = Array.isArray(r.numeros) ? r.numeros : [];
for (const n of nums) {
if (String(n.numero || '').replace(/\D/g, '').length < 10) continue;
const agora = contatoPermitidoAgora([n], clock);
const prox = agora ? null : proximaJanela([n], clock, tz);
contatos.push({
nome: r.nome || null, papel: r.role || null,
descricao: n.descricao || null, situacoes: n.situacoes || null,
contato_agora: !!agora, proxima_janela: prox ? prox.label : null,
});
}
}
res.json({ total: contatos.length, contatos });
} catch (e) { res.status(500).json({ error: e.message }); }
});
@@ -627,6 +819,19 @@ export function createAgendaBridge(pool) {
router.post('/pacientes', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.nome) return res.status(400).json({ error: 'clinica_id e nome obrigatórios' });
// Validação server-side (dígito verificador REAL): a Ana relata ok:false p/ corrigir.
if (b.cpf) {
const d = validarCpfCnpj(b.cpf);
if (!d.valido) return res.json({ ok: false, campo: 'cpf', mensagem: 'O CPF/CNPJ informado não é válido. Peça para a pessoa conferir e reenviar.' });
}
if (b.data_nascimento) {
const n = validarNascimento(b.data_nascimento);
if (!n.valido) {
const msg = n.motivo === 'futura' ? 'A data de nascimento está no futuro.' : n.motivo === 'muito_antiga' ? 'A data de nascimento não parece plausível.' : 'A data de nascimento não é uma data válida.';
return res.json({ ok: false, campo: 'data_nascimento', mensagem: `${msg} Peça para conferir e reenviar (DD/MM/AAAA).` });
}
b.data_nascimento = n.iso; // normaliza p/ YYYY-MM-DD antes de gravar
}
const cpf = soDigitos(b.cpf);
const telefone = b.telefone ? soDigitos(b.telefone) : null;
const idade = idadeDe(b.data_nascimento);
@@ -668,19 +873,22 @@ export function createAgendaBridge(pool) {
await client.query(`INSERT INTO grupos_familiares (id, nome, responsavel_id, created_at) VALUES ($1,$2,$3,NOW()) ON CONFLICT (id) DO NOTHING`, [grupoId, responsavel.nome, responsavel.id]);
}
// 4) Atualiza (dedup) OU cria.
// dados_confirmados=false: cadastro veio da Secretária (IA) → a recepção CONFIRMA
// no check-in (Feature C). O modal de confirmação some quando a humana valida.
if (existente) {
await client.query(
`UPDATE pacientes SET nome=$2, telefone=COALESCE($3,telefone), email=COALESCE($4,email),
convenio=COALESCE($5,convenio), datanascimento=COALESCE($6,datanascimento),
grupofamiliarid=COALESCE($7,grupofamiliarid), grupofamiliarrelacao=COALESCE($8,grupofamiliarrelacao) WHERE id=$1`,
grupofamiliarid=COALESCE($7,grupofamiliarid), grupofamiliarrelacao=COALESCE($8,grupofamiliarrelacao),
dados_confirmados=false WHERE id=$1`,
[existente.id, b.nome, telFinal, b.email || null, b.convenio || null, b.data_nascimento || null, grupoId, relacao]);
await client.query('COMMIT');
return res.json({ ok: true, atualizado: true, paciente: { id: existente.id, nome: b.nome, relacao, grupo_id: grupoId, idade } });
}
const id = `pac_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await client.query(
`INSERT INTO pacientes (id, clinica_id, nome, cpf, telefone, email, convenio, datanascimento, grupofamiliarid, grupofamiliarrelacao)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
`INSERT INTO pacientes (id, clinica_id, nome, cpf, telefone, email, convenio, datanascimento, grupofamiliarid, grupofamiliarrelacao, dados_confirmados)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,false)`,
[id, b.clinica_id, b.nome, cpf || null, telFinal, b.email || null, b.convenio || null, b.data_nascimento || null, grupoId, relacao]);
await client.query('COMMIT');
res.json({ ok: true, criado: true, paciente: { id, nome: b.nome, cpf: cpf || null, relacao, grupo_id: grupoId, idade, dependente: ehDependente } });
@@ -690,5 +898,229 @@ export function createAgendaBridge(pool) {
} finally { client.release(); }
});
// ── Helper: confirma que o agendamento pertence ao telefone (segurança) ──────
// Impede a IA de cancelar/remarcar a consulta de terceiros só pelo id.
async function agendamentoDoTelefone(db, clinicaId, agendamentoId, phone) {
const tel = soDigitos(phone).slice(-8);
const { rows } = await db.query(
`SELECT a.id, a.dentistaid, a.pacientenome, a.procedimento, a.status, a.sala_id,
to_char(a.start_time,'YYYY-MM-DD') AS data, to_char(a.start_time,'HH24:MI') AS hora,
regexp_replace(coalesce(a.pacientecelular,''),'\\D','','g') AS ag_tel,
regexp_replace(coalesce(p.telefone,''),'\\D','','g') AS pac_tel,
d.nome AS dentista
FROM agendamentos a
LEFT JOIN pacientes p ON p.id = a.pacienteid
LEFT JOIN dentistas d ON d.id = a.dentistaid
WHERE a.id = $1 AND a.clinica_id = $2 LIMIT 1`,
[agendamentoId, clinicaId]);
const a = rows[0];
if (!a) return { ok: false, motivo: 'nao_encontrado' };
if (tel && !(String(a.ag_tel).endsWith(tel) || String(a.pac_tel).endsWith(tel))) {
return { ok: false, motivo: 'nao_pertence' };
}
return { ok: true, ag: a };
}
const statusInativo = (s) => ['cancelado', 'falta', 'faltou'].includes(String(s || '').toLowerCase());
// ── GET /consultas ────────────────────────────────────────────────────────────
// Consultas do telefone/família. Futuras por padrão; incluir_passadas=true traz o histórico.
router.get('/consultas', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
const tel = soDigitos(req.query.phone).slice(-8);
if (!clinicaId || !tel) return res.status(400).json({ error: 'clinica_id e phone obrigatórios' });
const incluirPassadas = String(req.query.incluir_passadas || '') === 'true';
try {
const { rows } = await pool.query(
`SELECT a.id, to_char(a.start_time,'YYYY-MM-DD') AS data, to_char(a.start_time,'HH24:MI') AS hora,
to_char(a.end_time,'HH24:MI') AS hora_fim, a.status, a.procedimento, a.pacientenome,
d.nome AS dentista
FROM agendamentos a
LEFT JOIN dentistas d ON d.id = a.dentistaid
LEFT JOIN pacientes p ON p.id = a.pacienteid
WHERE a.clinica_id = $1
AND ( regexp_replace(coalesce(a.pacientecelular,''),'\\D','','g') LIKE '%'||$2
OR regexp_replace(coalesce(p.telefone,''),'\\D','','g') LIKE '%'||$2 )
AND lower(coalesce(a.status,'agendado')) NOT IN ('cancelado','falta','faltou')
${incluirPassadas ? '' : "AND a.start_time >= NOW() - interval '2 hours'"}
ORDER BY a.start_time ASC LIMIT 20`,
[clinicaId, tel]);
res.json({
total: rows.length,
consultas: rows.map((r) => ({
agendamento_id: r.id, data: r.data, hora: r.hora, hora_fim: r.hora_fim,
dentista: r.dentista || null, procedimento: r.procedimento || 'Consulta',
status: r.status || 'agendado', paciente: r.pacientenome || null,
})),
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /confirmar ───────────────────────────────────────────────────────────
// Paciente confirma presença numa consulta futura. Body: clinica_id, agendamento_id, phone.
router.post('/confirmar', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.agendamento_id) return res.status(400).json({ error: 'clinica_id e agendamento_id obrigatórios' });
try {
const chk = await agendamentoDoTelefone(pool, b.clinica_id, b.agendamento_id, b.phone || '');
if (!chk.ok) return res.status(chk.motivo === 'nao_encontrado' ? 404 : 403).json({ error: chk.motivo === 'nao_encontrado' ? 'Consulta não encontrada.' : 'Esta consulta não é deste número.' });
if (statusInativo(chk.ag.status)) return res.status(409).json({ error: 'Consulta não está ativa.' });
await pool.query(
`UPDATE agendamentos SET status='confirmado', updated_by_nome='Secretária IA', updated_at=NOW(), version=COALESCE(version,1)+1 WHERE id=$1`,
[b.agendamento_id]);
res.json({ ok: true, agendamento_id: b.agendamento_id, status: 'confirmado', data: chk.ag.data, hora: chk.ag.hora, dentista: chk.ag.dentista || null });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /cancelar ────────────────────────────────────────────────────────────
// Body: clinica_id, agendamento_id, phone, motivo?
router.post('/cancelar', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.agendamento_id) return res.status(400).json({ error: 'clinica_id e agendamento_id obrigatórios' });
try {
const chk = await agendamentoDoTelefone(pool, b.clinica_id, b.agendamento_id, b.phone || '');
if (!chk.ok) return res.status(chk.motivo === 'nao_encontrado' ? 404 : 403).json({ error: chk.motivo === 'nao_encontrado' ? 'Consulta não encontrada.' : 'Esta consulta não é deste número.' });
if (statusInativo(chk.ag.status)) return res.status(409).json({ error: 'Consulta já estava cancelada/inativa.' });
const motivo = b.motivo ? String(b.motivo).slice(0, 300) : null;
await pool.query(
`UPDATE agendamentos
SET status='cancelado', canceled_by_nome='Secretária IA', canceled_at=NOW(),
version=COALESCE(version,1)+1,
observacoes = CASE WHEN $2::text IS NULL THEN observacoes
ELSE trim(both ' ' from coalesce(observacoes,'') || ' | Cancelado via WhatsApp: ' || $2) END
WHERE id=$1`,
[b.agendamento_id, motivo]);
res.json({ ok: true, agendamento_id: b.agendamento_id, status: 'cancelado', data: chk.ag.data, hora: chk.ag.hora, dentista: chk.ag.dentista || null });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /remarcar ────────────────────────────────────────────────────────────
// Move a consulta para um novo horário livre. Body: clinica_id, agendamento_id, phone,
// dentista_id, start (ISO), end?, sala_id?. Anti-overbooking (exclui a própria consulta).
router.post('/remarcar', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.agendamento_id || !b.dentista_id || !b.start) {
return res.status(400).json({ error: 'clinica_id, agendamento_id, dentista_id e start obrigatórios' });
}
const end = b.end || new Date(new Date(b.start).getTime() + SLOT_MIN * 60000).toISOString();
const client = await pool.connect();
try {
const chk = await agendamentoDoTelefone(client, b.clinica_id, b.agendamento_id, b.phone || '');
if (!chk.ok) { client.release(); return res.status(chk.motivo === 'nao_encontrado' ? 404 : 403).json({ error: chk.motivo === 'nao_encontrado' ? 'Consulta não encontrada.' : 'Esta consulta não é deste número.' }); }
if (statusInativo(chk.ag.status)) { client.release(); return res.status(409).json({ error: 'Consulta não está ativa para remarcar.' }); }
await client.query('BEGIN');
const conflict = await findConflict(client, b.dentista_id, b.start, end, b.agendamento_id);
if (conflict) { await client.query('ROLLBACK'); client.release(); return res.status(409).json({ error: 'Novo horário já ocupado.', conflict_id: conflict.id }); }
const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id=$1 AND clinica_id=$2', [b.dentista_id, b.clinica_id]);
const setorId = dd[0]?.setor_id ?? null;
const { rows: upd } = await client.query(
`UPDATE agendamentos
SET dentistaid=$2, start_time=$3, end_time=$4, status='agendado', setor_id=COALESCE($5, setor_id),
sala_id=COALESCE($6, sala_id), updated_by_nome='Secretária IA', updated_at=NOW(),
version=COALESCE(version,1)+1
WHERE id=$1 RETURNING id, to_char(start_time,'YYYY-MM-DD') AS data, to_char(start_time,'HH24:MI') AS hora`,
[b.agendamento_id, b.dentista_id, b.start, end, setorId, b.sala_id || null]);
await client.query('COMMIT');
const { rows: dn } = await pool.query('SELECT nome FROM dentistas WHERE id=$1', [b.dentista_id]);
res.json({ ok: true, agendamento_id: b.agendamento_id, status: 'agendado', data: upd[0]?.data, hora: upd[0]?.hora, dentista: dn[0]?.nome || null });
} catch (e) {
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
res.status(500).json({ error: e.message });
} finally { try { client.release(); } catch { /* já liberado */ } }
});
// ── GET /paciente-contexto ────────────────────────────────────────────────────
// Contexto INTERNO do paciente p/ a Secretária: dentista responsável + se há
// tratamento em andamento. NÃO é p/ recitar ao cliente — serve p/ dar continuidade
// (agendar com o MESMO dentista) sem ficar falando de tratamento nem repetir nomes.
router.get('/paciente-contexto', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
const pacienteId = req.query.paciente_id ? String(req.query.paciente_id) : null;
const tel = soDigitos(req.query.phone).slice(-8);
// Inadimplente = parcela (RECEITA) vencida há mais de N dias e ainda em aberto.
// N vem do motor (config do dono); default 30. Falta recente = no-show nos últimos 60 dias.
const inadDias = Math.max(0, parseInt(String(req.query.inadimplente_dias || '30'), 10) || 30);
if (!clinicaId || (!pacienteId && !tel)) return res.status(400).json({ error: 'clinica_id e (paciente_id ou phone) obrigatórios' });
try {
let pacientes;
if (pacienteId) {
const { rows } = await pool.query('SELECT id, nome FROM pacientes WHERE id=$1 AND clinica_id=$2', [pacienteId, clinicaId]);
pacientes = rows;
} else {
const { rows } = await pool.query(
`SELECT id, nome FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2`, [clinicaId, tel]);
pacientes = rows;
}
const out = [];
for (const p of pacientes) {
// Orçamento/tratamento em aberto → dentista responsável + em_tratamento.
const { rows: orc } = await pool.query(
`SELECT dentista_nome, dentista_id FROM orcamentos
WHERE paciente_id=$1 AND lower(coalesce(status,'')) NOT IN ('concluido','cancelado','recusado','rejeitado','finalizado')
ORDER BY created_at DESC NULLS LAST LIMIT 1`, [p.id]).catch(() => ({ rows: [] }));
let dentistaNome = orc[0]?.dentista_nome || null;
let dentistaId = orc[0]?.dentista_id || null;
let emTratamento = orc.length > 0;
if (!dentistaNome) {
const { rows: ag } = await pool.query(
`SELECT a.dentistaid, d.nome AS dentista, (a.start_time >= NOW() - interval '120 days') AS recente
FROM agendamentos a LEFT JOIN dentistas d ON d.id=a.dentistaid
WHERE a.pacienteid=$1 AND lower(coalesce(a.status,'agendado')) NOT IN ('cancelado','falta','faltou')
ORDER BY a.start_time DESC LIMIT 1`, [p.id]).catch(() => ({ rows: [] }));
dentistaNome = ag[0]?.dentista || null; dentistaId = ag[0]?.dentistaid || null;
if (ag[0]?.recente) emTratamento = true;
}
// Inadimplência: parcelas RECEITA vencidas há > N dias, ainda em aberto
// (pago_em nulo e status não-pago). Resumo é INTERNO (nunca recitado ao paciente).
const { rows: inad } = await pool.query(
`SELECT COUNT(*)::int AS n, COALESCE(SUM(valor),0) AS total, MIN(datavencimento) AS mais_antiga
FROM financeiro
WHERE paciente_id=$1 AND clinica_id=$2 AND deleted_at IS NULL
AND upper(coalesce(tipo,'RECEITA'))='RECEITA'
AND pago_em IS NULL
AND lower(coalesce(status,'')) NOT IN ('pago','recebido','cancelado')
AND datavencimento < (CURRENT_DATE - ($3 || ' days')::interval)`,
[p.id, clinicaId, inadDias]).catch(() => ({ rows: [{ n: 0, total: 0, mais_antiga: null }] }));
const inadN = inad[0]?.n || 0;
const inadimplente = inadN > 0;
const inadimplencia_resumo = inadimplente
? `${inadN} parcela(s) vencida(s) há +${inadDias}d, total R$ ${Number(inad[0].total).toFixed(2)}, mais antiga venc. ${String(inad[0].mais_antiga).slice(0, 10)}`
: null;
// Falta recente (no-show) nos últimos 60 dias.
const { rows: falta } = await pool.query(
`SELECT COUNT(*)::int AS n FROM agendamentos
WHERE pacienteid=$1 AND lower(coalesce(status,'')) IN ('falta','faltou')
AND start_time >= NOW() - interval '60 days'`, [p.id]).catch(() => ({ rows: [{ n: 0 }] }));
const falta_recente = (falta[0]?.n || 0) > 0;
out.push({ paciente_id: p.id, nome: p.nome, dentista_responsavel: dentistaNome, dentista_id: dentistaId, em_tratamento: emTratamento, inadimplente, inadimplencia_resumo, falta_recente });
}
res.json({ pacientes: out });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /notificar ───────────────────────────────────────────────────────────
// Cria uma notificação in-app para a EQUIPE da clínica (todos os vínculos). Usado
// pela Secretária p/ avisar a recepção de eventos que exigem humano (ex.: cliente
// insistiu num dentista específico). Server-to-server (x-nw-agenda-secret).
router.post('/notificar', async (req, res) => {
const { clinica_id, titulo, mensagem, tipo } = req.body || {};
if (!clinica_id || !titulo) return res.status(400).json({ error: 'clinica_id e titulo obrigatórios' });
const t = ['sucesso', 'info', 'alerta', 'erro', 'aviso'].includes(String(tipo)) ? String(tipo) : 'alerta';
try {
const { rows } = await pool.query('SELECT DISTINCT usuario_id FROM vinculos WHERE clinica_id = $1', [clinica_id]);
let n = 0;
for (const r of rows) {
if (!r.usuario_id) continue;
await pool.query(
`INSERT INTO notificacoes (id, titulo, mensagem, tipo, lida, data, usuario_id, enviado_por)
VALUES (gen_random_uuid(), $1, $2, $3, 0, NOW(), $4, 'secretaria')`,
[String(titulo).slice(0, 120), String(mensagem || '').slice(0, 500), t, r.usuario_id]);
n++;
}
res.json({ ok: true, notificados: n });
} catch (e) { res.status(500).json({ error: e.message }); }
});
return router;
}
+91 -2
View File
@@ -243,6 +243,50 @@ export function createAgendaConfig(pool) {
finally { client.release(); }
});
// ── Procedimentos que a clínica atende / não atende (dono) ────────────────────
router.get('/clinica/:clinicaId/procedimentos-atende', async (req, res) => {
const clinicaId = req.params.clinicaId;
try {
let { rows } = await pool.query(
`SELECT id, nome, atende, motivo, como_falar, ordem FROM agenda_procedimentos WHERE clinica_id = $1 ORDER BY ordem, nome`, [clinicaId]);
// Primeira vez: semeia a lista a partir do catálogo de procedimentos da clínica (atende=1).
if (!rows.length) {
const { rows: cat } = await pool.query(
`SELECT DISTINCT nome FROM procedimentos WHERE (clinica_id = $1 OR clinica_id IS NULL) AND ativo IS DISTINCT FROM false AND nome IS NOT NULL AND nome <> '' ORDER BY nome`, [clinicaId]);
const client = await pool.connect();
try {
await client.query('BEGIN');
let i = 0;
for (const c of cat) await client.query(`INSERT INTO agenda_procedimentos (id, clinica_id, nome, atende, ordem) VALUES ($1,$2,$3,1,$4)`, [uid('prc'), clinicaId, c.nome, i++]);
await client.query('COMMIT');
} catch { try { await client.query('ROLLBACK'); } catch {} } finally { client.release(); }
({ rows } = await pool.query(`SELECT id, nome, atende, motivo, como_falar, ordem FROM agenda_procedimentos WHERE clinica_id = $1 ORDER BY ordem, nome`, [clinicaId]));
}
res.json({ procedimentos: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Substitui toda a lista. Body: { procedimentos: [{nome, atende, motivo, como_falar}] }
router.put('/clinica/:clinicaId/procedimentos-atende', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar os procedimentos.' });
const linhas = Array.isArray(req.body?.procedimentos) ? req.body.procedimentos : [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM agenda_procedimentos WHERE clinica_id = $1', [clinicaId]);
let i = 0;
for (const p of linhas) {
const nome = String(p?.nome || '').trim(); if (!nome) continue;
const atende = (p?.atende === false || p?.atende === 0) ? 0 : 1;
await client.query(
`INSERT INTO agenda_procedimentos (id, clinica_id, nome, atende, motivo, como_falar, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[uid('prc'), clinicaId, nome, atende, atende ? null : (String(p?.motivo || '').trim() || null), atende ? null : (String(p?.como_falar || '').trim() || null), i++]);
}
await client.query('COMMIT'); res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// ── Checklist do DONO + pendências (só o dono do workspace) ───────────────────
// Conta dentistas/pacientes reais da clínica p/ conferir contra as respostas.
async function contagens(clinicaId) {
@@ -349,8 +393,18 @@ export function createAgendaConfig(pool) {
const dentistaId = req.body?.dentista_id || ped.dentista_id;
if (!dentistaId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Escolha o dentista para confirmar.' }); }
const dur = Math.max(10, Number(req.body?.duracao_min) || 30);
const hi = String(ped.hora_inicio).slice(0, 5);
const start = `${(ped.data instanceof Date ? ped.data.toISOString().slice(0, 10) : String(ped.data).slice(0, 10))} ${hi}:00`;
// Horário pode ser AJUSTADO na confirmação: a IA fixou um horário, mas a humana
// combinou outro com o paciente por telefone. Aceita HH:MM válido; senão usa o do pedido.
const hi = /^([01]\d|2[0-3]):[0-5]\d$/.test(String(req.body?.hora_inicio || ''))
? String(req.body.hora_inicio)
: String(ped.hora_inicio).slice(0, 5);
// Dia pode ser TROCADO na confirmação: o paciente pediu outro dia. Aceita YYYY-MM-DD
// válido e não-passado; senão usa o do pedido.
const pedData = (ped.data instanceof Date ? ped.data.toISOString().slice(0, 10) : String(ped.data).slice(0, 10));
const reqData = String(req.body?.data || '');
const hojeStr = new Date().toISOString().slice(0, 10);
const dataConf = (/^\d{4}-\d{2}-\d{2}$/.test(reqData) && reqData >= hojeStr) ? reqData : pedData;
const start = `${dataConf} ${hi}:00`;
const endD = new Date(`${start.replace(' ', 'T')}`); endD.setMinutes(endD.getMinutes() + dur);
const end = `${start.slice(0, 10)} ${String(endD.getHours()).padStart(2, '0')}:${String(endD.getMinutes()).padStart(2, '0')}:00`;
const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id = $1', [dentistaId]);
@@ -400,5 +454,40 @@ export function createAgendaConfig(pool) {
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Nomes NOSSOS (base de pacientes) por telefone — p/ o /wa-inbox mostrar o nome que
// cadastramos no lugar do número/pushname. Casa pelos últimos 8 dígitos. Um número
// pode ter VÁRIOS pacientes (grupo familiar): devolve todos + um `principal`
// (1 paciente → o nome; N → "1º nome +N"). Titular designado virá na Feature B.
router.post('/clinica/:clinicaId/nomes-por-telefone', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await ehMembro(clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
const lista = Array.isArray(req.body?.telefones) ? req.body.telefones : [];
const chaves = [...new Set(lista.map((t) => String(t || '').replace(/\D/g, '').slice(-8)).filter((k) => k.length >= 8))];
if (chaves.length === 0) return res.json({ mapa: {} });
try {
const { rows } = await pool.query(
`SELECT id, nome, grupofamiliarrelacao,
right(regexp_replace(coalesce(telefone,''),'\\D','','g'), 8) AS chave
FROM pacientes
WHERE clinica_id = $1
AND right(regexp_replace(coalesce(telefone,''),'\\D','','g'), 8) = ANY($2::text[])
ORDER BY nome`,
[clinicaId, chaves]);
const mapa = {};
for (const p of rows) {
(mapa[p.chave] ??= { pacientes: [] }).pacientes.push({ id: p.id, nome: p.nome, relacao: p.grupofamiliarrelacao || null });
}
for (const k of Object.keys(mapa)) {
const ps = mapa[k].pacientes;
mapa[k].count = ps.length;
// Principal: se há titular no grupo, mostra o titular; senão "1º nome +N".
const titular = ps.find((x) => String(x.relacao || '').toLowerCase() === 'titular');
if (titular) mapa[k].principal = ps.length <= 1 ? titular.nome : `${titular.nome.split(' ')[0]} +${ps.length - 1}`;
else { const primeiro = ps[0]?.nome || ''; mapa[k].principal = ps.length <= 1 ? primeiro : `${primeiro.split(' ')[0]} +${ps.length - 1}`; }
}
res.json({ mapa });
} catch (e) { res.status(500).json({ error: e.message }); }
});
return router;
}
+15
View File
@@ -98,6 +98,21 @@ export async function ensureAgendaSchema(pool) {
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_situacoes_clinica ON agenda_situacoes (clinica_id)`);
// ── agenda_procedimentos — o que a clínica ATENDE ou não. Desmarcado (atende=0)
// = não atende: guarda o motivo e o "como_falar" (script p/ a Secretária responder).
await pool.query(`
CREATE TABLE IF NOT EXISTS agenda_procedimentos (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
nome text NOT NULL,
atende smallint DEFAULT 1,
motivo text,
como_falar text,
ordem integer DEFAULT 0,
created_at timestamptz DEFAULT now()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_procedimentos_clinica ON agenda_procedimentos (clinica_id)`);
// ── agenda_checklist — respostas Sim/Não do DONO sobre a clínica (ver
// checklist-defs.js). Conferidas contra o banco → geram pendências de config
// e deixam a Secretária cautelosa. resposta: 1=sim, 0=não, NULL=não respondido.
+24 -10
View File
@@ -60,18 +60,19 @@ async function resolveOwner(pool, clinicaId) {
// Retorna { ok: true } ou { ok: false, status, error }.
async function guardSessionAction(pool, req, tail, userId) {
const method = req.method.toUpperCase();
const m = tail.match(/^\/sessions(?:\/([^/?]+)(\/qr|\/connect)?)?/);
const m = tail.match(/^\/sessions(?:\/([^/?]+)(\/qr|\/connect|\/disconnect)?)?/);
if (!m) return { ok: true }; // não é uma rota de sessão
const instanceId = m[1] || null;
const sub = m[2] || null;
const isCreate = method === 'POST' && !instanceId; // POST /sessions
const isRescan = (method === 'GET' && sub === '/qr') ||
(method === 'POST' && sub === '/connect'); // parear/reconectar
const isDelete = method === 'DELETE' && instanceId && !sub; // DELETE /sessions/:id
const isCreate = method === 'POST' && !instanceId; // POST /sessions
const isRescan = (method === 'GET' && sub === '/qr') ||
(method === 'POST' && sub === '/connect'); // parear/reconectar
const isDisconnect = method === 'POST' && sub === '/disconnect'; // desligar a sessão (gestão da conexão)
const isDelete = method === 'DELETE' && instanceId && !sub; // DELETE /sessions/:id
if (!isCreate && !isRescan && !isDelete) return { ok: true }; // GET lista / etc → livre
if (!isCreate && !isRescan && !isDisconnect && !isDelete) return { ok: true }; // GET lista / etc → livre
const clinicaId = req.headers['x-nw-clinica'] ? String(req.headers['x-nw-clinica']) : null;
if (!clinicaId) return { ok: false, status: 403, error: 'Workspace não identificado para esta ação.' };
@@ -92,14 +93,17 @@ async function guardSessionAction(pool, req, tail, userId) {
authz = rows[0] || null;
} catch { /* nega por padrão */ }
if (isRescan && authz?.can_rescan) return { ok: true };
// Desconectar é gestão da conexão (o inverso de reconectar) → mesma permissão de re-scan.
if ((isRescan || isDisconnect) && authz?.can_rescan) return { ok: true };
if (isDelete && authz?.can_delete) return { ok: true };
return {
ok: false, status: 403,
error: isDelete
? 'Somente o dono pode excluir esta sessão. Peça autorização de exclusão.'
: 'Somente o dono pode reconectar esta sessão. Peça autorização de re-scan.',
: isDisconnect
? 'Somente o dono pode desconectar esta sessão. Peça autorização de re-scan.'
: 'Somente o dono pode reconectar esta sessão. Peça autorização de re-scan.',
};
}
@@ -152,8 +156,11 @@ export async function operatorSignature(pool, userId, clinicaId) {
// Gate de acesso a ÁREAS (Inbox / Secretária). Dono passa sempre; os demais só com
// delegação (can_inbox / can_secretaria) do dono em alguma sessão do workspace.
async function guardAreaAccess(pool, tail, userId, clinicaId) {
const isSecretaria = /^\/(secretaria|sec)(\/|$|\?)/.test(tail);
const isInbox = /^\/(inbox|conversations)(\/|$|\?)/.test(tail);
// /sec/handoff = assumir/soltar o CHAT (ação de INBOX), não config da Secretária.
// O resto de /sec/* e todo /secretaria/* é config (agents/nodes/slots/power) = Secretária.
const isHandoff = /^\/sec\/handoff(\/|$|\?)/.test(tail);
const isSecretaria = !isHandoff && /^\/(secretaria|sec)(\/|$|\?)/.test(tail);
const isInbox = isHandoff || /^\/(inbox|conversations)(\/|$|\?)/.test(tail);
if (!isSecretaria && !isInbox) return { ok: true };
if (!pool || !clinicaId) return { ok: true }; // sem workspace resolvido, não barra aqui
const ownerId = await resolveOwnerId(pool, clinicaId);
@@ -208,6 +215,13 @@ export function createRestProxy(pool) {
const area = await guardAreaAccess(pool, tail, userId, clinicaId);
if (!area.ok) return res.status(area.status).json({ error: area.error });
// Só o DONO liga/desliga a Secretária (global / por sessão / por chat), quando
// quiser. Fail-closed: os demais nem veem o controle na UI e aqui levam 403.
if (req.method === 'POST' && /^\/secretaria\/(power|session-power|chat-power)(\/|$|\?)/.test(tail)) {
const owner = clinicaId ? await resolveOwner(pool, clinicaId) : null;
if (!owner || owner.ownerId !== userId) return res.status(403).json({ error: 'Apenas o dono do workspace pode ligar/desligar a Secretária.' });
}
// Gate de ações destrutivas do inbox (apagar conversa/mensagem).
const destructive = await guardInboxDestructive(pool, req.method, tail, userId, clinicaId);
if (!destructive.ok) return res.status(destructive.status).json({ error: destructive.error });
+68
View File
@@ -0,0 +1,68 @@
// Validação de documentos e data de nascimento para o cadastro de pacientes.
// CPF/CNPJ com dígito verificador REAL (não só formato); nascimento com data válida,
// não-futura e idade plausível. Usado no cadastro via Secretária (server-side).
const soDigitos = (s) => String(s || '').replace(/\D/g, '');
// ── CPF ──────────────────────────────────────────────────────────────────────
function validarCPF(entrada) {
const cpf = soDigitos(entrada);
if (cpf.length !== 11) return false;
if (/^(\d)\1{10}$/.test(cpf)) return false; // 000..., 111..., etc.
const dv = (base, pesoIni) => {
let soma = 0;
for (let i = 0; i < base.length; i++) soma += Number(base[i]) * (pesoIni - i);
const r = (soma * 10) % 11;
return r === 10 ? 0 : r;
};
const d1 = dv(cpf.slice(0, 9), 10);
const d2 = dv(cpf.slice(0, 10), 11);
return d1 === Number(cpf[9]) && d2 === Number(cpf[10]);
}
// ── CNPJ ─────────────────────────────────────────────────────────────────────
function validarCNPJ(entrada) {
const cnpj = soDigitos(entrada);
if (cnpj.length !== 14) return false;
if (/^(\d)\1{13}$/.test(cnpj)) return false;
const dv = (len) => {
const pesos = len === 12 ? [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] : [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
let soma = 0;
for (let i = 0; i < len; i++) soma += Number(cnpj[i]) * pesos[i];
const r = soma % 11;
return r < 2 ? 0 : 11 - r;
};
return dv(12) === Number(cnpj[12]) && dv(13) === Number(cnpj[13]);
}
// CPF (11) ou CNPJ (14). Retorna { valido, tipo }.
function validarCpfCnpj(entrada) {
const d = soDigitos(entrada);
if (d.length === 11) return { valido: validarCPF(d), tipo: 'cpf' };
if (d.length === 14) return { valido: validarCNPJ(d), tipo: 'cnpj' };
return { valido: false, tipo: null };
}
// ── Data de nascimento ─────────────────────────────────────────────────────
// Aceita DD/MM/AAAA, DD-MM-AAAA ou AAAA-MM-DD. Retorna { valido, iso, motivo }.
// Rejeita data inexistente (31/02), futura, e idade fora de 0..120 anos.
function validarNascimento(entrada) {
const s = String(entrada || '').trim();
let y, m, d;
let mt;
if ((mt = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/))) { y = +mt[1]; m = +mt[2]; d = +mt[3]; }
else if ((mt = s.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/))) { d = +mt[1]; m = +mt[2]; y = +mt[3]; }
else return { valido: false, motivo: 'formato' };
if (m < 1 || m > 12 || d < 1 || d > 31) return { valido: false, motivo: 'invalida' };
const dt = new Date(Date.UTC(y, m - 1, d));
// Confere que a data existe de fato (evita 31/02, 30/02, etc.).
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) return { valido: false, motivo: 'invalida' };
const hoje = new Date();
if (dt.getTime() > hoje.getTime()) return { valido: false, motivo: 'futura' };
const idade = (hoje - dt) / (365.25 * 24 * 3600 * 1000);
if (idade > 120) return { valido: false, motivo: 'muito_antiga' };
const iso = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
return { valido: true, iso };
}
export { soDigitos, validarCPF, validarCNPJ, validarCpfCnpj, validarNascimento };
+242 -12
View File
@@ -13,6 +13,7 @@ import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { registerNewwhats, createWsTunnel, provisionNewwhatsAccountEager } from './newwhats/index.js';
import { operatorSignature } from './newwhats/proxy.js';
import { validarCpfCnpj, validarNascimento } from './newwhats/validacao.js';
const { Pool } = pg;
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
@@ -884,7 +885,7 @@ app.post('/api/clinicas/:clinicaId/membros', tenantGuard, requireCapability('ges
});
app.put('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
const { role, setor_id, funcao_id } = req.body;
const { role, setor_id, funcao_id, nome } = req.body;
try {
const reqNivel = await nivelNaClinica(req.authUser.userId, req.params.clinicaId);
const alvoNivel = await nivelNaClinica(req.params.userId, req.params.clinicaId);
@@ -893,6 +894,11 @@ app.put('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, requireCapabili
}
await pool.query('UPDATE vinculos SET role = COALESCE($1, role), setor_id = $2, funcao_id = $3 WHERE usuario_id = $4 AND clinica_id = $5',
[role || null, setor_id || null, funcao_id || null, req.params.userId, req.params.clinicaId]);
// Nome do titular da conta (usado, entre outros, na assinatura das mensagens do
// WhatsApp). Editável pelo dono p/ quando a pessoa da conta muda (ex.: troca de secretária).
if (typeof nome === 'string' && nome.trim()) {
await pool.query('UPDATE usuarios SET nome = $1 WHERE id = $2', [nome.trim().toUpperCase(), req.params.userId]);
}
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
@@ -1016,6 +1022,33 @@ app.put('/api/clinicas/:id/color', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Fusos horários válidos do Brasil (whitelist — evita injeção de TZ inválido).
const TIMEZONES_BR = [
'America/Sao_Paulo', 'America/Campo_Grande', 'America/Cuiaba', 'America/Manaus',
'America/Rio_Branco', 'America/Belem', 'America/Fortaleza', 'America/Recife',
'America/Bahia', 'America/Boa_Vista', 'America/Porto_Velho', 'America/Maceio', 'America/Noronha',
];
app.get('/api/clinicas/:id/timezone', authGuard, async (req, res) => {
try {
const { rows } = await pool.query('SELECT timezone FROM clinicas WHERE id = $1', [req.params.id]);
res.json({ timezone: rows[0]?.timezone || 'America/Sao_Paulo', opcoes: TIMEZONES_BR });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/clinicas/:id/timezone', authGuard, async (req, res) => {
const tz = String(req.body?.timezone || '');
if (!TIMEZONES_BR.includes(tz)) return res.status(400).json({ error: 'Fuso horário inválido.' });
// Só dono/admin da unidade altera o fuso.
const { rows: _own } = await pool.query(
`SELECT 1 FROM clinicas WHERE id=$1 AND owner_id=$2
UNION SELECT 1 FROM vinculos WHERE clinica_id=$1 AND usuario_id=$2 AND role = ANY(ARRAY['donoclinica','donoconsultorio','admin']) LIMIT 1`,
[req.params.id, req.authUser?.userId]);
if (!_own.length) return res.status(403).json({ error: 'Apenas o dono/admin pode alterar o fuso horário.' });
try {
await pool.query('UPDATE clinicas SET timezone = $1 WHERE id = $2', [tz, req.params.id]);
res.json({ success: true, timezone: tz });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Renomear a unidade (clínica/consultório/laboratório). Só o dono (ou vínculo dono/admin).
app.put('/api/clinicas/:id/nome', authGuard, async (req, res) => {
const nome = (req.body?.nome_fantasia || '').trim();
@@ -2143,6 +2176,43 @@ app.put('/api/pacientes/:id', authGuard, requireCapabilityRow('agenda', 'pacient
}
});
// Paciente único (p/ o modal de confirmação no check-in — Feature C).
app.get('/api/pacientes/:id', authGuard, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, nome, cpf, telefone, datanascimento, dados_confirmados,
grupofamiliarid, grupofamiliarrelacao, clinica_id FROM pacientes WHERE id=$1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Paciente não encontrado.' });
if (!await userHasVinculo(req.authUser?.userId, rows[0].clinica_id)) return res.status(403).json({ error: 'Acesso negado.' });
res.json({ paciente: rows[0] });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Confirma os dados do paciente no check-in (recepção): valida CPF/CNPJ + nascimento,
// atualiza nome/cpf/telefone/nascimento e marca dados_confirmados=true. O `telefone`
// pode ser o número PRÓPRIO deste paciente (dependente que ganhou WhatsApp próprio).
app.post('/api/pacientes/:id/confirmar-dados', authGuard, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => {
try {
const { rows: pc } = await pool.query('SELECT clinica_id FROM pacientes WHERE id=$1', [req.params.id]);
if (!pc.length) return res.status(404).json({ error: 'Paciente não encontrado.' });
if (!await userHasVinculo(req.authUser?.userId, pc[0].clinica_id)) return res.status(403).json({ error: 'Acesso negado.' });
const { nome, cpf, telefone } = req.body || {};
if (cpf) { const d = validarCpfCnpj(cpf); if (!d.valido) return res.json({ ok: false, campo: 'cpf', mensagem: 'CPF/CNPJ inválido — confira e corrija.' }); }
let nascIso = null;
if (req.body?.data_nascimento) {
const n = validarNascimento(req.body.data_nascimento);
if (!n.valido) return res.json({ ok: false, campo: 'data_nascimento', mensagem: 'Data de nascimento inválida — confira (DD/MM/AAAA).' });
nascIso = n.iso;
}
await pool.query(
`UPDATE pacientes SET nome=COALESCE($2,nome), cpf=COALESCE($3,cpf), telefone=COALESCE($4,telefone),
datanascimento=COALESCE($5,datanascimento), dados_confirmados=true WHERE id=$1`,
[req.params.id, nome || null, cpf || null, telefone || null, nascIso]);
schedulePush('pacientes');
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- DENTISTAS ---
app.get('/api/dentistas', authGuard, async (req, res) => {
try {
@@ -6175,6 +6245,23 @@ app.post('/api/pedidos-exame', authGuard, async (req, res) => {
async function runMigrations() {
const migrations = [
// Fuso horário por workspace (clínica) — usado p/ a Secretária e a agenda operarem
// no horário local da unidade. Default = Brasília (comportamento atual).
`ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS timezone TEXT DEFAULT 'America/Sao_Paulo'`,
// Comunicação interna: perfil de contato de cada NÃO-paciente (dentista/funcionário).
// Números de WhatsApp múltiplos (cada um com descrição, situações e janelas de horário
// permitido p/ a Secretária enviar msg — evita mensagem fora do expediente), endereço e
// disponibilidade de agenda (abrir/fechar). Chave por (usuario_id, clinica_id).
`CREATE TABLE IF NOT EXISTS pessoa_comunicacao (
usuario_id TEXT NOT NULL,
clinica_id TEXT NOT NULL,
endereco JSONB NOT NULL DEFAULT '{}'::jsonb,
numeros JSONB NOT NULL DEFAULT '[]'::jsonb,
agenda JSONB NOT NULL DEFAULT '{}'::jsonb,
completo BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (usuario_id, clinica_id)
)`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS grupofamiliarid TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS grupofamiliarrelacao TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS indicadorporid TEXT`,
@@ -6186,6 +6273,9 @@ async function runMigrations() {
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS cidade TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS estado TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS cep TEXT`,
// Feature C (check-in): cadastro da Secretária entra como não-confirmado
// (default true p/ os já existentes; a Secretária grava false ao cadastrar).
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS dados_confirmados BOOLEAN DEFAULT true`,
`CREATE TABLE IF NOT EXISTS modelos_receita (
id TEXT PRIMARY KEY,
nome TEXT NOT NULL,
@@ -6700,6 +6790,7 @@ async function runMigrations() {
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS diaria_consulta BOOLEAN DEFAULT false`,
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS semanal_consulta BOOLEAN DEFAULT false`,
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS mensal_consulta BOOLEAN DEFAULT false`,
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS contrato TEXT`,
`UPDATE salas s SET area = CASE u.role WHEN 'biomedico' THEN 'biomedicina' WHEN 'protetico' THEN 'protese' ELSE 'odontologia' END
FROM usuarios u WHERE u.id = s.owner_usuario_id AND s.area IS NULL`,
`UPDATE salas SET area = 'odontologia' WHERE area IS NULL`,
@@ -7538,6 +7629,131 @@ async function resolveOrigem(req, cep, clinicaId) {
return null;
}
// ─── Comunicação interna (perfil de contato de não-pacientes) ────────────────────
// Regra de completude que destrava o gate: endereço + ≥1 número (com descrição e ≥1
// janela de horário) + disponibilidade de agenda decidida.
function comunicacaoCompleto(p, opts) {
const { ehDentista, jornadaOk, ehDono, horarioClinicaOk } = opts || {};
const e = p.endereco || {};
const nums = Array.isArray(p.numeros) ? p.numeros : [];
const enderecoOk = !!(e.cidade && e.estado && (e.cep || e.logradouro));
const numerosOk = nums.length >= 1 && nums.every((n) =>
n && String(n.numero || '').replace(/\D/g, '').length >= 10 && String(n.descricao || '').trim() &&
Array.isArray(n.janelas) && n.janelas.length >= 1);
// Agenda = fonte única. DENTISTA exige jornada real (dentistas_horarios); DONO exige o
// horário de funcionamento da CLÍNICA (clinicas_horarios) — ambos em "Horários de Funcionamento".
const jornadaExigidaOk = !ehDentista || !!jornadaOk;
const clinicaExigidaOk = !ehDono || !!horarioClinicaOk;
return enderecoOk && numerosOk && jornadaExigidaOk && clinicaExigidaOk;
}
async function papelNaClinica(usuarioId, clinicaId) {
const { rows } = await pool.query('SELECT role FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [usuarioId, clinicaId]);
return rows[0]?.role || '';
}
// Jornada de atendimento do dentista já configurada? (fonte real usada pela Secretária)
async function jornadaConfigurada(usuarioId, clinicaId) {
const { rows: dr } = await pool.query('SELECT id FROM dentistas WHERE usuario_id=$1 AND clinica_id=$2 LIMIT 1', [usuarioId, clinicaId]);
const dentId = dr[0]?.id;
if (!dentId) return false;
const { rows } = await pool.query('SELECT 1 FROM dentistas_horarios WHERE dentista_id=$1 AND COALESCE(ativo,1) <> 0 LIMIT 1', [dentId]);
return rows.length > 0;
}
// Horário de FUNCIONAMENTO da clínica já configurado?
async function clinicaHorariosConfigurados(clinicaId) {
const { rows } = await pool.query('SELECT 1 FROM clinicas_horarios WHERE clinica_id=$1 AND COALESCE(ativo,1) <> 0 LIMIT 1', [clinicaId]);
return rows.length > 0;
}
const EH_DONO = (role) => ['donoclinica', 'donoconsultorio'].includes(role);
// Clínica do usuário: param explícito → primeiro vínculo.
async function resolveClinicaUsuario(userId, clinicaIdParam) {
if (clinicaIdParam) return String(clinicaIdParam);
const { rows } = await pool.query('SELECT clinica_id FROM vinculos WHERE usuario_id = $1 ORDER BY id LIMIT 1', [userId]);
return rows[0]?.clinica_id || null;
}
// Só o próprio, ou um gestor (dono/admin) da mesma clínica, edita o perfil de outro.
async function podeEditarComunicacao(reqUserId, alvoUserId, clinicaId) {
if (reqUserId === alvoUserId) return true;
const { rows } = await pool.query('SELECT role FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [reqUserId, clinicaId]);
return ['donoclinica', 'donoconsultorio', 'admin'].includes(rows[0]?.role || '');
}
// Lookup: este número já está cadastrado por OUTRA pessoa da clínica? (notificação de
// número compartilhado — a mesma pessoa pode ter contas/papéis diferentes).
// Definido ANTES de /:usuarioId para não ser capturado pela rota-parâmetro.
app.get('/api/comunicacao/numero-lookup', authGuard, async (req, res) => {
try {
const clinicaId = String(req.query.clinicaId || '');
const tel = String(req.query.numero || '').replace(/\D/g, '').slice(-8);
if (!clinicaId || tel.length < 8) return res.json({ encontrados: [] });
const { rows } = await pool.query(
`SELECT pc.usuario_id, pc.numeros, u.nome, v.role
FROM pessoa_comunicacao pc
LEFT JOIN usuarios u ON u.id = pc.usuario_id
LEFT JOIN vinculos v ON v.usuario_id = pc.usuario_id AND v.clinica_id = pc.clinica_id
WHERE pc.clinica_id = $1 AND pc.usuario_id <> $2`,
[clinicaId, req.authUser.userId]);
const encontrados = [];
for (const r of rows) {
const nums = Array.isArray(r.numeros) ? r.numeros : [];
if (nums.some((n) => String(n.numero || '').replace(/\D/g, '').slice(-8) === tel)) {
encontrados.push({ usuario_id: r.usuario_id, nome: r.nome, role: r.role });
}
}
res.json({ encontrados });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/comunicacao/:usuarioId', authGuard, async (req, res) => {
try {
const alvo = req.params.usuarioId === 'me' ? req.authUser.userId : req.params.usuarioId;
const clinicaId = await resolveClinicaUsuario(alvo, req.query.clinicaId);
if (!clinicaId) return res.json({ usuario_id: alvo, clinica_id: null, endereco: {}, numeros: [], agenda: {}, completo: true, sem_clinica: true });
if (!(await podeEditarComunicacao(req.authUser.userId, alvo, clinicaId))) return res.status(403).json({ error: 'Sem permissão.' });
const { rows } = await pool.query('SELECT endereco, numeros, agenda, completo FROM pessoa_comunicacao WHERE usuario_id=$1 AND clinica_id=$2', [alvo, clinicaId]);
const p = rows[0] || { endereco: {}, numeros: [], agenda: {}, completo: false };
// Perfil ainda sem endereço próprio → sugere o endereço da CLÍNICA (só confirmar, não redigitar).
let enderecoDaClinica = false;
if (!p.endereco || !p.endereco.cidade) {
const { rows: cr } = await pool.query('SELECT logradouro, numero, bairro, cidade, estado, cep FROM clinicas WHERE id=$1', [clinicaId]);
if (cr[0] && (cr[0].cidade || cr[0].logradouro)) { p.endereco = { ...cr[0], ...(p.endereco || {}) }; enderecoDaClinica = true; }
}
const role = await papelNaClinica(alvo, clinicaId);
const ehDentista = role === 'dentista';
const ehDono = EH_DONO(role);
const jornadaOk = ehDentista ? await jornadaConfigurada(alvo, clinicaId) : true;
const horarioClinicaOk = ehDono ? await clinicaHorariosConfigurados(clinicaId) : true;
// `completo` recomputado AO VIVO (jornada/horário podem ter sido configurados fora do modal).
const completoLive = comunicacaoCompleto({ endereco: p.endereco, numeros: p.numeros }, { ehDentista, jornadaOk, ehDono, horarioClinicaOk });
res.json({ usuario_id: alvo, clinica_id: clinicaId, ...p, completo: completoLive, endereco_da_clinica: enderecoDaClinica, role, jornada_configurada: jornadaOk, horario_clinica_configurado: horarioClinicaOk });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.put('/api/comunicacao/:usuarioId', authGuard, async (req, res) => {
try {
const alvo = req.params.usuarioId === 'me' ? req.authUser.userId : req.params.usuarioId;
const b = req.body || {};
const clinicaId = await resolveClinicaUsuario(alvo, b.clinicaId || req.query.clinicaId);
if (!clinicaId) return res.status(400).json({ error: 'Clínica não encontrada para o usuário.' });
if (!(await podeEditarComunicacao(req.authUser.userId, alvo, clinicaId))) return res.status(403).json({ error: 'Sem permissão.' });
const endereco = b.endereco && typeof b.endereco === 'object' ? b.endereco : {};
const numeros = Array.isArray(b.numeros) ? b.numeros : [];
const agenda = b.agenda && typeof b.agenda === 'object' ? b.agenda : {};
const role = await papelNaClinica(alvo, clinicaId);
const ehDentista = role === 'dentista';
const ehDono = EH_DONO(role);
const jornadaOk = ehDentista ? await jornadaConfigurada(alvo, clinicaId) : true;
const horarioClinicaOk = ehDono ? await clinicaHorariosConfigurados(clinicaId) : true;
const completo = comunicacaoCompleto({ endereco, numeros }, { ehDentista, jornadaOk, ehDono, horarioClinicaOk });
await pool.query(
`INSERT INTO pessoa_comunicacao (usuario_id, clinica_id, endereco, numeros, agenda, completo, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,NOW())
ON CONFLICT (usuario_id, clinica_id) DO UPDATE
SET endereco=EXCLUDED.endereco, numeros=EXCLUDED.numeros, agenda=EXCLUDED.agenda, completo=EXCLUDED.completo, updated_at=NOW()`,
[alvo, clinicaId, JSON.stringify(endereco), JSON.stringify(numeros), JSON.stringify(agenda), completo]);
res.json({ ok: true, completo });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Minhas salas (como locador)
app.get('/api/salas/minhas', authGuard, async (req, res) => {
try {
@@ -7602,14 +7818,15 @@ app.post('/api/salas', authGuard, async (req, res) => {
await pool.query(
`INSERT INTO salas (id, owner_usuario_id, clinica_id, nome, tipo, descricao, equipamentos, endereco, bairro, cidade, estado, cep,
valor_hora, valor_turno, valor_diaria, valor_semanal, valor_mensal, disponivel, area,
hora_consulta, turno_consulta, diaria_consulta, semanal_consulta, mensal_consulta)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)`,
hora_consulta, turno_consulta, diaria_consulta, semanal_consulta, mensal_consulta, contrato)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25)`,
[id, req.authUser.userId, b.clinicaId || null, b.nome, SALA_TIPOS.includes(b.tipo) ? b.tipo : 'independente',
b.descricao || null, b.equipamentos || null, b.endereco || null,
b.bairro?.toUpperCase() || null, String(b.cidade).toUpperCase(), String(b.estado).toUpperCase(), b.cep || null,
b.valorHora ?? null, b.valorTurno ?? null, b.valorDiaria ?? null, b.valorSemanal ?? null, b.valorMensal ?? null,
b.disponivel !== false, area,
b.horaConsulta === true, b.turnoConsulta === true, b.diariaConsulta === true, b.semanalConsulta === true, b.mensalConsulta === true]);
b.horaConsulta === true, b.turnoConsulta === true, b.diariaConsulta === true, b.semanalConsulta === true, b.mensalConsulta === true,
b.contrato || null]);
await setGeoByCep('salas', id, b.cep);
await registrarAudit(pool, { clinicaId: b.clinicaId || null, entidade: 'sala', entidadeId: id, acao: 'criou', actorId: req.authUser.userId, actorNome: await actorNome(req.authUser.userId), detalhes: { nome: b.nome } });
res.json({ success: true, id });
@@ -7626,7 +7843,7 @@ app.put('/api/salas/:id', authGuard, async (req, res) => {
endereco: b.endereco, bairro: b.bairro?.toUpperCase(), cidade: b.cidade ? String(b.cidade).toUpperCase() : undefined,
estado: b.estado ? String(b.estado).toUpperCase() : undefined, cep: b.cep,
valor_hora: b.valorHora, valor_turno: b.valorTurno, valor_diaria: b.valorDiaria, valor_semanal: b.valorSemanal, valor_mensal: b.valorMensal,
area: b.area, disponivel: b.disponivel,
area: b.area, disponivel: b.disponivel, contrato: b.contrato,
hora_consulta: b.horaConsulta, turno_consulta: b.turnoConsulta, diaria_consulta: b.diariaConsulta, semanal_consulta: b.semanalConsulta, mensal_consulta: b.mensalConsulta };
const keys = Object.keys(COLS).filter(k => COLS[k] !== undefined);
if (!keys.length) return res.json({ success: true });
@@ -7662,6 +7879,9 @@ app.get('/api/salas/marketplace', authGuard, async (req, res) => {
const params = [];
let where = `WHERE s.disponivel = true`;
// Não listar as PRÓPRIAS salas (não se aluga a própria) nem as da unidade ativa.
params.push(req.authUser.userId); where += ` AND s.owner_usuario_id <> $${params.length}`;
if (clinicaId) { params.push(String(clinicaId)); where += ` AND (s.clinica_id IS NULL OR s.clinica_id <> $${params.length})`; }
if (tipo && SALA_TIPOS.includes(tipo)) { params.push(tipo); where += ` AND s.tipo = $${params.length}`; }
if (estado) { params.push(String(estado).toUpperCase()); where += ` AND s.estado = $${params.length}`; }
if (cidade) { params.push(`%${String(cidade).toUpperCase()}%`); where += ` AND s.cidade ILIKE $${params.length}`; }
@@ -7680,7 +7900,7 @@ app.get('/api/salas/marketplace', authGuard, async (req, res) => {
const { rows } = await pool.query(
`SELECT s.id, s.nome, s.tipo, s.descricao, s.equipamentos, s.endereco, s.bairro, s.cidade, s.estado, s.cep, s.area,
s.valor_hora, s.valor_turno, s.valor_diaria, s.valor_semanal, s.valor_mensal,
s.hora_consulta, s.turno_consulta, s.diaria_consulta, s.semanal_consulta, s.mensal_consulta,
s.hora_consulta, s.turno_consulta, s.diaria_consulta, s.semanal_consulta, s.mensal_consulta, s.contrato,
s.owner_usuario_id, u.nome AS owner_nome, c.nome_fantasia AS clinica_nome, ${distSelect}
FROM salas s LEFT JOIN usuarios u ON u.id = s.owner_usuario_id LEFT JOIN clinicas c ON c.id = s.clinica_id
${where} ${order} LIMIT 200`, params);
@@ -9450,12 +9670,22 @@ app.get('/api/superadmin/secretaria-desligamentos', superadminGuard, async (req,
const email = rows[0]?.email;
if (!email) return res.json({ configurado: true, log: [] });
const limit = Math.min(500, Math.max(1, parseInt(String(req.query.limit || '150'), 10)));
const r = await fetch(`${cfg.motorUrl}/api/ext/v1/secretaria/session-power/log?limit=${limit}`, {
headers: { 'x-nw-key': cfg.integrationKey, 'x-nw-email': email },
});
if (!r.ok) return res.status(502).json({ error: 'Motor indisponível', status: r.status });
const log = await r.json();
res.json({ configurado: true, log: Array.isArray(log) ? log : [] });
const headers = { 'x-nw-key': cfg.integrationKey, 'x-nw-email': email };
const pull = async (path, escopo) => {
try {
const r = await fetch(`${cfg.motorUrl}/api/ext/v1${path}?limit=${limit}`, { headers });
if (!r.ok) return [];
const j = await r.json();
return (Array.isArray(j) ? j : []).map((e) => ({ ...e, escopo }));
} catch { return []; }
};
// "global" = botão que desativa a Secretária para TODOS os chats; "sessao" = por número.
const [glob, sess] = await Promise.all([
pull('/secretaria/power/log', 'global'),
pull('/secretaria/session-power/log', 'sessao'),
]);
const log = [...glob, ...sess].sort((a, b) => new Date(b.at) - new Date(a.at));
res.json({ configurado: true, log });
} catch (err) { res.status(500).json({ error: err.message }); }
});
+39 -1
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { HybridBackend } from './services/backend.ts';
import { Sidebar } from './components/Sidebar.tsx';
@@ -8,6 +8,7 @@ import {
} from 'lucide-react';
import { ToastContainer } from './components/Toast.tsx';
import { ComunicacaoInternaModal } from './components/ComunicacaoInternaModal.tsx';
import { useAppVersion } from './buildInfo.ts';
import { Dashboard } from './views/Dashboard.tsx';
@@ -487,6 +488,37 @@ const App: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Gate de comunicação interna: todo NÃO-paciente com clínica ativa preenche o perfil
// (endereço + números com janelas de horário; agenda só p/ dentista). Gate PROGRESSIVO:
// reaparece a cada login com "Lembrar mais tarde"; a partir da 4ª aparição vira bloqueio
// até preencher. Conta as aparições em localStorage por clínica.
const [comGate, setComGate] = useState<{ show: boolean; forced: boolean; clinicaId?: string }>({ show: false, forced: false });
const gateCountedFor = useRef<string | null>(null); // evita contar 2x no mesmo mount (StrictMode)
useEffect(() => {
const ws: any = HybridBackend.getActiveWorkspace();
const role = HybridBackend.getCurrentRole();
const aplica = HybridBackend.isAuthenticated() && !HybridBackend.isPreviewMode() &&
role && role !== 'paciente' && role !== 'superadmin' &&
ws?.id && ws?.tipo !== 'sala' && ws?.tipo !== 'laboratorio';
if (!aplica) { setComGate({ show: false, forced: false }); return; }
const key = `comgate:${ws.id}`;
let cancel = false;
fetch(`${(window as any).__API_URL__ || ''}/api/comunicacao/me?clinicaId=${encodeURIComponent(ws.id)}`,
{ headers: { Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}` } })
.then(r => (r.ok ? r.json() : { completo: true }))
.then(d => {
if (cancel) return;
if (d?.sem_clinica || d?.completo) { try { localStorage.removeItem(key); } catch { /* ignore */ } setComGate({ show: false, forced: false }); return; }
// Incompleto → conta a aparição 1x por carga da página.
let count = 0; try { count = parseInt(localStorage.getItem(key) || '0', 10) || 0; } catch { /* ignore */ }
if (gateCountedFor.current !== ws.id) { count += 1; try { localStorage.setItem(key, String(count)); } catch { /* ignore */ } gateCountedFor.current = ws.id; }
setComGate({ show: true, forced: count >= 4, clinicaId: ws.id });
})
.catch(() => { if (!cancel) setComGate({ show: false, forced: false }); });
return () => { cancel = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeWorkspace?.id, currentRole]);
// Roteamento pós-login/cadastro.
const onAuthSuccess = (result?: { noWorkspace?: boolean; userRole?: string }) => {
if (result?.noWorkspace) {
@@ -591,6 +623,12 @@ const App: React.FC = () => {
{HybridBackend.isAuthenticated() && HybridBackend.mustChangePassword() && (
<ChangePasswordModal forced onDone={() => forcePwRerender(t => t + 1)} />
)}
{comGate.show && !HybridBackend.mustChangePassword() && (
<ComunicacaoInternaModal forced={comGate.forced} clinicaId={comGate.clinicaId}
onSnooze={() => setComGate(s => ({ ...s, show: false }))}
onClose={() => setComGate(s => ({ ...s, show: false }))}
onSaved={(c) => { if (c) { try { localStorage.removeItem(`comgate:${comGate.clinicaId}`); } catch { /* ignore */ } setComGate({ show: false, forced: false }); } }} />
)}
<div className="flex h-screen bg-gray-50 text-gray-800 overflow-hidden relative">
<style>{`
:root { --brand-color: ${clinColor}; }
+7 -6
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { StackModal, StackPage, useStackModal } from './StackModal.tsx';
import { fmtHora, fmtDataCurta, fmtDataHora } from '../utils/appTime.ts';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { AvaliacaoEditor } from './AvaliacaoEditor.tsx';
@@ -137,9 +138,9 @@ const HistoryPage: React.FC<{ appointmentId: string }> = ({ appointmentId }) =>
<p className="text-[10px] text-gray-400 font-medium">{new Date(h.at).toLocaleString('pt-BR')}</p>
{h.detalhes?.de && h.detalhes?.para && (
<p className="text-[10px] text-gray-500 mt-0.5">
{new Date(h.detalhes.de.start).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
{fmtDataHora(h.detalhes.de.start)}
{' → '}
{new Date(h.detalhes.para.start).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
{fmtDataHora(h.detalhes.para.start)}
</p>
)}
</div>
@@ -304,8 +305,8 @@ const DentistaRestritoView: React.FC<{ ag: any; nome: string; dentNome: string;
<div className="grid grid-cols-2 gap-4 py-2 border-y border-gray-100">
<div className="space-y-1.5">
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Data e hora</label>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleDateString('pt-BR', { weekday: 'short', day: 'numeric', month: 'short' }) : '—'}</p>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '—'}</p>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? fmtDataCurta(ag.start) : '—'}</p>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? fmtHora(ag.start) : '—'}</p>
</div>
<div className="space-y-1.5">
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Profissional</label>
@@ -421,8 +422,8 @@ const DetailPage: React.FC<Props> = (p) => {
<div className="grid grid-cols-2 gap-4 py-2 border-y border-gray-100">
<div className="space-y-1.5">
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Data e hora</label>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleDateString('pt-BR', { weekday: 'short', day: 'numeric', month: 'short' }) : '—'}</p>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '—'}</p>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? fmtDataCurta(ag.start) : '—'}</p>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? fmtHora(ag.start) : '—'}</p>
</div>
<div className="space-y-1.5">
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Profissional</label>
+33 -34
View File
@@ -65,42 +65,41 @@ export const AgendaPresence: React.FC = () => {
</span>
</button>
{/* Grupo oculto que expande PARA BAIXO dentro da div (inline) — largura cheia
da coluna/gaveta, sem flutuar; assim não é cortado pelo overflow no desktop. */}
{open && (
<>
<div className="fixed inset-0 z-[55]" onClick={() => setOpen(false)} />
<div className="absolute right-0 mt-2 w-64 bg-white rounded-2xl shadow-2xl border border-gray-100 z-[56] overflow-hidden">
<div className="px-4 py-3 border-b border-gray-50">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-1.5">
<Circle size={8} className="fill-emerald-500 text-emerald-500" /> Online agora ({online.length})
</p>
</div>
<div className="max-h-72 overflow-y-auto custom-scrollbar">
{online.length === 0 && (
<p className="px-4 py-3 text-[11px] text-gray-400 font-bold uppercase"> você por aqui.</p>
)}
{online.map(o => (
<div key={o.usuario_id} className="flex items-center gap-2.5 px-4 py-2.5">
<span className="relative w-7 h-7 rounded-full flex items-center justify-center text-[9px] font-black text-white shrink-0" style={{ backgroundColor: colorFor(o.nome) }}>
{initials(o.nome)}
<Circle size={9} className="absolute -bottom-0.5 -right-0.5 fill-emerald-500 text-white" />
</span>
<span className="text-xs font-bold text-gray-700 uppercase truncate">{o.nome}</span>
</div>
))}
{ultimasOffline.length > 0 && (
<div className="px-4 py-2 border-t border-gray-50">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-1">Últimas visitas</p>
{ultimasOffline.slice(0, 6).map(u => (
<div key={u.usuario_id} className="flex items-center justify-between py-1">
<span className="text-[11px] font-bold text-gray-500 uppercase truncate">{u.usuario_nome}</span>
<span className="text-[9px] text-gray-300 font-bold whitespace-nowrap ml-2">{relTime(u.last_view_at)}</span>
</div>
))}
</div>
)}
</div>
<div className="mt-2 w-full bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b border-gray-50">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-1.5">
<Circle size={8} className="fill-emerald-500 text-emerald-500" /> Online agora ({online.length})
</p>
</div>
</>
<div className="max-h-72 overflow-y-auto custom-scrollbar">
{online.length === 0 && (
<p className="px-4 py-3 text-[11px] text-gray-400 font-bold uppercase"> você por aqui.</p>
)}
{online.map(o => (
<div key={o.usuario_id} className="flex items-center gap-2.5 px-4 py-2.5">
<span className="relative w-7 h-7 rounded-full flex items-center justify-center text-[9px] font-black text-white shrink-0" style={{ backgroundColor: colorFor(o.nome) }}>
{initials(o.nome)}
<Circle size={9} className="absolute -bottom-0.5 -right-0.5 fill-emerald-500 text-white" />
</span>
<span className="text-xs font-bold text-gray-700 uppercase truncate">{o.nome}</span>
</div>
))}
{ultimasOffline.length > 0 && (
<div className="px-4 py-2 border-t border-gray-50">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-1">Últimas visitas</p>
{ultimasOffline.slice(0, 6).map(u => (
<div key={u.usuario_id} className="flex items-center justify-between py-1">
<span className="text-[11px] font-bold text-gray-500 uppercase truncate">{u.usuario_nome}</span>
<span className="text-[9px] text-gray-300 font-bold whitespace-nowrap ml-2">{relTime(u.last_view_at)}</span>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
@@ -0,0 +1,370 @@
import React, { useState, useEffect, useCallback } from 'react';
import { X, Plus, Trash2, MapPin, Phone, Clock, CalendarClock, Save, Loader2, MessageSquare, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { useToast } from '../contexts/ToastContext.tsx';
import { buscarCep } from '../services/viacep.ts';
import { HorariosConfig } from './HorariosConfig.tsx';
import { HybridBackend } from '../services/backend.ts';
// Comunicação interna: perfil de contato de não-pacientes (dentistas/funcionários).
// Números de WhatsApp múltiplos (cada um com descrição, situações e janelas de horário
// permitido p/ a Secretária enviar msg — proteção contra mensagem fora do expediente),
// endereço e disponibilidade de agenda (abrir/fechar + jornada).
const API_URL = (window as any).__API_URL__ || '';
const COLOR = '#0d9488';
const UF = ['', 'AC', 'AL', 'AM', 'AP', 'BA', 'CE', 'DF', 'ES', 'GO', 'MA', 'MG', 'MS', 'MT', 'PA', 'PB', 'PE', 'PI', 'PR', 'RJ', 'RN', 'RO', 'RR', 'RS', 'SC', 'SE', 'SP', 'TO'];
const DIAS = [{ v: 0, l: 'Dom' }, { v: 1, l: 'Seg' }, { v: 2, l: 'Ter' }, { v: 3, l: 'Qua' }, { v: 4, l: 'Qui' }, { v: 5, l: 'Sex' }, { v: 6, l: 'Sáb' }];
const api = (path: string, opts: RequestInit = {}) =>
fetch(`${API_URL}${path}`, {
...opts,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}` },
});
interface Janela { dias: number[]; inicio: string; fim: string }
interface Numero { numero: string; descricao: string; situacoes: string; janelas: Janela[] }
interface Endereco { cep?: string; logradouro?: string; numero?: string; complemento?: string; bairro?: string; cidade?: string; estado?: string }
interface Agenda { aberta?: boolean; horarios?: Janela[] }
const input = 'w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm font-medium text-gray-700 outline-none focus:border-teal-400';
const label = 'text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1';
// ── Editor de janelas de horário (dias + faixa) — reutilizado p/ contato e agenda ──
const JanelaEditor: React.FC<{ janelas: Janela[]; onChange: (j: Janela[]) => void; addLabel: string }> = ({ janelas, onChange, addLabel }) => {
const upd = (i: number, patch: Partial<Janela>) => onChange(janelas.map((j, k) => (k === i ? { ...j, ...patch } : j)));
const toggleDia = (i: number, d: number) => {
const j = janelas[i]; const has = j.dias.includes(d);
upd(i, { dias: has ? j.dias.filter(x => x !== d) : [...j.dias, d].sort((a, b) => a - b) });
};
return (
<div className="space-y-2">
{janelas.map((j, i) => (
<div key={i} className="rounded-xl border border-gray-200 p-3 bg-white space-y-2">
<div className="flex items-center justify-between">
<div className="flex flex-wrap gap-1">
{DIAS.map(d => {
const on = j.dias.includes(d.v);
return (
<button key={d.v} type="button" onClick={() => toggleDia(i, d.v)}
className={`w-8 h-8 rounded-lg text-[10px] font-black transition-all ${on ? 'text-white' : 'bg-gray-100 text-gray-400 hover:bg-gray-200'}`}
style={on ? { backgroundColor: COLOR } : {}}>{d.l}</button>
);
})}
</div>
<button type="button" onClick={() => onChange(janelas.filter((_, k) => k !== i))} className="p-1.5 text-gray-300 hover:text-red-500"><Trash2 size={15} /></button>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-gray-400 uppercase">Das</span>
<input type="time" value={j.inicio} onChange={e => upd(i, { inicio: e.target.value })} className="px-2 py-1.5 bg-gray-50 border border-gray-200 rounded-lg text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
<span className="text-[10px] font-black text-gray-400 uppercase">até</span>
<input type="time" value={j.fim} onChange={e => upd(i, { fim: e.target.value })} className="px-2 py-1.5 bg-gray-50 border border-gray-200 rounded-lg text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
</div>
</div>
))}
<button type="button" onClick={() => onChange([...janelas, { dias: [1, 2, 3, 4, 5], inicio: '08:00', fim: '18:00' }])}
className="w-full py-2 rounded-xl border border-dashed border-gray-300 text-[11px] font-black uppercase text-gray-400 hover:border-teal-400 hover:text-teal-600 flex items-center justify-center gap-1.5">
<Plus size={13} /> {addLabel}
</button>
</div>
);
};
export const ComunicacaoInternaModal: React.FC<{
usuarioId?: string; clinicaId?: string; nome?: string; forced?: boolean;
onClose: () => void; onSaved?: (completo: boolean) => void; onSnooze?: () => void;
}> = ({ usuarioId = 'me', clinicaId, nome, forced, onClose, onSaved, onSnooze }) => {
const toast = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [endereco, setEndereco] = useState<Endereco>({});
const [numeros, setNumeros] = useState<Numero[]>([]);
const [enderecoDaClinica, setEnderecoDaClinica] = useState(false);
const [isDentista, setIsDentista] = useState(false); // dentista precisa da jornada
const [isDono, setIsDono] = useState(false); // dono precisa do horário da clínica
const [jornadaOk, setJornadaOk] = useState(false); // jornada real (dentistas_horarios) configurada
const [horarioClinicaOk, setHorarioClinicaOk] = useState(false); // clinicas_horarios configurado
const [horariosTab, setHorariosTab] = useState<'dentistas' | 'clinica' | null>(null); // abre "Horários de Funcionamento"
const [trocarOpen, setTrocarOpen] = useState(false); // dropdown "Trocar de clínica" (escape do gate forçado)
// Escape do gate forçado: o gate é POR clínica; travar ESTA não deve prender o
// usuário no sistema. Lista as OUTRAS clínicas (exceto a atual e salas) para trocar.
const outrasClinicas = (HybridBackend.getWorkspaces() || [])
.filter((w: any) => w.tipo !== 'sala' && w.id !== clinicaId);
const load = useCallback(async () => {
setLoading(true);
try {
const qs = clinicaId ? `?clinicaId=${encodeURIComponent(clinicaId)}` : '';
const r = await api(`/api/comunicacao/${usuarioId}${qs}`);
const d = await r.json();
setEndereco(d.endereco || {});
setNumeros(Array.isArray(d.numeros) ? d.numeros : []);
setEnderecoDaClinica(!!d.endereco_da_clinica);
setIsDentista((d.role || '') === 'dentista');
setIsDono(['donoclinica', 'donoconsultorio'].includes(d.role || ''));
setJornadaOk(!!d.jornada_configurada);
setHorarioClinicaOk(!!d.horario_clinica_configurado);
} catch { /* mantém vazio */ } finally { setLoading(false); }
}, [usuarioId, clinicaId]);
useEffect(() => { load(); }, [load]);
const setEnd = (k: keyof Endereco, v: string) => setEndereco(p => ({ ...p, [k]: v }));
const onCep = async () => {
const cep = (endereco.cep || '').replace(/\D/g, '');
if (cep.length !== 8) return;
const e = await buscarCep(cep);
if (e) setEndereco(p => ({ ...p, logradouro: e.logradouro || p.logradouro, bairro: e.bairro || p.bairro, cidade: e.cidade || p.cidade, estado: e.estado || p.estado }));
};
const updNum = (i: number, patch: Partial<Numero>) => setNumeros(ns => ns.map((n, k) => (k === i ? { ...n, ...patch } : n)));
const addNum = () => setNumeros(ns => [...ns, { numero: '', descricao: '', situacoes: '', janelas: [{ dias: [1, 2, 3, 4, 5], inicio: '08:00', fim: '18:00' }] }]);
// Notificação de número compartilhado: ao sair do campo, verifica se o número já é de
// outra conta/papel da clínica (a mesma pessoa pode ser dono E dentista, por ex.).
const [avisos, setAvisos] = useState<Record<number, { nome: string; role: string }[]>>({});
const roleLabel = (r: string) => (({ dentista: 'dentista', donoclinica: 'dono', donoconsultorio: 'dono', funcionario: 'funcionário', admin: 'admin' } as Record<string, string>)[r] || r || 'conta');
const checkNumero = async (i: number, numero: string) => {
const tel = (numero || '').replace(/\D/g, '');
if (tel.length < 10 || !clinicaId) { setAvisos(a => ({ ...a, [i]: [] })); return; }
try {
const r = await api(`/api/comunicacao/numero-lookup?clinicaId=${encodeURIComponent(clinicaId)}&numero=${encodeURIComponent(tel)}`);
const d = await r.json();
setAvisos(a => ({ ...a, [i]: Array.isArray(d.encontrados) ? d.encontrados : [] }));
} catch { /* silencioso */ }
};
// Completude (espelha o backend) — orienta o preenchimento e destrava o gate.
const enderecoOk = !!(endereco.cidade && endereco.estado && (endereco.cep || endereco.logradouro));
const numerosOk = numeros.length >= 1 && numeros.every(n => (n.numero || '').replace(/\D/g, '').length >= 10 && (n.descricao || '').trim() && (n.janelas?.length ?? 0) >= 1);
const jornadaFeita = !isDentista || jornadaOk; // dentista: jornada real configurada
const clinicaFeita = !isDono || horarioClinicaOk; // dono: horário da clínica configurado
const completo = enderecoOk && numerosOk && jornadaFeita && clinicaFeita;
const salvar = async () => {
setSaving(true);
try {
const r = await api(`/api/comunicacao/${usuarioId}`, { method: 'PUT', body: JSON.stringify({ clinicaId, endereco, numeros }) });
const d = await r.json();
if (!r.ok) throw new Error(d.error || 'Erro ao salvar.');
toast.success('DADOS SALVOS!');
onSaved?.(!!d.completo);
if (!forced || d.completo) onClose();
else toast.error('AINDA FALTAM CAMPOS OBRIGATÓRIOS PARA LIBERAR O SISTEMA.');
} catch (e: any) { toast.error(String(e.message).toUpperCase()); }
finally { setSaving(false); }
};
return (
<>
<div className="fixed inset-0 z-[70] bg-white flex flex-col">
{/* Header */}
<div className="px-5 sm:px-8 py-4 border-b border-gray-100 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: `${COLOR}18`, color: COLOR }}><MessageSquare size={20} /></div>
<div>
<h2 className="text-sm sm:text-base font-black text-gray-900 uppercase">Comunicação interna{nome ? ` · ${nome}` : ''}</h2>
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: completo ? '#16a34a' : COLOR }}>
{completo ? 'Perfil completo' : 'Preencha para liberar o sistema'}
</p>
</div>
</div>
{!forced && <button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-500"><X size={20} /></button>}
</div>
{loading ? (
<div className="flex-1 flex items-center justify-center text-gray-400"><Loader2 className="animate-spin" size={28} /></div>
) : (
<>
{/* Conteúdo */}
<div className="flex-1 overflow-y-auto px-5 sm:px-8 py-6">
<div className="max-w-3xl mx-auto space-y-8">
{forced && (
<div className="rounded-2xl bg-amber-50 border border-amber-200 p-4 flex gap-3">
<AlertTriangle size={18} className="text-amber-500 shrink-0 mt-0.5" />
<p className="text-xs text-amber-700 font-medium leading-relaxed">
Precisamos destes dados para o sistema funcionar corretamente e para a <b>Secretária IA</b> contatar você
nos horários certos. Mensagens fora do expediente podem gerar problemas por isso as <b>janelas de horário</b> são obrigatórias.
</p>
</div>
)}
{/* ── Endereço ── */}
<section>
<h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><MapPin size={15} style={{ color: COLOR }} /> Endereço</h3>
{enderecoDaClinica && <p className="text-[11px] text-teal-600 font-bold mb-3">Puxamos o endereço da clínica é conferir (ou ajustar, se o seu for diferente).</p>}
<div className="grid grid-cols-1 sm:grid-cols-6 gap-3">
<div className="sm:col-span-2">
<label className={label}>CEP</label>
<input className={input} value={endereco.cep || ''} onChange={e => setEnd('cep', e.target.value)} onBlur={onCep} placeholder="00000-000" />
</div>
<div className="sm:col-span-4">
<label className={label}>Logradouro</label>
<input className={input} value={endereco.logradouro || ''} onChange={e => setEnd('logradouro', e.target.value)} placeholder="Rua / Avenida" />
</div>
<div className="sm:col-span-1">
<label className={label}>Número</label>
<input className={input} value={endereco.numero || ''} onChange={e => setEnd('numero', e.target.value)} />
</div>
<div className="sm:col-span-2">
<label className={label}>Complemento</label>
<input className={input} value={endereco.complemento || ''} onChange={e => setEnd('complemento', e.target.value)} />
</div>
<div className="sm:col-span-3">
<label className={label}>Bairro</label>
<input className={input} value={endereco.bairro || ''} onChange={e => setEnd('bairro', e.target.value)} />
</div>
<div className="sm:col-span-4">
<label className={label}>Cidade</label>
<input className={input} value={endereco.cidade || ''} onChange={e => setEnd('cidade', e.target.value)} />
</div>
<div className="sm:col-span-2">
<label className={label}>UF</label>
<select className={input} value={endereco.estado || ''} onChange={e => setEnd('estado', e.target.value)}>
{UF.map(u => <option key={u} value={u}>{u || '—'}</option>)}
</select>
</div>
</div>
</section>
{/* ── Telefones / WhatsApp ── */}
<section>
<h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><Phone size={15} style={{ color: COLOR }} /> Números de WhatsApp</h3>
<p className="text-[11px] text-gray-400 font-medium mb-3">Cadastre todos os seus números. Para cada um, descreva quando ele pode ser usado e em quais horários a Secretária pode enviar mensagem.</p>
<div className="space-y-4">
{numeros.map((n, i) => (
<div key={i} className="rounded-2xl border border-gray-200 p-4 space-y-3">
<div className="flex items-start gap-3">
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={label}>Número</label>
<input className={input} value={n.numero} onChange={e => updNum(i, { numero: e.target.value })} onBlur={e => checkNumero(i, e.target.value)} placeholder="(67) 99999-9999" />
</div>
<div>
<label className={label}>Descrição</label>
<input className={input} value={n.descricao} onChange={e => updNum(i, { descricao: e.target.value })} placeholder="Ex.: WhatsApp de trabalho" />
</div>
</div>
<button type="button" onClick={() => setNumeros(ns => ns.filter((_, k) => k !== i))} className="p-2 text-gray-300 hover:text-red-500 mt-5"><Trash2 size={16} /></button>
</div>
{(avisos[i]?.length ?? 0) > 0 && (
<div className="rounded-xl bg-amber-50 border border-amber-200 p-2.5 text-[11px] text-amber-700 font-medium flex gap-2">
<AlertTriangle size={14} className="shrink-0 mt-0.5 text-amber-500" />
<span>Este número está cadastrado por <b>{avisos[i].map(x => `${x.nome} (${roleLabel(x.role)})`).join(', ')}</b>. Pode compartilhar o mesmo número descreva abaixo as situações deste papel; a Secretária identifica pelo assunto quem deve responder.</span>
</div>
)}
<div>
<label className={label}>Situações em que a Secretária pode mandar msg neste número</label>
<textarea className={input} rows={2} value={n.situacoes} onChange={e => updNum(i, { situacoes: e.target.value })} placeholder="Ex.: só confirmações de encaixe e avisos urgentes; nunca assuntos pessoais." />
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5 mb-1"><Clock size={12} /> Horários permitidos para envio</label>
<JanelaEditor janelas={n.janelas || []} onChange={j => updNum(i, { janelas: j })} addLabel="Adicionar janela de horário" />
</div>
</div>
))}
<button type="button" onClick={addNum} className="w-full py-3 rounded-2xl border border-dashed border-gray-300 text-xs font-black uppercase text-gray-400 hover:border-teal-400 hover:text-teal-600 flex items-center justify-center gap-2">
<Plus size={15} /> Adicionar número
</button>
</div>
</section>
{/* ── Agenda de atendimento — fonte única: "Horários de Funcionamento" ── */}
{isDentista && (
<section>
<h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><CalendarClock size={15} style={{ color: COLOR }} /> Agenda de atendimento</h3>
<p className="text-[11px] text-gray-400 font-medium mb-3">São os horários em que a Secretária pode marcar consultas para você. Ficam em "Horários de Funcionamento" (a mesma jornada que a agenda usa).</p>
{jornadaOk ? (
<div className="rounded-xl bg-green-50 border border-green-200 p-3.5 flex items-center justify-between">
<span className="flex items-center gap-2 text-xs font-black text-green-700 uppercase"><CheckCircle2 size={15} /> Jornada configurada</span>
<button type="button" onClick={() => setHorariosTab('dentistas')} className="text-[10px] font-black uppercase text-teal-600 hover:text-teal-700">Editar</button>
</div>
) : (
<div className="rounded-xl bg-amber-50 border border-amber-200 p-3.5">
<p className="text-[11px] text-amber-700 font-medium mb-2 flex gap-2"><AlertTriangle size={14} className="shrink-0 mt-0.5 text-amber-500" /> Você ainda não configurou sua agenda de atendimento. Sem ela, a Secretária não consegue marcar consultas com você.</p>
<button type="button" onClick={() => setHorariosTab('dentistas')} className="w-full py-2.5 rounded-xl text-[11px] font-black uppercase text-white hover:opacity-90" style={{ backgroundColor: COLOR }}>Configurar minha agenda</button>
</div>
)}
</section>
)}
{/* ── Horário de funcionamento da clínica — só para o DONO ── */}
{isDono && (
<section>
<h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><CalendarClock size={15} style={{ color: COLOR }} /> Horário da clínica</h3>
<p className="text-[11px] text-gray-400 font-medium mb-3">É o horário de funcionamento que a Secretária usa para atender e marcar consultas. Fica em "Horários de Funcionamento".</p>
{horarioClinicaOk ? (
<div className="rounded-xl bg-green-50 border border-green-200 p-3.5 flex items-center justify-between">
<span className="flex items-center gap-2 text-xs font-black text-green-700 uppercase"><CheckCircle2 size={15} /> Horário da clínica configurado</span>
<button type="button" onClick={() => setHorariosTab('clinica')} className="text-[10px] font-black uppercase text-teal-600 hover:text-teal-700">Editar</button>
</div>
) : (
<div className="rounded-xl bg-amber-50 border border-amber-200 p-3.5">
<p className="text-[11px] text-amber-700 font-medium mb-2 flex gap-2"><AlertTriangle size={14} className="shrink-0 mt-0.5 text-amber-500" /> A clínica ainda não tem horário de funcionamento. Sem ele, a Secretária não consegue atender nem marcar consultas.</p>
<button type="button" onClick={() => setHorariosTab('clinica')} className="w-full py-2.5 rounded-xl text-[11px] font-black uppercase text-white hover:opacity-90" style={{ backgroundColor: COLOR }}>Configurar horário da clínica</button>
</div>
)}
</section>
)}
</div>
</div>
{/* Footer */}
<div className="px-5 sm:px-8 py-4 border-t border-gray-100 shrink-0">
<div className="max-w-3xl mx-auto flex items-center gap-3">
<div className="flex-1 flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black uppercase tracking-wide">
<span className={enderecoOk ? 'text-green-600' : 'text-gray-300'}>{enderecoOk ? '✓' : '○'} Endereço</span>
<span className={numerosOk ? 'text-green-600' : 'text-gray-300'}>{numerosOk ? '✓' : '○'} Números</span>
{isDentista && <span className={jornadaFeita ? 'text-green-600' : 'text-gray-300'}>{jornadaFeita ? '✓' : '○'} Agenda</span>}
{isDono && <span className={clinicaFeita ? 'text-green-600' : 'text-gray-300'}>{clinicaFeita ? '✓' : '○'} Clínica</span>}
</div>
{!forced && onSnooze && (
<button onClick={onSnooze} className="px-4 py-3 rounded-xl text-xs font-black uppercase text-gray-500 border border-gray-200 hover:bg-gray-50">
Lembrar mais tarde
</button>
)}
{/* Escape do gate forçado: sair para OUTRA clínica sem preencher esta. */}
{forced && outrasClinicas.length > 0 && (
<div className="relative">
<button type="button" onClick={() => setTrocarOpen(o => !o)}
className="px-4 py-3 rounded-xl text-xs font-black uppercase text-gray-600 border border-gray-200 hover:bg-gray-50 flex items-center gap-1.5">
Trocar de clínica
</button>
{trocarOpen && (
<>
<div className="fixed inset-0 z-[71]" onClick={() => setTrocarOpen(false)} />
<div className="absolute right-0 bottom-full mb-2 z-[72] min-w-[220px] bg-white border border-gray-200 rounded-2xl shadow-xl py-2 max-h-[50vh] overflow-y-auto">
<p className="px-4 py-1.5 text-[9px] font-black text-gray-400 uppercase tracking-widest">Ir para outra clínica</p>
{outrasClinicas.map((w: any) => (
<button key={w.id} type="button" onClick={() => HybridBackend.switchWorkspace(w.id)}
className="w-full flex items-center gap-2.5 px-4 py-2 hover:bg-gray-50 text-left">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: w.cor || '#2563eb' }} />
<span className="text-xs font-bold text-gray-700 uppercase truncate">{w.nome}</span>
</button>
))}
</div>
</>
)}
</div>
)}
<button onClick={salvar} disabled={saving}
className="px-6 py-3 text-white rounded-xl text-xs font-black uppercase hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2" style={{ backgroundColor: completo ? '#16a34a' : COLOR }}>
{saving ? <Loader2 size={15} className="animate-spin" /> : completo ? <CheckCircle2 size={15} /> : <Save size={15} />}
{forced ? (completo ? 'Salvar e liberar' : 'Salvar progresso') : 'Salvar'}
</button>
</div>
</div>
</>
)}
</div>
{/* "Horários de Funcionamento" por cima — fonte única (dentistas_horarios / clinicas_horarios). */}
{horariosTab && (
<HorariosConfig isOpen clinicaId={clinicaId} initialTab={horariosTab} soDentista={horariosTab === 'dentistas' && usuarioId === 'me'}
onClose={() => { setHorariosTab(null); load(); }} />
)}
</>
);
};
export default ComunicacaoInternaModal;
@@ -0,0 +1,135 @@
// Feature C — modal de confirmação de dados no CHECK-IN. Abre quando a recepção
// clica "Atender" e o paciente veio da Secretária (dados_confirmados=false). A recepção
// confere/ajusta Nome, Data de nascimento, CPF e Telefone (o número PRÓPRIO deste
// paciente — um dependente pode ter ganhado WhatsApp próprio) e vê o grupo familiar.
// Salva na base de Pacientes e marca dados_confirmados=true (o modal para de abrir).
import React, { useState, useEffect } from 'react';
import { X, Loader2, Check, Users, ShieldCheck } from 'lucide-react';
import { HybridBackend } from '../services/backend';
import { useToast } from '../contexts/ToastContext.tsx';
// Normaliza p/ YYYY-MM-DD (aceita DD/MM/AAAA); senão devolve vazio p/ o <input type=date>.
const paraInputDate = (s?: string | null): string => {
const v = String(s || '').trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
const m = v.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/);
if (m) return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`;
return '';
};
interface Props {
pacienteId: string | null;
isOpen: boolean;
onClose: () => void;
onConfirmed: () => void; // segue o fluxo (ex.: prosseguir com o Atender)
}
export const ConfirmarDadosPaciente: React.FC<Props> = ({ pacienteId, isOpen, onClose, onConfirmed }) => {
const toast = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [nome, setNome] = useState('');
const [nasc, setNasc] = useState('');
const [cpf, setCpf] = useState('');
const [tel, setTel] = useState('');
const [familia, setFamilia] = useState<{ grupo: any; membros: any[] }>({ grupo: null, membros: [] });
useEffect(() => {
if (!isOpen || !pacienteId) return;
let alive = true;
setLoading(true);
Promise.all([
HybridBackend.getPacienteById(pacienteId),
HybridBackend.getFamiliaPaciente(pacienteId).catch(() => ({ grupo: null, membros: [] })),
]).then(([p, fam]) => {
if (!alive) return;
setNome(p?.nome || ''); setNasc(paraInputDate(p?.datanascimento)); setCpf(p?.cpf || ''); setTel(p?.telefone || '');
setFamilia(fam as any);
}).catch(() => { if (alive) toast.error('Não foi possível carregar o paciente.'); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [isOpen, pacienteId]);
if (!isOpen || !pacienteId) return null;
const salvar = async () => {
if (!nome.trim()) { toast.error('Informe o nome.'); return; }
setSaving(true);
try {
const r = await HybridBackend.confirmarDadosPaciente(pacienteId, {
nome: nome.trim(), cpf: cpf.trim() || undefined, telefone: tel.trim() || undefined, data_nascimento: nasc || undefined,
});
if (!r.ok) { toast.error(r.mensagem || 'Dados inválidos.'); setSaving(false); return; }
toast.success('Dados confirmados!');
onConfirmed(); onClose();
} catch { toast.error('Erro ao confirmar.'); } finally { setSaving(false); }
};
return (
<div className="fixed inset-0 z-[90] bg-black/60 backdrop-blur-md flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white w-full max-w-md rounded-[1.75rem] shadow-2xl border border-gray-100 flex flex-col max-h-[92vh] overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-6 py-5 border-b border-gray-50 flex items-center justify-between bg-gradient-to-br from-teal-50 to-white">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-teal-500 rounded-2xl shadow-lg shadow-teal-200"><ShieldCheck size={20} className="text-white" /></div>
<div>
<h2 className="text-base font-black text-gray-900 uppercase tracking-tighter leading-none">Confirme os dados</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-1">Cadastro veio da Secretária IA · confira no check-in</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={20} /></button>
</div>
{loading ? (
<div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin text-gray-400" /></div>
) : (
<div className="p-6 space-y-4 overflow-y-auto">
<Campo label="Nome completo"><input value={nome} onChange={e => setNome(e.target.value)} className={inpCls} /></Campo>
<div className="grid grid-cols-2 gap-3">
<Campo label="Data de nascimento"><input type="date" value={nasc} onChange={e => setNasc(e.target.value)} className={inpCls} /></Campo>
<Campo label="CPF"><input value={cpf} onChange={e => setCpf(e.target.value)} placeholder="000.000.000-00" inputMode="numeric" className={inpCls} /></Campo>
</div>
<Campo label="Telefone deste paciente">
<input value={tel} onChange={e => setTel(e.target.value)} placeholder="(67) 90000-0000" inputMode="tel" className={inpCls} />
<p className="text-[10px] text-gray-400 mt-1">Se for dependente com WhatsApp próprio, ajuste o número dele aqui.</p>
</Campo>
{/* Grupo familiar (contexto) */}
{(familia.grupo || familia.membros.length > 0) && (
<div className="rounded-2xl bg-gray-50 p-3">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5 mb-2">
<Users size={12} /> Grupo familiar{familia.grupo?.nome ? ` · ${familia.grupo.nome}` : ''}
</p>
<div className="space-y-1">
{familia.membros.map((m: any) => (
<div key={m.id} className="flex items-center gap-2 text-xs">
<span className={`w-1.5 h-1.5 rounded-full ${String(m.relacao || m.grupofamiliarrelacao || '').toLowerCase() === 'titular' ? 'bg-teal-500' : 'bg-gray-300'}`} />
<span className="font-bold text-gray-700 truncate">{m.nome}</span>
{String(m.relacao || m.grupofamiliarrelacao || '').toLowerCase() === 'titular' && <span className="text-[9px] font-black uppercase text-teal-600">titular</span>}
</div>
))}
{familia.membros.length === 0 && <p className="text-[11px] text-gray-400">Sem outros membros vinculados.</p>}
</div>
</div>
)}
</div>
)}
<div className="px-6 py-4 border-t border-gray-50 flex gap-2">
<button onClick={onClose} className="flex-1 py-2.5 rounded-xl text-sm font-black uppercase tracking-wide text-gray-500 hover:bg-gray-100">Depois</button>
<button onClick={salvar} disabled={saving || loading}
className="flex-[2] py-2.5 rounded-xl text-sm font-black uppercase tracking-wide bg-teal-600 hover:bg-teal-700 text-white flex items-center justify-center gap-2 disabled:opacity-50">
{saving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />} Confirmar e continuar
</button>
</div>
</div>
</div>
);
};
const inpCls = 'w-full bg-gray-50 border-2 border-transparent focus:border-teal-500 rounded-xl px-3 py-2 text-sm outline-none';
const Campo: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{label}</label>
{children}
</div>
);
+62 -12
View File
@@ -2,7 +2,8 @@
// horários dos dentistas. Consome /api/nw/agenda-config (o backend garante o
// ownership: horário/feriado da clínica = dono; horário do dentista = ele ou dono).
import React, { useState, useEffect, useCallback } from 'react';
import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy, AlertTriangle } from 'lucide-react';
import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy, AlertTriangle, Check } from 'lucide-react';
import { SegmentedTabs } from './SegmentedTabs.tsx';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
const BASE = `${API}/nw/agenda-config`;
@@ -88,8 +89,8 @@ const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void;
);
};
export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas' | 'situacoes'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => {
const [aba, setAba] = useState<'clinica' | 'dentistas' | 'situacoes'>(initialTab || 'clinica');
export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas' | 'situacoes' | 'procedimentos'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => {
const [aba, setAba] = useState<'clinica' | 'dentistas' | 'situacoes' | 'procedimentos'>(initialTab || 'clinica');
useEffect(() => { if (isOpen && initialTab) setAba(initialTab); }, [isOpen, initialTab]);
const [erro, setErro] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
@@ -109,6 +110,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
// Situações (regras que a Secretária IA segue)
const [situacoes, setSituacoes] = useState<string[]>([]);
const [procedimentos, setProcedimentos] = useState<Array<{ nome: string; atende: boolean; motivo: string; como_falar: string }>>([]);
const flash = (setter: (v: string | null) => void, msg: string) => { setter(msg); setTimeout(() => setter(null), 3000); };
@@ -137,10 +139,20 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
const carregarSituacoes = useCallback(async () => {
if (!clinicaId) return;
try { const s = await req(`/clinica/${clinicaId}/situacoes`); setSituacoes((s.situacoes || []).map((r: any) => r.texto)); }
finally { /* noop */ }
}, [clinicaId]);
const carregarProcedimentos = useCallback(async () => {
if (!clinicaId) return;
try { const d = await req(`/clinica/${clinicaId}/procedimentos-atende`); setProcedimentos((d.procedimentos || []).map((r: any) => ({ nome: r.nome, atende: r.atende !== 0 && r.atende !== false, motivo: r.motivo || '', como_falar: r.como_falar || '' }))); }
catch (e: any) { flash(setErro, e.message); }
}, [clinicaId]);
useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); if (!soDentista) carregarSituacoes(); } }, [isOpen, carregarClinica, carregarDentistas, carregarSituacoes, soDentista]);
useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); if (!soDentista) { carregarSituacoes(); carregarProcedimentos(); } } }, [isOpen, carregarClinica, carregarDentistas, carregarSituacoes, carregarProcedimentos, soDentista]);
const salvarProcedimentos = async () => {
setSaving(true);
try { await req(`/clinica/${clinicaId}/procedimentos-atende`, { method: 'PUT', body: JSON.stringify({ procedimentos: procedimentos.map((p) => ({ nome: p.nome.trim(), atende: p.atende, motivo: p.motivo, como_falar: p.como_falar })).filter((p) => p.nome) }) }); flash(setOk, 'Procedimentos salvos!'); }
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
};
const carregarFolgas = useCallback((id: string) => {
req(`/dentista/${id}/folgas`).then((f) => setFolgas(f.folgas || [])).catch(() => setFolgas([]));
}, []);
@@ -204,13 +216,10 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
</div>
{/* Tabs */}
<div className="flex gap-1 px-8 pt-4 border-b border-gray-50">
{(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas', 'situacoes'] as const)).map((t) => (
<button key={t} onClick={() => setAba(t)}
className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide rounded-t-xl transition-colors ${aba === t ? 'bg-teal-600 text-white' : 'text-gray-400 hover:text-gray-600'}`}>
{t === 'clinica' ? 'Clínica' : t === 'dentistas' ? 'Dentistas' : 'Situações'}
</button>
))}
<div className="px-8 pt-4">
<SegmentedTabs
tabs={(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas', 'situacoes', 'procedimentos'] as const)).map((t) => ({ id: t, label: t === 'clinica' ? 'Clínica' : t === 'dentistas' ? 'Dentistas' : t === 'situacoes' ? 'Situações' : 'Procedimentos' }))}
value={aba} onChange={setAba} />
</div>
{(erro || ok) && (
@@ -301,7 +310,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
</section>
)}
</div>
) : (
) : aba === 'situacoes' ? (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2"><AlertTriangle size={16} className="text-teal-600" /> Situações e regras da Secretária IA</h3>
@@ -325,6 +334,47 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
</div>
{situacoes.length === 0 && <p className="text-[11px] text-gray-400 italic">Nenhuma situação cadastrada. A IA seguirá apenas o roteiro padrão.</p>}
</div>
) : (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2"><AlertTriangle size={16} className="text-teal-600" /> Procedimentos que a clínica atende</h3>
<button onClick={salvarProcedimentos} disabled={saving} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
</button>
</div>
<p className="text-[11px] text-gray-500 leading-relaxed bg-amber-50 rounded-2xl p-4">
Marque o que a clínica <b>atende</b>. Ao <b>desmarcar</b>, a Secretária IA não vai agendar aquele procedimento preencha o <b>motivo</b> e o <b>como falar</b> (o que ela deve responder ao paciente).
</p>
<div className="space-y-2">
{procedimentos.map((p, i) => (
<div key={i} className="rounded-2xl border border-gray-100 p-3">
<div className="flex items-center gap-3">
<button type="button" onClick={() => setProcedimentos(procedimentos.map((x, j) => j === i ? { ...x, atende: !x.atende } : x))}
className={`w-6 h-6 rounded-lg flex items-center justify-center border-2 shrink-0 transition-colors ${p.atende ? 'bg-teal-600 border-teal-600 text-white' : 'bg-white border-gray-300 text-transparent'}`}>
<Check size={14} />
</button>
<input value={p.nome} onChange={(e) => setProcedimentos(procedimentos.map((x, j) => j === i ? { ...x, nome: e.target.value } : x))}
placeholder="Nome do procedimento"
className="flex-1 bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold text-gray-800 outline-none" />
<span className={`text-[10px] font-black uppercase ${p.atende ? 'text-teal-600' : 'text-red-500'}`}>{p.atende ? 'Atende' : 'Não atende'}</span>
<button onClick={() => setProcedimentos(procedimentos.filter((_, j) => j !== i))} className="text-gray-300 hover:text-red-600"><Trash2 size={16} /></button>
</div>
{!p.atende && (
<div className="mt-2 pl-9 space-y-2">
<input value={p.motivo} onChange={(e) => setProcedimentos(procedimentos.map((x, j) => j === i ? { ...x, motivo: e.target.value } : x))}
placeholder="Motivo de não atender (uso interno)"
className="w-full bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-xs font-medium text-gray-700 outline-none" />
<textarea value={p.como_falar} rows={2} onChange={(e) => setProcedimentos(procedimentos.map((x, j) => j === i ? { ...x, como_falar: e.target.value } : x))}
placeholder="Como a Secretária deve falar isso ao paciente…"
className="w-full bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-xs font-medium text-gray-700 outline-none resize-y" />
</div>
)}
</div>
))}
<button onClick={() => setProcedimentos([...procedimentos, { nome: '', atende: true, motivo: '', como_falar: '' }])} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Novo procedimento</button>
</div>
{procedimentos.length === 0 && <p className="text-[11px] text-gray-400 italic">Nenhum procedimento na lista. Adicione os que a clínica atende (ou não).</p>}
</div>
)}
</div>
</div>
+201 -13
View File
@@ -2,7 +2,7 @@
// registrou ("vou confirmar e retorno"). A humana confirma (vira agendamento) ou
// recusa. Consome /api/nw/agenda-config/clinica/:id/pedidos*.
import React, { useState, useEffect, useCallback } from 'react';
import { X, CalendarClock, Check, Ban, Loader2, Phone, User, HelpCircle } from 'lucide-react';
import { X, CalendarClock, Check, Ban, Loader2, Phone, User, HelpCircle, MessageCircle, ChevronDown, ChevronLeft, ChevronRight, Clock, Sparkles, CalendarDays } from 'lucide-react';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
const BASE = `${API}/nw/agenda-config`;
@@ -17,10 +17,85 @@ async function req(path: string, opts: RequestInit = {}) {
const dm = (s: string) => s ? s.split('-').reverse().join('/') : s;
export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onChange?: () => void }> = ({ isOpen, onClose, clinicaId, onChange }) => {
// Grade de horários (07:0020:00, passo 30min) para AJUSTAR o horário na confirmação.
// Garante que o horário sugerido pela IA esteja sempre presente (mesmo fora do passo).
function gerarSlots(sugerido?: string): string[] {
const set = new Set<string>();
for (let m = 7 * 60; m <= 20 * 60; m += 30) {
set.add(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`);
}
const s = (sugerido || '').slice(0, 5);
if (/^\d{2}:\d{2}$/.test(s)) set.add(s);
return [...set].sort();
}
// ── Mini-calendário (troca de DIA na confirmação) ────────────────────────────
const WD = ['D', 'S', 'T', 'Q', 'Q', 'S', 'S'];
const MESES = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'];
const toISO = (y: number, m: number, d: number) => `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const hojeISO = () => { const n = new Date(); return toISO(n.getFullYear(), n.getMonth(), n.getDate()); };
// Calendário mensal compacto, sem libs. Dia sugerido pela IA marcado (bolinha âmbar),
// selecionado em verde, hoje com anel, passado desabilitado. Navega por mês.
const MiniCalendar: React.FC<{ value: string; suggested: string; onSelect: (iso: string) => void }> = ({ value, suggested, onSelect }) => {
const base = (value || suggested || hojeISO()).slice(0, 10);
const [view, setView] = useState<{ y: number; m: number }>({ y: Number(base.slice(0, 4)), m: Number(base.slice(5, 7)) - 1 });
const hoje = hojeISO();
const firstDow = new Date(view.y, view.m, 1).getDay();
const nDias = new Date(view.y, view.m + 1, 0).getDate();
const cells: (number | null)[] = [...Array(firstDow).fill(null), ...Array.from({ length: nDias }, (_, i) => i + 1)];
const passo = (delta: number) => setView(v => { const m = v.m + delta; return m < 0 ? { y: v.y - 1, m: 11 } : m > 11 ? { y: v.y + 1, m: 0 } : { y: v.y, m }; });
return (
<div className="w-64 max-w-full">
<div className="flex items-center justify-between mb-2">
<button type="button" onClick={() => passo(-1)} className="p-1 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"><ChevronLeft size={16} /></button>
<span className="text-xs font-black uppercase tracking-wide text-gray-700">{MESES[view.m]} {view.y}</span>
<button type="button" onClick={() => passo(1)} className="p-1 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"><ChevronRight size={16} /></button>
</div>
<div className="grid grid-cols-7 gap-1 text-center mb-1">
{WD.map((w, i) => <span key={i} className="text-[10px] font-black text-gray-300 uppercase">{w}</span>)}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((d, i) => {
if (d === null) return <span key={i} />;
const iso = toISO(view.y, view.m, d);
const past = iso < hoje;
const on = iso === value;
const ia = iso === suggested;
const isHoje = iso === hoje;
return (
<button key={i} type="button" disabled={past} onClick={() => onSelect(iso)}
title={ia ? 'Sugerido pela IA' : undefined}
className={`relative h-8 rounded-lg text-xs font-bold tabular-nums transition-all ${
past ? 'text-gray-300 cursor-not-allowed'
: on ? 'bg-emerald-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-emerald-50 hover:text-emerald-700'} ${isHoje && !on ? 'ring-1 ring-inset ring-teal-300' : ''}`}>
{d}
{ia && !on && <span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-amber-400" />}
</button>
);
})}
</div>
</div>
);
};
export const PedidosPendentes: React.FC<{
isOpen: boolean;
onClose: () => void;
clinicaId?: string;
onChange?: () => void;
// Abre o chat do WhatsApp do solicitante (drawer da agenda) — telefone direto,
// funciona mesmo para lead sem cadastro.
onOpenChat?: (t: { pacienteId?: string; pacienteNome?: string; phone?: string }) => void;
}> = ({ isOpen, onClose, clinicaId, onChange, onOpenChat }) => {
const [pedidos, setPedidos] = useState<any[]>([]);
const [dentistas, setDentistas] = useState<any[]>([]);
const [sel, setSel] = useState<Record<string, string>>({}); // pedido_id → dentista_id escolhido
const [horaSel, setHoraSel] = useState<Record<string, string>>({}); // pedido_id → HH:MM ajustado
const [horaOpen, setHoraOpen] = useState<Record<string, boolean>>({}); // pedido_id → grade aberta
const [dataSel, setDataSel] = useState<Record<string, string>>({}); // pedido_id → YYYY-MM-DD ajustado
const [dataOpen, setDataOpen] = useState<Record<string, boolean>>({}); // pedido_id → calendário aberto
const [busy, setBusy] = useState<string | null>(null);
const [erro, setErro] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -41,8 +116,11 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
const confirmar = async (p: any) => {
const dentistaId = p.dentista_id || sel[p.id];
if (!dentistaId) { setErro('Escolha o dentista para confirmar.'); return; }
// Horário e DIA: os ajustados pela humana (se mexeu) ou os que a IA fixou.
const hora = horaSel[p.id] || String(p.hora_inicio || '').slice(0, 5);
const data = dataSel[p.id] || String(p.data || '').slice(0, 10);
setBusy(p.id); setErro(null);
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/confirmar`, { method: 'POST', body: JSON.stringify({ dentista_id: dentistaId }) }); await carregar(); onChange?.(); }
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/confirmar`, { method: 'POST', body: JSON.stringify({ dentista_id: dentistaId, hora_inicio: hora, data }) }); await carregar(); onChange?.(); }
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
};
const recusar = async (p: any) => {
@@ -88,6 +166,12 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
{p.paciente_telefone && <span className="flex items-center gap-1 text-gray-400"><Phone size={12} /> {p.paciente_telefone}</span>}</div>
</div>
<div className="flex gap-2">
{onOpenChat && p.paciente_telefone && (
<button onClick={() => onOpenChat({ pacienteNome: p.paciente_nome, phone: p.paciente_telefone })}
className="bg-[#25D366]/10 hover:bg-[#25D366]/20 text-[#128C7E] text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
<MessageCircle size={13} /> Abrir chat
</button>
)}
<button onClick={() => resolver(p)} disabled={busy === p.id}
className="bg-indigo-600 hover:bg-indigo-700 text-white text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 disabled:opacity-50">
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Resolvida
@@ -103,9 +187,46 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
<div key={p.id} className="border border-gray-200 rounded-2xl p-4 bg-white">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="space-y-1">
<div className="text-sm font-black text-gray-800">
{dm(p.data)} às {p.hora_inicio}{p.hora_fim ? `${p.hora_fim}` : ''}
</div>
{/* Dia AJUSTÁVEL: a IA fixou p.data; a humana troca se o paciente quiser outro dia. */}
{(() => {
const sugerido = String(p.data || '').slice(0, 10);
const atual = dataSel[p.id] || sugerido;
const mudou = atual !== sugerido;
return (
<button type="button" onClick={() => setDataOpen({ ...dataOpen, [p.id]: !dataOpen[p.id] })}
title="Trocar o dia combinado com o paciente"
className="flex items-center gap-1.5 text-sm font-black text-gray-800 hover:text-teal-700 transition-colors">
<CalendarDays size={14} className="text-teal-600" />
<span>{dm(atual)}</span>
{mudou ? (
<span className="text-[9px] font-black uppercase text-teal-700 bg-teal-50 px-1.5 py-0.5 rounded-md">trocado</span>
) : (
<span className="text-[9px] font-black uppercase text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-md flex items-center gap-0.5"><Sparkles size={9} /> IA</span>
)}
<ChevronDown size={14} className={`text-gray-400 transition-transform ${dataOpen[p.id] ? 'rotate-180' : ''}`} />
</button>
);
})()}
{/* Horário AJUSTÁVEL: a IA fixou p.hora_inicio; a humana troca pelo combinado com o paciente. */}
{(() => {
const sugerida = String(p.hora_inicio).slice(0, 5);
const atual = horaSel[p.id] || sugerida;
const mudou = atual !== sugerida;
return (
<button type="button" onClick={() => setHoraOpen({ ...horaOpen, [p.id]: !horaOpen[p.id] })}
title="Ajustar o horário combinado com o paciente"
className="flex items-center gap-1.5 text-sm font-black text-gray-800 hover:text-teal-700 transition-colors">
<Clock size={14} className="text-teal-600" />
<span>{atual}{p.hora_fim && !mudou ? `${String(p.hora_fim).slice(0, 5)}` : ''}</span>
{mudou ? (
<span className="text-[9px] font-black uppercase text-teal-700 bg-teal-50 px-1.5 py-0.5 rounded-md">ajustado</span>
) : (
<span className="text-[9px] font-black uppercase text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-md flex items-center gap-0.5"><Sparkles size={9} /> IA</span>
)}
<ChevronDown size={14} className={`text-gray-400 transition-transform ${horaOpen[p.id] ? 'rotate-180' : ''}`} />
</button>
);
})()}
<div className="flex items-center gap-2 text-xs text-gray-600 font-bold"><User size={13} className="text-gray-400" /> {p.paciente_nome || 'Cliente'}
{p.paciente_telefone && <span className="flex items-center gap-1 text-gray-400"><Phone size={12} /> {p.paciente_telefone}</span>}</div>
{p.procedimento && <div className="text-[11px] text-gray-500">{p.procedimento}</div>}
@@ -114,15 +235,26 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
{p.dentista_id ? (
<span className="text-[11px] font-bold text-teal-600">{p.dentista_nome}</span>
) : (
<select value={sel[p.id] || ''} onChange={(e) => setSel({ ...sel, [p.id]: e.target.value })}
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-xs font-bold text-gray-800 outline-none">
<option value="">Escolher dentista</option>
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}</option>)}
</select>
<div className="flex flex-col items-end gap-0.5">
<select value={sel[p.id] || ''} onChange={(e) => setSel({ ...sel, [p.id]: e.target.value })}
className={`bg-gray-50 border-2 rounded-xl px-2 py-1.5 text-xs font-bold text-gray-800 outline-none transition-colors ${
sel[p.id] ? 'border-transparent focus:border-teal-600' : 'border-amber-400 ring-2 ring-amber-100'}`}>
<option value="">Escolher dentista</option>
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}</option>)}
</select>
{!sel[p.id] && <span className="text-[9px] font-black uppercase tracking-wide text-amber-600">Escolha o dentista</span>}
</div>
)}
<div className="flex gap-2">
<button onClick={() => confirmar(p)} disabled={busy === p.id}
className="bg-emerald-600 hover:bg-emerald-700 text-white text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 disabled:opacity-50">
{onOpenChat && p.paciente_telefone && (
<button onClick={() => onOpenChat({ pacienteNome: p.paciente_nome, phone: p.paciente_telefone })}
className="bg-[#25D366]/10 hover:bg-[#25D366]/20 text-[#128C7E] text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
<MessageCircle size={13} /> Abrir chat
</button>
)}
<button onClick={() => confirmar(p)} disabled={busy === p.id || (!p.dentista_id && !sel[p.id])}
title={!p.dentista_id && !sel[p.id] ? 'Escolha o dentista antes de confirmar' : undefined}
className="bg-emerald-600 hover:bg-emerald-700 text-white text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed">
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Confirmar
</button>
<button onClick={() => recusar(p)} disabled={busy === p.id}
@@ -132,6 +264,62 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
</div>
</div>
</div>
{/* Calendário — escondido por padrão, abre pelo chevron do dia. Troca o dia
combinado com o paciente; o sugerido pela IA vem marcado (bolinha âmbar). */}
{dataOpen[p.id] && (() => {
const sugerido = String(p.data || '').slice(0, 10);
const atual = dataSel[p.id] || sugerido;
return (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1.5">
<CalendarDays size={12} /> Dia combinado com o paciente
</div>
<MiniCalendar value={atual} suggested={sugerido} onSelect={(iso) => setDataSel({ ...dataSel, [p.id]: iso })} />
{dataSel[p.id] && dataSel[p.id] !== sugerido && (
<button type="button" onClick={() => setDataSel({ ...dataSel, [p.id]: sugerido })}
className="mt-2 text-[10px] font-bold text-gray-400 hover:text-gray-600 uppercase tracking-wide">
Voltar ao sugerido ({dm(sugerido)})
</button>
)}
</div>
);
})()}
{/* Grade de horários — escondida por padrão, abre pelo chevron. Escolha rápida
do horário decidido com o paciente; o sugerido pela IA vem marcado. */}
{horaOpen[p.id] && (() => {
const sugerida = String(p.hora_inicio).slice(0, 5);
const atual = horaSel[p.id] || sugerida;
return (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1.5">
<Clock size={12} /> Horário combinado com o paciente
</div>
<div className="flex flex-wrap gap-1.5">
{gerarSlots(sugerida).map((h) => {
const on = h === atual;
const ia = h === sugerida;
return (
<button key={h} type="button" onClick={() => setHoraSel({ ...horaSel, [p.id]: h })}
className={`relative px-2.5 py-1.5 rounded-lg text-xs font-black tabular-nums transition-all border ${
on ? 'bg-emerald-600 text-white border-emerald-600 shadow-sm'
: 'bg-gray-50 text-gray-600 border-transparent hover:border-emerald-300 hover:bg-emerald-50'}`}>
{h}
{ia && !on && <span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-amber-400" title="Sugerido pela IA" />}
</button>
);
})}
</div>
{horaSel[p.id] && horaSel[p.id] !== sugerida && (
<button type="button" onClick={() => setHoraSel({ ...horaSel, [p.id]: sugerida })}
className="mt-2 text-[10px] font-bold text-gray-400 hover:text-gray-600 uppercase tracking-wide">
Voltar ao sugerido ({sugerida})
</button>
)}
</div>
);
})()}
</div>
))}
</div>
+46
View File
@@ -0,0 +1,46 @@
// SegmentedTabs — controle segmentado (abas em cápsula) PADRÃO do app.
// Grupo de abas mutuamente exclusivas num trilho cinza; a ativa vira um cartão
// branco com a cor de destaque. Responsivo: no mobile ocupa a largura toda e
// distribui igual; no desktop encolhe ao conteúdo. Referência: /minhas-salas.
import React from 'react';
export interface SegTab<T extends string = string> {
id: T;
label: string;
icon?: React.ElementType;
badge?: number;
}
interface SegmentedTabsProps<T extends string> {
tabs: SegTab<T>[];
value: T;
onChange: (id: T) => void;
/** Cor de destaque da aba ativa (texto + badge). Padrão: teal do app. */
accent?: string;
className?: string;
}
export function SegmentedTabs<T extends string>({ tabs, value, onChange, accent = '#0d9488', className = '' }: SegmentedTabsProps<T>) {
return (
<div className={`flex gap-1 bg-gray-100/80 p-1 rounded-2xl w-full sm:w-fit overflow-x-auto ${className}`}>
{tabs.map((t) => {
const active = value === t.id;
const Icon = t.icon;
return (
<button
key={t.id}
onClick={() => onChange(t.id)}
className={`flex-1 sm:flex-none flex items-center justify-center gap-2 px-3 sm:px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider whitespace-nowrap transition-all ${active ? 'bg-white shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
style={active ? { color: accent } : {}}
>
{Icon && <Icon size={14} className="shrink-0" />}
<span className="truncate">{t.label}</span>
{t.badge ? (
<span className="text-[9px] font-black px-1.5 py-0.5 rounded-full text-white shrink-0" style={{ backgroundColor: accent }}>{t.badge}</span>
) : null}
</button>
);
})}
</div>
);
}
+7 -6
View File
@@ -35,8 +35,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
}
return () => { alive = false; };
}, [activeWorkspace?.id]);
const canSeeInbox = !waAccess || waAccess.isOwner || waAccess.inbox;
const canSeeSecretaria = !waAccess || waAccess.isOwner || waAccess.secretaria;
// Fail-CLOSED: enquanto waAccess não carregou (null) ou negou, NÃO mostra as áreas.
const canSeeInbox = !!waAccess && (waAccess.isOwner || waAccess.inbox);
const canSeeSecretaria = !!waAccess && (waAccess.isOwner || waAccess.secretaria);
const pluginMenuItems: Array<{ id: string; label: string; icon: any; color: string }> = [];
if (activePluginIds.includes('rx-scoreodonto')) {
@@ -54,9 +55,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
// ativo (a CONFIG do plugin fica no catálogo PLUGINS, exclusivo do superadmin).
if (isPluginActive('newwhats')) {
if (canSeeInbox) pluginMenuItems.push({ id: 'wa-inbox', label: 'WHATSAPP', icon: MessageCircle, color: '#16a34a' });
// INSTÂNCIAS (gestão/QR) só para o dono; os demais operam via Inbox.
if (!waAccess || waAccess.isOwner) pluginMenuItems.push({ id: 'wa-sessions', label: 'INSTÂNCIAS', icon: Smartphone, color: '#0ea5e9' });
if (canSeeSecretaria) pluginMenuItems.push({ id: 'wa-secretaria', label: 'SECRETÁRIA IA', icon: Bot, color: '#2563eb' });
// INSTÂNCIAS (gestão/QR) só para o dono; os demais operam via Inbox. Fail-closed.
if (waAccess?.isOwner) pluginMenuItems.push({ id: 'wa-sessions', label: 'INSTÂNCIAS', icon: Smartphone, color: '#0ea5e9' });
// SECRETÁRIA IA saiu do menu — vive agora como aba DENTRO do WhatsApp (wa-inbox).
}
// Locação de Salas: menus de LOCADOR (administra as próprias salas) e LOCATÁRIO (aluga de
// terceiros). São decisões de NEGÓCIO do titular — não cabem a funcionário (subordinado) nem
@@ -122,7 +123,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
{ id: 'configuracoes', label: 'CONFIGURAÇÕES', icon: Settings },
].filter(item => {
// superadmin: itens específicos de administração global
if (currentRole === 'superadmin') return ['superadmin-overview', 'superadmin-users', 'superadmin-clinicas', 'superadmin-logs', 'superadmin-financeiro', 'superadmin-notificacoes', 'plugins', 'configuracoes'].includes(item.id);
if (currentRole === 'superadmin') return ['superadmin-overview', 'superadmin-users', 'superadmin-clinicas', 'superadmin-logs', 'superadmin-financeiro', 'superadmin-notificacoes', 'superadmin-secretaria', 'plugins', 'configuracoes'].includes(item.id);
// telas de superadmin são exclusivas do superadmin (não vazam p/ admin/donos)
if (item.id.startsWith('superadmin')) return false;
+28
View File
@@ -0,0 +1,28 @@
// Estilo PADRÃO de filtros/busca do app (fonte única). Referência visual:
// marketplace de profissionais / alugar salas.
import React, { forwardRef } from 'react';
import { Search } from 'lucide-react';
// Classes de campo e rótulo dos filtros (select/input dentro do card de filtros).
export const inputNew = 'w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm font-semibold text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors';
export const labelNew = 'text-[11px] font-bold text-gray-500 mb-1.5 block';
// Campo de BUSCA padrão (ícone à esquerda + input arredondado). Encaminha ref e
// todos os props nativos (value, onChange, onKeyDown, onFocus, autoFocus, etc.).
interface SearchInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
/** classe do wrapper (ex.: largura: 'w-full' | 'min-w-[220px]'). */
wrapperClassName?: string;
}
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({ wrapperClassName = 'w-full', className = '', ...rest }, ref) => (
<div className={`relative ${wrapperClassName}`}>
<Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
<input
ref={ref}
{...rest}
className={`w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm font-medium text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors ${className}`}
/>
</div>
),
);
SearchInput.displayName = 'SearchInput';
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+34 -5
View File
@@ -21,6 +21,18 @@ const enforceUppercase = <T extends object>(data: T): T => {
const API_URL = import.meta.env.VITE_API_URL || '/api';
// Limpa o estado LOCAL da inbox (/wa-inbox): a instância ativa selecionada e o
// cache de chats por instância (inboxCache:v1:*). Sem isso, o próximo usuário
// (ex.: superadmin após outro login) herda a sessão e vê chats antigos de outra.
const clearInboxLocalState = () => {
try {
localStorage.removeItem('activeInstanceId');
Object.keys(localStorage)
.filter(k => k.startsWith('inboxCache:'))
.forEach(k => localStorage.removeItem(k));
} catch { /* ignore */ }
};
// Returns headers always including the JWT token stored after login
let _sessionExpiredHandled = false;
const apiFetch = async (url: string, options: RequestInit = {}) => {
@@ -38,6 +50,7 @@ const apiFetch = async (url: string, options: RequestInit = {}) => {
try {
['SCOREODONTO_AUTH_TOKEN', 'SCOREODONTO_USER_DATA', 'SCOREODONTO_WORKSPACES', 'SCOREODONTO_ACTIVE_WORKSPACE', 'SCOREODONTO_PREVIEW_ROLE']
.forEach(k => localStorage.removeItem(k));
clearInboxLocalState();
} catch { /* ignore */ }
window.location.href = '/?session=expired';
window.location.reload();
@@ -119,6 +132,7 @@ export const HybridBackend = {
localStorage.removeItem('SCOREODONTO_WORKSPACES');
localStorage.removeItem('SCOREODONTO_ACTIVE_WORKSPACE');
localStorage.removeItem('SCOREODONTO_PREVIEW_ROLE');
clearInboxLocalState(); // não vazar instância/chats do /wa-inbox p/ o próximo login
window.location.href = '/';
window.location.reload();
},
@@ -393,6 +407,7 @@ export const HybridBackend = {
'SCOREODONTO_CACHE_NOTIFICACOES',
];
TENANT_CACHE_KEYS.forEach(k => localStorage.removeItem(k));
clearInboxLocalState(); // troca de clínica não deve carregar a inbox da anterior
console.log(`[SECURITY] Cache cleared for tenant switch to: ${workspaceId}`);
// ─── Set new active workspace and reload to flush React state completely ───
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(workspace));
@@ -1481,6 +1496,18 @@ export const HybridBackend = {
return { grupo: j.grupo || null, membros: j.membros || [] };
},
// Paciente único (modal de confirmação no check-in — Feature C).
getPacienteById: async (id: string): Promise<any | null> => {
const res = await apiFetch(`${API_URL}/pacientes/${id}`, {});
const j = await res.json().catch(() => ({} as any));
return j.paciente || null;
},
// Confirma os dados no check-in: valida server-side; { ok:false, mensagem } se inválido.
confirmarDadosPaciente: async (id: string, payload: { nome?: string; cpf?: string; telefone?: string; data_nascimento?: string }): Promise<{ ok: boolean; mensagem?: string; campo?: string }> => {
const res = await apiFetch(`${API_URL}/pacientes/${id}/confirmar-dados`, { method: 'POST', body: JSON.stringify(payload) });
return await res.json().catch(() => ({ ok: false, mensagem: 'Falha ao confirmar.' }));
},
// ── NewWhats: ownership & autorizações de sessão de WhatsApp ──────────────
// Dono do workspace tem poder total; delega re-scan/exclusão por sessão a outras contas.
getWaSessionAuthz: async (instanceId: string): Promise<{
@@ -1506,15 +1533,17 @@ export const HybridBackend = {
// Acesso agregado do usuário logado às áreas do WhatsApp (para esconder menu/botões).
// Fail-open: se o endpoint estiver indisponível, não esconde nada (backend é a autoridade).
getWaAccess: async (): Promise<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean }> => {
getWaAccess: async (): Promise<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean; signature?: string | null }> => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const open = { isOwner: false, inbox: true, secretaria: true, delete_msg: true };
if (!clinicaId) return open;
// Fail-CLOSED: sem clínica resolvida ou em erro, NEGA acesso — nunca vaza as áreas
// do WhatsApp (Inbox/Secretária) por falha transitória. Só libera com resposta OK.
const closed = { isOwner: false, inbox: false, secretaria: false, delete_msg: false, signature: null };
if (!clinicaId) return closed;
try {
const res = await apiFetch(`${API_URL}/nw/access?clinicaId=${encodeURIComponent(clinicaId)}`, {});
if (!res.ok) return open;
if (!res.ok) return closed;
return await res.json();
} catch { return open; }
} catch { return closed; }
},
setWaSessionAuthz: async (instanceId: string, usuarioId: string, perms: WaPerms): Promise<{ ok: boolean; error?: string }> => {
+29
View File
@@ -0,0 +1,29 @@
// Fuso de REFERÊNCIA do armazenamento. As colunas de horário (agendamentos.start_time)
// são `timestamp` naive; o backend (container) roda em America/Sao_Paulo, então o node-pg
// serializa o wall-clock naive nesse fuso. Para exibir o horário LITERAL (o mesmo que a
// Secretária ofereceu/gravou) em qualquer navegador, formatamos SEMPRE neste fuso — e não
// no fuso local do navegador (que causaria o deslocamento de ±1h).
export const STORAGE_TZ = 'America/Sao_Paulo';
const D = (v?: string | number | Date | null) => (v === null || v === undefined || v === '' ? null : new Date(v));
export const fmtHora = (v?: string | number | Date | null): string => {
const d = D(v); return d ? d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', timeZone: STORAGE_TZ }) : '—';
};
export const fmtDataCurta = (v?: string | number | Date | null): string => {
const d = D(v); return d ? d.toLocaleDateString('pt-BR', { weekday: 'short', day: 'numeric', month: 'short', timeZone: STORAGE_TZ }) : '—';
};
export const fmtDataHora = (v?: string | number | Date | null): string => {
const d = D(v); return d ? d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: STORAGE_TZ }) : '—';
};
// Converte um instante (ISO/Date) para o WALL-CLOCK naive no fuso canônico, no formato
// "YYYY-MM-DDTHH:mm:ss" (SEM offset). Uso: alimentar o FullCalendar, que — sem o plugin
// de timezone (luxon) — IGNORA o prop `timeZone` nomeado e cai no fuso do NAVEGADOR.
// Passando a string naive, o calendário exibe o horário literal em qualquer navegador
// (mesma garantia do fmtHora, mas para os eventos do grid). Retorna undefined se vazio.
export const toCalendarNaive = (v?: string | number | Date | null): string | undefined => {
const d = D(v); if (!d) return undefined;
// 'sv-SE' produz "YYYY-MM-DD HH:mm:ss" (ISO-like); trocamos o espaço por 'T'.
return d.toLocaleString('sv-SE', { timeZone: STORAGE_TZ, hour12: false }).replace(' ', 'T');
};
+51 -5
View File
@@ -3,11 +3,13 @@ import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import { fmtDataHora, toCalendarNaive } from '../utils/appTime.ts';
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
import { HorariosConfig } from '../components/HorariosConfig.tsx';
import { PedidosPendentes } from '../components/PedidosPendentes.tsx';
import { AgendaDetailModal, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx';
import { ConfirmarDadosPaciente } from '../components/ConfirmarDadosPaciente.tsx';
import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx';
import { AgendaPresence } from '../components/AgendaPresence.tsx';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
@@ -375,7 +377,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null);
const [showDetails, setShowDetails] = useState(false);
const [whatsTarget, setWhatsTarget] = useState<{ pacienteId: string; pacienteNome: string } | null>(null);
const [whatsTarget, setWhatsTarget] = useState<{ pacienteId?: string; pacienteNome?: string; phone?: string } | null>(null);
const [confirmarDadosId, setConfirmarDadosId] = useState<string | null>(null); // Feature C: paciente a confirmar no check-in
const [pendingAtender, setPendingAtender] = useState(false); // segue o Atender após confirmar
const [interesses, setInteresses] = useState<any[]>([]);
const [showInteresses, setShowInteresses] = useState(false);
const [pendencias, setPendencias] = useState<any[]>([]);
@@ -484,8 +488,11 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
return {
id: ag.id,
title: `${nomePac} - ${ag.procedimento}`,
start: ag.start,
end: ag.end,
// Wall-clock naive no fuso canônico: o FullCalendar (sem plugin luxon)
// ignora o prop timeZone nomeado e usaria o fuso do navegador — passando
// naive, exibe o horário literal (mesmo que o card) em qualquer navegador.
start: toCalendarNaive(ag.start),
end: toCalendarNaive(ag.end),
backgroundColor: cor,
borderColor: cor,
extendedProps: { ...ag, pacienteNome: nomePac, isGoogle: false, dentistaNome: dentist?.nome }
@@ -597,7 +604,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
const currentUserEmail = HybridBackend.getCurrentUser();
const gtoStore = useGTOStore();
const handleAtender = async () => {
// Prossegue o atendimento (Lançar GTO). Separado p/ ser chamado após a confirmação
// de dados (Feature C), quando ela é necessária.
const prosseguirAtender = async () => {
if (!selectedAppointment || !onNavigate) return;
try {
const result = await HybridBackend.getPacientes(selectedAppointment.pacienteNome);
@@ -614,6 +623,24 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
onNavigate('lancar-gto');
};
// Feature C: ao Atender (check-in), se o paciente veio da Secretária e ainda NÃO
// teve os dados confirmados, abre o modal de confirmação ANTES de prosseguir.
const handleAtender = async () => {
if (!selectedAppointment || !onNavigate) return;
const pid = (selectedAppointment as any).pacienteid || (selectedAppointment as any).pacienteId;
if (pid) {
try {
const pac = await HybridBackend.getPacienteById(pid);
if (pac && pac.dados_confirmados === false) {
setConfirmarDadosId(pid);
setPendingAtender(true); // após confirmar, segue o atender
return;
}
} catch { /* falha ao checar → segue normal */ }
}
await prosseguirAtender();
};
const filteredDentists = dentists?.filter(d => {
if (currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') return true;
if (currentRole === 'dentista') return d.email === currentUserEmail;
@@ -749,6 +776,11 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
<FullCalendar
key={isMobile ? 'mobile' : 'desktop'}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
/* Sem o plugin luxon, um timeZone NOMEADO ("America/Sao_Paulo") é ignorado
e o FullCalendar usa o fuso do navegador. Por isso os eventos entram como
wall-clock naive (toCalendarNaive) e aqui deixamos 'local': o horário é
exibido literal (mesmo do card), sem depender do fuso do navegador. */
timeZone="local"
initialView={isMobile ? 'timeGridDay' : 'timeGridWeek'}
headerToolbar={isMobile
? { left: 'prev,next', center: 'title', right: 'today' }
@@ -813,9 +845,22 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
onClose={() => setWhatsTarget(null)}
pacienteId={whatsTarget?.pacienteId}
pacienteNome={whatsTarget?.pacienteNome}
phone={whatsTarget?.phone}
onNavigate={onNavigate}
/>
{/* Feature C: confirmação de dados no check-in (paciente cadastrado pela Secretária) */}
<ConfirmarDadosPaciente
isOpen={!!confirmarDadosId}
pacienteId={confirmarDadosId}
onClose={() => { setConfirmarDadosId(null); setPendingAtender(false); }}
onConfirmed={() => {
setConfirmarDadosId(null);
if (pendingAtender) { setPendingAtender(false); prosseguirAtender(); }
refresh();
}}
/>
{/* Lista "A REAGENDAR" — pendências de falta/cancelamento/remarcação */}
{showPendencias && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowPendencias(false)}>
@@ -924,6 +969,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
<AgendaSettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} dentists={dentists} />
<HorariosConfig isOpen={showMeusHorarios} onClose={() => setShowMeusHorarios(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id} initialTab="dentistas" soDentista />
<PedidosPendentes isOpen={showPedidos} onClose={() => setShowPedidos(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id}
onOpenChat={(t) => setWhatsTarget(t)}
onChange={() => { const cid = (HybridBackend.getActiveWorkspace() as any)?.id; const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); if (cid) fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }).then(r => r.ok ? r.json() : { total: 0 }).then(j => setPedidosCount(j.total || 0)).catch(() => {}); }} />
{showInteresses && (
@@ -943,7 +989,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
<div key={i.id} className="border border-gray-100 rounded-xl p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-bold text-gray-800">
{new Date(i.start_time).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
{fmtDataHora(i.start_time)}
</p>
<span className={`text-[9px] font-black uppercase px-2 py-0.5 rounded-full ${i.status === 'aguardando' ? 'bg-yellow-50 text-yellow-700' : i.status === 'liberado' ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500'}`}>{i.status}</span>
</div>
+4 -8
View File
@@ -4,6 +4,7 @@ import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
import { SegmentedTabs } from '../components/SegmentedTabs.tsx';
const COLOR = '#0d9488';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
@@ -22,14 +23,9 @@ export const ComissoesView: React.FC = () => {
return (
<div className="space-y-6 pb-10">
<PageHeader title="COMISSÕES & REPASSE" />
<div className="flex gap-2 border-b border-gray-200">
{([['relatorio', 'Relatório / Fechamento', Wallet], ['regras', 'Regras de %', Percent], ['historico', 'Histórico', History]] as const).map(([id, label, Icon]) => (
<button key={id} onClick={() => setAba(id)}
className={`flex items-center gap-2 px-4 py-2.5 text-xs font-black uppercase tracking-wide border-b-2 -mb-px transition-colors ${aba === id ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>
<Icon size={15} /> {label}
</button>
))}
</div>
<SegmentedTabs
tabs={[{ id: 'relatorio', label: 'Relatório / Fechamento', icon: Wallet }, { id: 'regras', label: 'Regras de %', icon: Percent }, { id: 'historico', label: 'Histórico', icon: History }]}
value={aba} onChange={setAba} />
{aba === 'relatorio' && <AbaRelatorio toast={toast} confirm={confirm} />}
{aba === 'regras' && <AbaRegras toast={toast} confirm={confirm} />}
+19
View File
@@ -11,6 +11,15 @@ import { buscarCep } from '../services/viacep.ts';
const FIELD_CLS = 'w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm font-medium text-gray-800 outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-100 transition-all';
const LABEL_CLS = 'text-[10px] font-black text-gray-400 uppercase tracking-wider block mb-1';
const TIPO_LABEL: Record<string, string> = { pessoal: 'Pessoal', consultorio: 'Consultório', clinica: 'Clínica' };
const TZ_LABEL: Record<string, string> = {
'America/Sao_Paulo': 'Brasília (UTC-3) — SP/RJ/MG/Sul/NE', 'America/Campo_Grande': 'Campo Grande/MS (UTC-4)',
'America/Cuiaba': 'Cuiabá/MT (UTC-4)', 'America/Manaus': 'Manaus/AM (UTC-4)', 'America/Rio_Branco': 'Rio Branco/AC (UTC-5)',
'America/Belem': 'Belém/PA (UTC-3)', 'America/Fortaleza': 'Fortaleza/CE (UTC-3)', 'America/Recife': 'Recife/PE (UTC-3)',
'America/Bahia': 'Salvador/BA (UTC-3)', 'America/Boa_Vista': 'Boa Vista/RR (UTC-4)', 'America/Porto_Velho': 'Porto Velho/RO (UTC-4)',
'America/Maceio': 'Maceió/AL (UTC-3)', 'America/Noronha': 'F. de Noronha (UTC-2)',
};
const tzApi = (path: string, opts: RequestInit = {}) =>
fetch(`${(window as any).__API_URL__ || ''}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}` } });
// --- Card: Meu Perfil (Sistema) ---
const MeuPerfilCard: React.FC<{ clinColor: string; role: string }> = ({ clinColor, role }) => {
@@ -100,6 +109,7 @@ const UnidadePerfilCard: React.FC<{ workspaceId?: string; clinColor: string }> =
const [f, setF] = useState<any>({});
const [allAreas, setAllAreas] = useState<{ slug: string; nome: string; cor: string }[]>([]);
const [clinAreas, setClinAreas] = useState<string[]>([]);
const [tzOpts, setTzOpts] = useState<string[]>([]);
useEffect(() => { HybridBackend.getAreas().then(setAllAreas); }, []);
const load = async () => {
@@ -108,6 +118,7 @@ const UnidadePerfilCard: React.FC<{ workspaceId?: string; clinColor: string }> =
try {
const r = await HybridBackend.getClinicaProfile(workspaceId); if (r.success) setF(r.clinica || {});
setClinAreas(await HybridBackend.getClinicaAreas(workspaceId));
try { const tr = await tzApi(`/api/clinicas/${workspaceId}/timezone`); if (tr.ok) { const td = await tr.json(); setTzOpts(td.opcoes || []); setF((p: any) => ({ ...p, timezone: td.timezone })); } } catch { /* ignore */ }
}
catch { /* ignore */ } finally { setLoading(false); }
};
@@ -136,6 +147,7 @@ const UnidadePerfilCard: React.FC<{ workspaceId?: string; clinColor: string }> =
});
if (!r.success) { toast.error(r.message || 'ERRO AO SALVAR.'); return; }
await HybridBackend.setClinicaAreas(workspaceId, clinAreas);
if (f.timezone) { try { await tzApi(`/api/clinicas/${workspaceId}/timezone`, { method: 'PUT', body: JSON.stringify({ timezone: f.timezone }) }); } catch { /* ignore */ } }
toast.success('DADOS DA UNIDADE ATUALIZADOS!'); setEditing(false);
} catch { toast.error('ERRO AO SALVAR.'); } finally { setSaving(false); }
};
@@ -181,6 +193,13 @@ const UnidadePerfilCard: React.FC<{ workspaceId?: string; clinColor: string }> =
<div><label className={LABEL_CLS}>Cidade</label><input className={`${FIELD_CLS} uppercase`} value={f.cidade || ''} onChange={e => set('cidade', e.target.value.toUpperCase())} /></div>
<div><label className={LABEL_CLS}>Estado</label><input maxLength={2} className={`${FIELD_CLS} uppercase`} value={f.estado || ''} onChange={e => set('estado', e.target.value.toUpperCase())} /></div>
<div><label className={LABEL_CLS}>Cor</label><input type="color" className="w-full h-10 border border-gray-200 rounded-xl px-1" value={f.cor || clinColor} onChange={e => set('cor', e.target.value)} /></div>
<div className="md:col-span-2">
<label className={LABEL_CLS}>Fuso horário da unidade</label>
<select className={FIELD_CLS} value={f.timezone || 'America/Sao_Paulo'} onChange={e => set('timezone', e.target.value)}>
{(tzOpts.length ? tzOpts : ['America/Sao_Paulo']).map(tz => <option key={tz} value={tz}>{TZ_LABEL[tz] || tz}</option>)}
</select>
<p className="text-[10px] text-gray-400 mt-1">A Secretária e a agenda usam este fuso para o horário local desta unidade.</p>
</div>
</div>
<div>
<label className={LABEL_CLS}>Áreas de atuação da unidade</label>
+2 -7
View File
@@ -7,6 +7,7 @@ import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
import { SegmentedTabs } from '../components/SegmentedTabs.tsx';
const COLOR = '#0d9488'; // teal
@@ -473,13 +474,7 @@ export const ContratosView: React.FC = () => {
: <button onClick={() => setEditor('novo')} className="bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors uppercase font-bold text-sm shadow-sm"><Plus size={18} /> NOVO MODELO</button>}
</PageHeader>
<div className="flex gap-2">
{([['contratos', 'Contratos', FileSignature], ['modelos', 'Modelos', Library]] as const).map(([id, label, Icon]) => (
<button key={id} onClick={() => setTab(id)} className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider transition-all ${tab === id ? 'text-white shadow-md' : 'bg-white text-gray-500 border border-gray-200 hover:bg-gray-50'}`} style={tab === id ? { backgroundColor: COLOR } : {}}>
<Icon size={14} /> {label}
</button>
))}
</div>
<SegmentedTabs tabs={[{ id: 'contratos', label: 'Contratos', icon: FileSignature }, { id: 'modelos', label: 'Modelos', icon: Library }]} value={tab} onChange={setTab} accent={COLOR} />
{loading ? <div className="flex justify-center py-16"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div> : (
<>
+19 -1
View File
@@ -1,10 +1,11 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Users, Building, Briefcase, Plus, Trash2, Edit2, X, Check, UserPlus, Shield, ChevronDown, Copy, KeyRound } from 'lucide-react';
import { Users, Building, Briefcase, Plus, Trash2, Edit2, X, Check, UserPlus, Shield, ChevronDown, Copy, KeyRound, MessageSquare } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
import { GoogleConnectButton } from '../components/GoogleConnectButton.tsx';
import { ComunicacaoInternaModal } from '../components/ComunicacaoInternaModal.tsx';
type Tab = 'usuarios' | 'setores' | 'funcoes';
@@ -128,6 +129,7 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<Membro | null>(null);
const [comMembro, setComMembro] = useState<Membro | null>(null); // perfil de comunicação interna
// Add form
const [email, setEmail] = useState('');
@@ -202,6 +204,7 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
role: editing.role,
setor_id: editing.setor_id || null,
funcao_id: editing.funcao_id || null,
nome: editing.nome?.trim() || undefined,
});
if (!res.success) { toast.error(res.message || 'ERRO AO ATUALIZAR.'); return; }
toast.success('MEMBRO ATUALIZADO!');
@@ -264,6 +267,10 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
</div>
{podeGerenciar(m) && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => setComMembro({ ...m })} title="Comunicação interna"
className="p-2 hover:bg-teal-50 rounded-lg transition-colors text-teal-500">
<MessageSquare size={15} />
</button>
<button onClick={() => handleResetSenha(m)} title="Resetar senha"
className="p-2 hover:bg-amber-50 rounded-lg transition-colors text-amber-500">
<KeyRound size={15} />
@@ -366,6 +373,12 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
{editing && (
<Modal title={`EDITAR — ${editing.nome}`} onClose={() => setEditing(null)}>
<div className="space-y-4">
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">NOME</label>
<input value={editing.nome || ''} onChange={e => setEditing({ ...editing, nome: e.target.value.toUpperCase() })}
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-bold uppercase focus:outline-none focus:border-teal-400" />
<p className="text-[10px] text-gray-400 mt-1 leading-snug">Usado na assinatura das mensagens do WhatsApp. Troque quando a pessoa desta conta mudar (ex.: nova secretária).</p>
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">ACESSO NO SISTEMA</label>
<select value={editing.role} onChange={e => setEditing({ ...editing, role: e.target.value })}
@@ -420,6 +433,11 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
</div>
</Modal>
)}
{comMembro && (
<ComunicacaoInternaModal usuarioId={comMembro.id} clinicaId={clinicaId} nome={comMembro.nome}
onClose={() => setComMembro(null)} onSaved={() => setComMembro(null)} />
)}
</div>
);
};
+2 -5
View File
@@ -4,6 +4,7 @@ import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
import { SegmentedTabs } from '../components/SegmentedTabs.tsx';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
@@ -137,11 +138,7 @@ export const GlosasView: React.FC = () => {
return (
<div className="space-y-6 pb-10">
<PageHeader title="GLOSAS DE CONVÊNIO" />
<div className="flex gap-2 border-b border-gray-200">
{([['registrar', 'Recebíveis / Marcar glosa'], ['glosas', `Glosas (${glosas.length})`]] as const).map(([id, label]) => (
<button key={id} onClick={() => setAba(id)} className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide border-b-2 -mb-px transition-colors ${aba === id ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{label}</button>
))}
</div>
<SegmentedTabs tabs={[{ id: 'registrar', label: 'Recebíveis / Marcar glosa' }, { id: 'glosas', label: `Glosas (${glosas.length})` }]} value={aba} onChange={setAba} />
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : aba === 'registrar' ? (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
+2 -5
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { Building2, Wallet, Clock, RefreshCw, Inbox, CheckCircle2, Phone, CalendarDays, Stethoscope, Briefcase, User } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { PageHeader } from '../components/PageHeader.tsx';
import { SegmentedTabs } from '../components/SegmentedTabs.tsx';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
@@ -55,11 +56,7 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
<div className="flex border-b border-gray-100">
{(['clientes', 'financeiro'] as const).map(t => (
<button key={t} onClick={() => setAba(t)} className={`px-4 py-2.5 text-xs font-black uppercase border-b-2 -mb-px transition-colors ${aba === t ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t === 'clientes' ? 'Clientes (CRM)' : 'Financeiro'}</button>
))}
</div>
<SegmentedTabs tabs={[{ id: 'clientes', label: 'Clientes (CRM)' }, { id: 'financeiro', label: 'Financeiro' }]} value={aba} onChange={setAba} />
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
<>
+2 -10
View File
@@ -6,6 +6,7 @@ import { useHybridBackend } from '../hooks/useHybridBackend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
import { SearchInput } from '../components/filterStyles.tsx';
import { OrcamentoModal, DEFAULT_CLAUSULAS } from './OrcamentoModal.tsx';
type StatusFilter = 'TODOS' | 'Ativo' | 'Rascunho' | 'Assinado' | 'Encerrado';
@@ -148,16 +149,7 @@ export const OrcamentosView: React.FC = () => {
{/* Search + status filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" size={16} />
<input
type="text"
placeholder="Buscar por paciente ou título..."
className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none bg-white"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<SearchInput wrapperClassName="flex-1" placeholder="Buscar por paciente ou título..." value={search} onChange={e => setSearch(e.target.value)} />
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1 overflow-x-auto">
{STATUS_TABS.map(s => (
<button
+5 -9
View File
@@ -4,6 +4,7 @@ import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
import { SegmentedTabs } from '../components/SegmentedTabs.tsx';
import { rotulosPaciente } from '../utils/pacienteLabel.ts';
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
import { SearchSelect } from '../components/SearchSelect.tsx';
@@ -318,11 +319,8 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
</div>
</div>
{/* Abas */}
<div className="flex border-b border-gray-100 px-2 overflow-x-auto">
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-3 py-2.5 text-xs font-black uppercase border-b-2 -mb-px whitespace-nowrap transition-colors ${tab === t.id ? 'border-violet-600 text-violet-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t.label}</button>
))}
<div className="px-4 pt-3">
<SegmentedTabs tabs={TABS.map(t => ({ id: t.id, label: t.label }))} value={tab} onChange={setTab} accent="#7c3aed" />
</div>
<div className="flex-1 overflow-y-auto p-5">
@@ -1037,10 +1035,8 @@ const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => v
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FileText size={16} className="text-violet-600" /> Cotações de prótese</h3>
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
</div>
<div className="flex border-b border-gray-100 px-2">
{(['lista', 'novo'] as const).map(t => (
<button key={t} onClick={() => setAba(t)} className={`px-3 py-2.5 text-xs font-black uppercase border-b-2 -mb-px ${aba === t ? 'border-violet-600 text-violet-700' : 'border-transparent text-gray-400'}`}>{t === 'lista' ? 'Meus pedidos' : 'Pedir cotação'}</button>
))}
<div className="px-4 pt-3">
<SegmentedTabs tabs={[{ id: 'lista', label: 'Meus pedidos' }, { id: 'novo', label: 'Pedir cotação' }]} value={aba} onChange={setAba} accent="#7c3aed" />
</div>
<div className="flex-1 overflow-y-auto p-5">
{aba === 'novo' ? (
+8 -6
View File
@@ -113,8 +113,8 @@ const TabSecretaria: React.FC = () => {
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tight flex items-center gap-2"><Power size={18} className="text-red-600" /> Secretária IA liga/desliga por sessão</h2>
<p className="text-xs text-gray-400 font-medium">Quem ligou/desligou a Secretária de cada número, e o motivo do desligamento. {totalOff > 0 && `${totalOff} desligamento(s).`}</p>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tight flex items-center gap-2"><Power size={18} className="text-red-600" /> Secretária IA motivos de desativação</h2>
<p className="text-xs text-gray-400 font-medium">Motivos de desativar a Secretária para TODOS os chats (global) ou por sessão. Contexto GERAL p/ identificar bugs/estratégias no atendimento e corrigir. Sem dados das clínicas. {totalOff > 0 && `${totalOff} desativação(ões).`}</p>
</div>
<button onClick={load} className="p-2 rounded-xl border border-gray-200 text-gray-400 hover:text-gray-700"><RefreshCw size={16} /></button>
</div>
@@ -130,8 +130,7 @@ const TabSecretaria: React.FC = () => {
<thead className="bg-gray-50 text-[10px] font-black text-gray-400 uppercase tracking-widest">
<tr>
<th className="text-left px-4 py-3">Ação</th>
<th className="text-left px-4 py-3">Número</th>
<th className="text-left px-4 py-3">Quem</th>
<th className="text-left px-4 py-3">Escopo</th>
<th className="text-left px-4 py-3">Quando</th>
<th className="text-left px-4 py-3">Motivo</th>
</tr>
@@ -144,8 +143,11 @@ const TabSecretaria: React.FC = () => {
? <span className="inline-flex items-center gap-1 text-emerald-700 text-[11px] font-black uppercase"><ToggleRight size={14} /> Ligou</span>
: <span className="inline-flex items-center gap-1 text-red-600 text-[11px] font-black uppercase"><ToggleLeft size={14} /> Desligou</span>}
</td>
<td className="px-4 py-3 text-gray-700 font-bold whitespace-nowrap">{l.phone || l.name || l.instance_id}</td>
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{l.by || '—'}</td>
<td className="px-4 py-3">
{l.escopo === 'global'
? <span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-black uppercase bg-red-50 text-red-700 border border-red-200">Todos os chats</span>
: <span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-black uppercase bg-gray-100 text-gray-500 border border-gray-200">Uma sessão</span>}
</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{fmt(l.at)}</td>
<td className="px-4 py-3 text-gray-600">{l.reason || <span className="text-gray-300"></span>}</td>
</tr>
+279 -19
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import {
Search,
MoreVertical,
@@ -10,7 +10,11 @@ import {
MessageSquarePlus,
RotateCw,
ArrowLeft,
Info,
Bot,
X,
} from 'lucide-react';
import { roleMeta } from './utils/roleMeta';
import { motion, AnimatePresence } from 'framer-motion';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
@@ -22,17 +26,24 @@ import { useChatFilter } from './hooks/inbox/useChatFilter';
import { useChatWindow } from './hooks/inbox/useChatWindow';
import { useMessageInput } from './hooks/inbox/useMessageInput';
import { nw } from './services/nwClient';
import { chatApi } from './services/chatApiService';
import { useChatStore } from './store/chatStore';
import ForwardModal from './components/ForwardModal';
import LabelManagerModal from './components/LabelManagerModal';
import { useProtocolPanel } from './hooks/useProtocolPanel';
import ChatSidebar from './components/ChatSidebar';
import ThinSidebar, { TabType } from './components/ThinSidebar';
import InboxHeader from './components/InboxHeader';
import MessageArea from './components/MessageArea';
import ConversationSearch from './components/ConversationSearch';
import SettingsPanel from './components/SettingsPanel';
import InputBar from './components/InputBar';
import NewConversationModal from './components/NewConversationModal';
import ContactProfile from './components/ContactProfile';
import ProtocolPanel from './components/ProtocolPanel';
import { HybridBackend } from '../../services/backend';
import { PedidosPendentes } from '../../components/PedidosPendentes.tsx';
import { AgendaView } from '../AgendaView.tsx';
type Chat = NewWhatsChat;
@@ -63,6 +74,7 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
handleSendText,
handleLoadMoreHistory,
handleMenuAction,
handleDeleteMessage,
} = useNewInboxBridge()
// ── Filtros de chat (hook dedicado) ───────────────────────────────────────
@@ -81,7 +93,6 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
// ── Chat window (hook dedicado) ───────────────────────────────────────────
const {
scrollRef,
isTyping,
replyingTo,
reactionPickerFor,
hasMoreHistory,
@@ -100,20 +111,45 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
handleSendAudio,
handleSyncAvatar,
toggleReactionPicker,
handleClearConversation,
editingMessage,
setEditingMessage,
forwardingMessage,
setForwardingMessage,
} = useChatWindow({
selectedChat,
activeInstanceId: activeInstance?.instance ?? null,
onLoadMoreHistory: handleLoadMoreHistory,
})
// "digitando..." do CONTATO (presença composing/recording do chat aberto).
const activePresence = selectedChat?.remote_jid ? presenceByJid[selectedChat.remote_jid] : undefined
const isTyping = !!activePresence && (Date.now() - activePresence.ts <= 8000) && (activePresence.state === 'composing' || activePresence.state === 'recording')
// ── Mensagem de entrada (hook dedicado) ───────────────────────────────────
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
selectedChat,
replyingTo,
onClearReply: () => setReplyingTo(null),
onSendText: handleSendText,
editingMessage,
onClearEdit: () => setEditingMessage(null),
onEditText: async (messageId, text) => {
if (!selectedChat?.id) return
try {
await chatApi.editMessage(String(selectedChat.id), messageId, text)
useChatStore.getState().patchMessage(messageId, { body: text })
} catch (e) { console.error('Erro ao editar:', e) }
},
})
// Entra em modo edição: preenche o input com o texto atual da mensagem.
const handleEnterEdit = useCallback((msg: any) => {
setEditingMessage(msg)
setReplyingTo(null)
setNewMessage(msg?.text ?? msg?.body ?? '')
}, [setEditingMessage, setReplyingTo, setNewMessage])
// ── Painel de Protocolo ───────────────────────────────────────────────────
const [isProtocolOpen, setIsProtocolOpen] = useState(false)
const protocolPanel = useProtocolPanel(selectedChat ? String(selectedChat.id) : null)
@@ -121,19 +157,99 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
// ── Estado local de UI ────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<TabType>('chats')
const [isInstanceDropdownOpen, setIsInstanceDropdownOpen] = useState(false)
const [isRoleInfoOpen, setIsRoleInfoOpen] = useState(false)
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const roleInfoRef = useRef<HTMLDivElement>(null)
const [isOptionsMenuOpen, setIsOptionsMenuOpen] = useState(false)
const [drafts] = useState<Record<string, string>>({})
const [isMobile, setIsMobile] = useState(false)
const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false)
// Permissão de apagar conversa/mensagem (dono ou liberado). Fail-open enquanto carrega.
const [canDeleteMsg, setCanDeleteMsg] = useState(true)
// Permissão de apagar conversa/mensagem (dono ou liberado). Fail-closed: só libera após confirmar.
const [canDeleteMsg, setCanDeleteMsg] = useState(false)
// "Limpar conversa" é só do DONO do workspace. Fail-closed: só aparece se confirmado dono.
const [isWaOwner, setIsWaOwner] = useState(false)
// Secretária IA: só o dono ou quem tem can_secretaria acessa a config. Fail-closed.
const [canSecretaria, setCanSecretaria] = useState(false)
const [labelModalOpen, setLabelModalOpen] = useState(false)
useEffect(() => {
let alive = true
HybridBackend.getWaAccess().then(a => { if (alive) setCanDeleteMsg(a.isOwner || a.delete_msg) }).catch(() => {})
HybridBackend.getWaAccess().then(a => { if (alive) { setCanDeleteMsg(a.isOwner || a.delete_msg); setIsWaOwner(!!a.isOwner); setCanSecretaria(a.isOwner || a.secretaria); useChatStore.getState().setOperatorSignature(a.signature ?? null) } }).catch(() => {})
return () => { alive = false }
}, [])
// ── Fila da Secretária ────────────────────────────────────────────────────
// Pedidos que a Secretária IA encaminhou p/ a humana confirmar (zona cinza).
// Carregamos os telefones dos pendentes e cruzamos com a lista de conversas
// (últimos 8 dígitos, tolerante ao 9º dígito/DDI) → flag `pendenteSec` por chat.
// A aba "Fila" filtra a lista por essa flag; a lista normal só ganha o selo.
const [pendingPhones, setPendingPhones] = useState<Set<string>>(new Set())
const [pedidosCount, setPedidosCount] = useState(0) // total de pedidos pendentes (badge do botão)
const [showPedidos, setShowPedidos] = useState(false)
const [showAgenda, setShowAgenda] = useState(false) // agenda em overlay, sem sair do inbox
const carregarPendentes = useCallback(async () => {
const cid = (HybridBackend.getActiveWorkspace() as any)?.id
if (!cid) return
try {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN')
const r = await fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
const j = r.ok ? await r.json() : { pedidos: [] }
const keys = new Set<string>()
for (const p of (j.pedidos || [])) {
const k = String(p.paciente_telefone || '').replace(/\D/g, '').slice(-8)
if (k) keys.add(k)
}
setPendingPhones(keys)
setPedidosCount((j.pedidos || []).length)
} catch { /* silencioso: fila é auxiliar, não bloqueia o inbox */ }
}, [])
useEffect(() => {
carregarPendentes()
const t = setInterval(carregarPendentes, 30000) // mantém a fila fresca sem recarregar a página
return () => { clearInterval(t) }
}, [carregarPendentes])
// ── Nomes NOSSOS (base de pacientes) por telefone ─────────────────────────
// Mostra o nome que a clínica cadastrou no lugar do número/pushname do WhatsApp.
// Um número pode ter VÁRIOS pacientes (grupo familiar) → `principal` = "1º nome +N".
// Fallback (sem paciente): pushname → telefone. Casa pelos últimos 8 dígitos.
const [nomesNossos, setNomesNossos] = useState<Record<string, { principal: string; count: number }>>({})
const phonesKey = useMemo(
() => [...new Set(chatsByScope.map(c => String(c.phone || c.remote_jid || '').replace(/\D/g, '').slice(-8)).filter(k => k.length >= 8))].sort().join(','),
[chatsByScope],
)
useEffect(() => {
const cid = (HybridBackend.getActiveWorkspace() as any)?.id
const telefones = phonesKey ? phonesKey.split(',') : []
if (!cid || telefones.length === 0) return
let alive = true
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN')
fetch(`/api/nw/agenda-config/clinica/${cid}/nomes-por-telefone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ telefones }),
}).then(r => (r.ok ? r.json() : { mapa: {} }))
.then(j => { if (alive) setNomesNossos(j.mapa || {}) })
.catch(() => { /* silencioso: sem nossos nomes, cai no pushname */ })
return () => { alive = false }
}, [phonesKey])
const nomeKey = useCallback((c: any) => String(c?.phone || c?.remote_jid || '').replace(/\D/g, '').slice(-8), [])
const selectedNomeNosso = selectedChat ? nomesNossos[nomeKey(selectedChat)]?.principal : undefined
// Enriquecimento: nome NOSSO + flag `pendenteSec` (fila) por conversa.
const chatsEnriched = useMemo(() => {
const temNomes = Object.keys(nomesNossos).length > 0
if (pendingPhones.size === 0 && !temNomes) return chatsByScope
return chatsByScope.map(c => {
const key = String(c.phone || c.remote_jid || '').replace(/\D/g, '').slice(-8)
const nomeNosso = key ? nomesNossos[key]?.principal : undefined
const pend = !!(key && pendingPhones.has(key))
return (nomeNosso || pend) ? { ...c, ...(nomeNosso ? { nomeNosso } : {}), ...(pend ? { pendenteSec: true } : {}) } : c
})
}, [chatsByScope, pendingPhones, nomesNossos])
const filaChats = useMemo(() => chatsEnriched.filter((c: any) => c.pendenteSec), [chatsEnriched])
// Preloader: aparece APENAS em hard reload (F5 / URL direta) — nunca em
// navegação client-side (router.push). Detectado por:
// 1. performance.getEntriesByType('navigation')[0].type === 'reload'
@@ -171,6 +287,7 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
resize()
const click = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) setIsInstanceDropdownOpen(false)
if (roleInfoRef.current && !roleInfoRef.current.contains(e.target as Node)) setIsRoleInfoOpen(false)
if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) setIsOptionsMenuOpen(false)
}
document.addEventListener('mousedown', click)
@@ -189,6 +306,18 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
handleMenuAction(action, chat, toggleFavorite)
}, [handleMenuAction, toggleFavorite])
// "Abrir chat" a partir do modal de pedidos: seleciona a conversa correspondente
// na própria lista do inbox (casa pelos últimos 8 dígitos) e fecha o modal.
const handleOpenChatFromFila = useCallback((t: { pacienteNome?: string; phone?: string }) => {
const key = String(t.phone || '').replace(/\D/g, '').slice(-8)
const chat = key ? chatsStore.find(c => String(c.phone || c.remote_jid || '').replace(/\D/g, '').slice(-8) === key) : undefined
if (chat) {
handleSelectChat(chat as any)
setShowPedidos(false)
setActiveTab('chats')
}
}, [chatsStore, handleSelectChat])
const handleFetchChats = useCallback(async () => {
await refreshInstances()
@@ -249,9 +378,9 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
</AnimatePresence>
<div className="flex flex-1 overflow-hidden h-full">
<ThinSidebar activeTab={activeTab} setActiveTab={setActiveTab} activeInstance={activeInstance} onNavigate={onNavigate} />
<ThinSidebar activeTab={activeTab} setActiveTab={setActiveTab} activeInstance={activeInstance} onNavigate={onNavigate} filaCount={filaChats.length} canPower={isWaOwner} />
{activeTab === 'chats' && (
{(activeTab === 'chats' || activeTab === 'fila') && (
<div className={`${isMobile ? (selectedChat ? 'hidden' : 'w-full') : 'w-[400px]'} flex flex-col bg-white border-r border-[#d1d7db] shrink-0 z-20`}>
{/* Header da sidebar */}
<div className="h-[60px] px-4 flex items-center justify-between bg-[#f0f2f5] border-b border-[#d1d7db] shrink-0">
@@ -284,10 +413,12 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
{isInstanceDropdownOpen && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}
className="absolute left-0 top-full bg-white shadow-2xl rounded-xl border border-gray-100 w-72 z-50 py-2">
{instances.map(i => (
{instances.map(i => {
const rm = roleMeta(i.role)
return (
<button key={i.instance} onClick={() => { selectInstance(i); setIsInstanceDropdownOpen(false) }}
className="w-full p-3 hover:bg-gray-50 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-100 overflow-hidden">
<div className="w-8 h-8 rounded-full bg-gray-100 overflow-hidden shrink-0">
<img
src={getAvatarProxyUrl({
instance_name: i.instance,
@@ -297,12 +428,74 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
</div>
<div className="text-left">
<span className="text-sm font-medium block">{i.nome}</span>
<span className={`text-xs ${i.status === 'connected' ? 'text-green-500' : 'text-gray-400'}`}>{i.status}</span>
<div className="text-left min-w-0 flex-1">
<span className="text-sm font-medium block truncate">{i.nome}</span>
<span className="text-[11px] flex items-center gap-1 truncate" style={{ color: rm.color }}>
<span className="w-1.5 h-1.5 rounded-full inline-block shrink-0" style={{ background: rm.color }} />
{rm.label}{!i.own && i.ownerNome ? ` · ${i.ownerNome}` : ''}
</span>
</div>
{!i.own && (
<span className="text-[9px] font-bold uppercase tracking-wide text-brand-600 bg-brand-500/10 px-1.5 py-0.5 rounded shrink-0">clínica</span>
)}
</button>
))}
)
})}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Secretária IA aba dentro do WhatsApp (saiu do menu lateral).
o dono ou quem tem can_secretaria acessa a CONFIG (fail-closed). */}
{canSecretaria && (
<button
onClick={() => onNavigate?.('wa-secretaria')}
className="p-1.5 rounded-full text-[#8696a0] hover:bg-black/5 hover:text-[#2563eb] transition-colors"
title="Secretária IA"
>
<Bot className="w-4 h-4" />
</button>
)}
{/* Ícone (i): legenda dos papéis de cada número acessível */}
<div className="relative" ref={roleInfoRef}>
<button
onClick={() => setIsRoleInfoOpen(o => !o)}
className={`p-1.5 rounded-full transition-colors ${isRoleInfoOpen ? 'bg-black/10 text-[#111b21]' : 'text-[#8696a0] hover:bg-black/5 hover:text-[#54656f]'}`}
title="O que é cada número?"
>
<Info className="w-4 h-4" />
</button>
<AnimatePresence>
{isRoleInfoOpen && (
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }}
className="absolute left-0 top-full mt-1 bg-white shadow-2xl rounded-xl border border-gray-100 w-80 z-50 p-3">
<p className="text-[11px] font-bold uppercase tracking-wide text-gray-400 px-1 pb-2">Seus números</p>
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
{instances.length === 0 && (
<p className="text-xs text-gray-400 px-1 py-2">Nenhum número disponível.</p>
)}
{instances.map(i => {
const rm = roleMeta(i.role)
const desc = [i.area, i.notes].filter(Boolean).join(' · ')
return (
<div key={i.instance} className="flex items-start gap-2.5 px-1">
<span className="w-2 h-2 rounded-full mt-1.5 shrink-0" style={{ background: rm.color }} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[13px] font-semibold text-[#111b21] truncate">{i.nome}</span>
{!i.own && <span className="text-[9px] font-bold uppercase text-brand-600 bg-brand-500/10 px-1 py-0.5 rounded shrink-0">clínica</span>}
</div>
<p className="text-[11px] font-medium" style={{ color: rm.color }}>{rm.label}</p>
{i.phone && <p className="text-[11px] text-gray-500">{i.phone}</p>}
{desc && <p className="text-[11px] text-gray-400 leading-snug">{desc}</p>}
{!i.own && i.ownerNome && <p className="text-[10px] text-gray-400">caixa de {i.ownerNome}</p>}
</div>
</div>
)
})}
</div>
</motion.div>
)}
</AnimatePresence>
@@ -350,9 +543,9 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
</button>
</div>
{/* Lista de chats */}
{/* Lista de chats — na aba "Fila", só as conversas com pendência da Secretária */}
<ChatSidebar
chats={chatsByScope}
chats={activeTab === 'fila' ? filaChats : chatsEnriched}
loading={loading}
selectedChat={selectedChat}
searchTerm={searchTerm}
@@ -393,10 +586,27 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
isSyncingAvatar={isSyncingAvatar}
isProtocolOpen={isProtocolOpen}
hasActiveProtocol={protocolPanel.hasActive}
onSearchClick={() => {}}
isOwner={isWaOwner}
nomeNosso={selectedNomeNosso}
activeInstanceId={activeInstance?.instance ?? null}
pedidosCount={pedidosCount}
onOpenPedidos={() => setShowPedidos(true)}
onOpenAgenda={() => setShowAgenda(true)}
onClearConversation={handleClearConversation}
onManageLabels={() => setLabelModalOpen(true)}
onSearchClick={() => setIsSearchOpen(true)}
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
onProtocolClick={() => { setIsProtocolOpen(!isProtocolOpen); setIsProfileOpen(false) }}
/>
{isSearchOpen && (
<ConversationSearch
messages={messages as any}
onClose={() => setIsSearchOpen(false)}
onQueryChange={setSearchQuery}
hasOlder={hasMoreHistory}
loadOlder={async () => { const r = await handleLoadMoreHistory(); return !!r?.hasMore; }}
/>
)}
<MessageArea
messages={messages as any}
selectedChat={selectedChat}
@@ -405,14 +615,15 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
onScroll={handleScroll}
historyLoadingMore={loadingMore}
hasMoreHistory={hasMoreHistory}
highlightTerm={isSearchOpen ? searchQuery : ''}
reactionPickerFor={reactionPickerFor}
onReply={setReplyingTo}
onToggleReactionPicker={toggleReactionPicker}
onReact={handleReactToMessage}
onForward={() => {}}
onDelete={() => {}}
onForward={(msg: any) => setForwardingMessage(msg)}
onDelete={(msg: any) => { if (selectedChat && msg?.id) handleDeleteMessage(String(selectedChat.id), String(msg.id)); }}
canDelete={canDeleteMsg}
onEdit={() => {}}
onEdit={handleEnterEdit}
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
getGroupSenderLabel={(msg: any) => {
if (msg.direction === 'out') return 'Você'
@@ -432,6 +643,8 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
onPresence={handleTyping}
onCancelReply={() => setReplyingTo(null)}
replyingTo={replyingTo}
editingMessage={editingMessage}
onCancelEdit={() => { setEditingMessage(null); setNewMessage('') }}
selectedChat={selectedChat}
isMobile={isMobile}
onSendMedia={handleSendMedia}
@@ -446,6 +659,7 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
chat={selectedChat}
messages={messages as any}
onClose={() => setIsProfileOpen(false)}
nomeNosso={selectedNomeNosso}
/>
<ProtocolPanel
isOpen={isProtocolOpen && !isMobile}
@@ -479,6 +693,52 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
}
}}
/>
<LabelManagerModal
isOpen={labelModalOpen}
onClose={() => setLabelModalOpen(false)}
chat={selectedChat as any}
onChanged={() => { if (activeInstance?.instance) useChatStore.getState().loadChats(activeInstance.instance) }}
/>
<ForwardModal
isOpen={!!forwardingMessage}
onClose={() => setForwardingMessage(null)}
chats={chatsByScope as any}
onForward={async (toChatIds: string[]) => {
if (!forwardingMessage || !selectedChat?.id) return
try {
await chatApi.forward(String(selectedChat.id), String((forwardingMessage as any).id), toChatIds)
} catch (e) { console.error('Erro ao encaminhar:', e) }
setForwardingMessage(null)
}}
/>
{/* Fila da Secretária — mesmo modal da /agenda (confirmar/recusar/ajustar horário) */}
<PedidosPendentes
isOpen={showPedidos}
onClose={() => setShowPedidos(false)}
clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id}
onOpenChat={handleOpenChatFromFila}
onChange={carregarPendentes}
/>
{/* Agenda em overlay — reutiliza a AgendaView inteira p/ conferir horários vagos
em outro dia sem sair do inbox. Overlay full-screen: zero mudança estrutural. */}
{showAgenda && (
<div className="fixed inset-0 z-[80] bg-white flex flex-col">
<div className="h-12 px-4 flex items-center justify-between border-b border-gray-100 bg-[#f0f2f5] shrink-0">
<span className="text-sm font-black uppercase tracking-tight text-gray-700">Agenda</span>
<button onClick={() => setShowAgenda(false)} className="p-2 hover:bg-black/5 rounded-full text-[#54656f]" title="Fechar agenda (voltar ao chat)">
<X className="w-5 h-5" />
</button>
</div>
{/* Mesmo respiro da página /agenda (App.tsx: p-4 md:p-8 + scroll) */}
<div className="flex-1 min-h-0 overflow-y-auto scroll-smooth p-4 md:p-8">
<AgendaView onNavigate={onNavigate} />
</div>
</div>
)}
</div>
)
}
+98 -11
View File
@@ -1617,27 +1617,57 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
const [showFila, setShowFila] = useState(false)
const [pendCount, setPendCount] = useState(0)
// Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) — todos, sem timer
// Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) — todos os chats.
// Ao desligar exige MOTIVO (contexto geral p/ o super admin) e guarda histórico.
const [secOn, setSecOn] = useState(true)
const [powerBusy, setPowerBusy] = useState(false)
const [powerModal, setPowerModal] = useState(false)
const [powerReason, setPowerReason] = useState('')
const [powerData, setPowerData] = useState<any>(null)
const [powerErr, setPowerErr] = useState<string | null>(null)
// Guard de acesso (fail-closed): a CONFIG da Secretária é só do dono ou de quem tem
// can_secretaria. Protege contra navegação por URL direta a /wa-secretaria — sem isto,
// qualquer operador do inbox abriria esta tela. Enquanto verifica, não renderiza nada.
const [accessChecked, setAccessChecked] = useState(false)
const [hasSecretariaAccess, setHasSecretariaAccess] = useState(false)
useEffect(() => {
let alive = true
HybridBackend.getWaAccess().then((a) => {
if (!alive) return
const ok = !!(a.isOwner || a.secretaria)
setHasSecretariaAccess(ok); setAccessChecked(true)
if (!ok) props.onNavigate?.('wa-inbox')
}).catch(() => { if (alive) { setAccessChecked(true); props.onNavigate?.('wa-inbox') } })
return () => { alive = false }
}, [])
// Boot
useEffect(() => {
store.loadAgents()
store.loadCalendar()
store.loadNumbers()
secPowerApi.get().then((r) => setSecOn(r.enabled)).catch(() => {})
secPowerApi.get().then((r) => { setSecOn(r.enabled); setPowerData(r) }).catch(() => {})
}, [])
const handleTogglePower = useCallback(async () => {
const openPowerModal = useCallback(() => {
setPowerErr(null); setPowerReason('')
secPowerApi.get().then((r) => { setSecOn(r.enabled); setPowerData(r) }).catch(() => {})
setPowerModal(true)
}, [])
const confirmTogglePower = useCallback(async () => {
if (powerBusy) return
const proximo = !secOn
// Ao DESLIGAR (para todos os chats, sem retorno automático), confirma.
if (!proximo && !window.confirm('Desligar a Secretária para TODOS os chats? Ela para de responder e só volta quando você ligar de novo.')) return
setPowerBusy(true)
try { const r = await secPowerApi.set(proximo); setSecOn(r.enabled) }
catch { /* mantém estado atual */ } finally { setPowerBusy(false) }
}, [secOn, powerBusy])
if (!proximo && !powerReason.trim()) { setPowerErr('Escreva o motivo para desativar.'); return }
setPowerBusy(true); setPowerErr(null)
try {
const userName = (() => { try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}')?.nome || 'Usuário' } catch { return 'Usuário' } })()
const r = await secPowerApi.set(proximo, powerReason.trim(), userName)
setSecOn(r.enabled); setPowerReason('')
const fresh = await secPowerApi.get().catch(() => null); if (fresh) setPowerData(fresh)
} catch (e: any) { setPowerErr(e?.message || 'Não foi possível alterar.') } finally { setPowerBusy(false) }
}, [secOn, powerBusy, powerReason])
// Contagem de pendências para o badge (dono) — atualiza ao abrir e a cada 5 min.
useEffect(() => {
@@ -1704,13 +1734,70 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
setActiveTab('conversations')
}
// Fail-closed: enquanto verifica ou se negado, não renderiza a config (evita flash e vazamento).
if (!accessChecked || !hasSecretariaAccess) return null
return (
<div className="flex h-full w-full bg-gray-50 overflow-hidden font-sans">
{/* Thin Nav */}
<ThinNav active={activeTab} onChange={handleTabChange} onHelp={() => setShowHelp(true)} onBack={() => props.onNavigate?.('dashboard')}
<ThinNav active={activeTab} onChange={handleTabChange} onHelp={() => setShowHelp(true)} onBack={() => props.onNavigate?.('wa-inbox')}
onPendencias={ehDono ? () => setShowPendencias(true) : undefined} pendenciasBadge={pendCount}
secOn={secOn} onTogglePower={handleTogglePower} />
secOn={secOn} onTogglePower={openPowerModal} />
{powerModal && (
<div className="fixed inset-0 bg-black/60 z-[80] flex items-center justify-center backdrop-blur-md p-4" onClick={() => setPowerModal(false)}>
<div className="bg-white w-full max-w-md rounded-[1.5rem] shadow-2xl border border-gray-100 flex flex-col max-h-[90vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="px-6 py-5 border-b border-gray-50 flex items-center justify-between bg-gradient-to-br from-gray-50 to-white">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-xl ${secOn ? 'bg-emerald-500' : 'bg-red-600'} shadow`}><Power size={18} className="text-white" /></div>
<div>
<h2 className="text-base font-black text-gray-900 uppercase tracking-tighter leading-none">Secretária todos os chats</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-1">{secOn ? 'Ativa' : 'Desativada'} · vale para TODAS as sessões</p>
</div>
</div>
<button onClick={() => setPowerModal(false)} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={20} /></button>
</div>
{powerErr && <div className="px-6 py-2 text-xs font-bold bg-red-50 text-red-600">{powerErr}</div>}
<div className="p-6 space-y-4 overflow-y-auto">
{!secOn && powerData?.reason && (
<div className="text-xs bg-red-50 text-red-700 rounded-xl p-3"><span className="font-black">Desativada.</span> Motivo: {powerData.reason}</div>
)}
{secOn && (
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Motivo para desativar (todos os chats)</label>
<textarea value={powerReason} onChange={(e) => setPowerReason(e.target.value)} rows={3}
placeholder="Ex.: bug na forma de agendar; estratégia a ajustar no atendimento da IA…"
className="w-full bg-gray-50 border-2 border-transparent focus:border-red-500 rounded-xl px-3 py-2 text-sm outline-none resize-y" />
<p className="text-[10px] text-gray-400 mt-1">Desativa a Secretária para TODAS as sessões até alguém reativar. O motivo aparece no super admin.</p>
</div>
)}
<button onClick={confirmTogglePower} disabled={powerBusy}
className={`w-full py-2.5 rounded-xl text-sm font-black uppercase tracking-wide flex items-center justify-center gap-2 disabled:opacity-40 ${secOn ? 'bg-red-600 hover:bg-red-700 text-white' : 'bg-emerald-600 hover:bg-emerald-700 text-white'}`}>
{powerBusy ? <Loader2 size={16} className="animate-spin" /> : <Power size={16} />}
{secOn ? 'Desativar (todos os chats)' : 'Ativar (todos os chats)'}
</button>
{!!powerData?.history?.length && (
<div>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2">Histórico</p>
<div className="space-y-1.5 max-h-40 overflow-y-auto">
{powerData.history.map((h: any, i: number) => (
<div key={i} className="text-[11px] flex items-start gap-2">
<span className={`mt-1 w-1.5 h-1.5 rounded-full shrink-0 ${h.enabled ? 'bg-emerald-500' : 'bg-red-500'}`} />
<div className="min-w-0">
<span className="font-bold text-gray-700">{h.enabled ? 'Ativou' : 'Desativou'}</span>
<span className="text-gray-400"> · {h.by || '—'} · {h.at ? new Date(h.at).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}</span>
{!h.enabled && h.reason && <div className="text-gray-500 leading-snug">{h.reason}</div>}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
{ehDono && (
<>
+24 -15
View File
@@ -358,7 +358,7 @@ const SessionCard = React.forwardRef<HTMLDivElement, {
<div className="flex items-start justify-between relative z-10">
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-2xl overflow-hidden flex items-center justify-center border-2 shadow-lg ${isConnected ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' : 'bg-gray-50 border-gray-200 text-gray-500'}`}>
<div className={`w-14 h-14 rounded-full overflow-hidden flex items-center justify-center border-2 shadow-lg ${isConnected ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' : 'bg-gray-50 border-gray-200 text-gray-500'}`}>
{inst.avatar
? <img src={inst.avatar} alt={inst.name} className="w-full h-full object-cover" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; (e.currentTarget.nextSibling as HTMLElement)?.style.setProperty('display', 'flex') }} />
: null}
@@ -435,11 +435,20 @@ const SessionCard = React.forwardRef<HTMLDivElement, {
<UserCog className="w-3.5 h-3.5" /> Acesso
</button>
)}
<button onClick={onDisconnect}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-600 hover:text-gray-800 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-100"
>
<LogOut className="w-3.5 h-3.5" /> Desconectar
</button>
{/* Desconectar = gestão da conexão (o backend exige dono ou can_rescan). */}
{rescanLocked ? (
<div title="Somente o dono pode desconectar esta sessão — peça autorização de re-scan."
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-400 bg-gray-500/5 rounded-xl border border-gray-200 cursor-not-allowed"
>
<Lock className="w-3.5 h-3.5" /> Desconectar
</div>
) : (
<button onClick={onDisconnect}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-600 hover:text-gray-800 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-100"
>
<LogOut className="w-3.5 h-3.5" /> Desconectar
</button>
)}
</>
) : rescanLocked ? (
<div className="flex-1 flex items-center justify-center gap-2 bg-gray-500/5 text-gray-400 font-black py-3 rounded-xl text-[11px] uppercase tracking-widest border border-gray-200 cursor-not-allowed"
@@ -652,9 +661,9 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
// ── Ownership & permissões por sessão ──────────────────────────────────────
const [authzByInstance, setAuthzByInstance] = useState<Record<string, SessionPerms>>({});
const [workspaceOwner, setWorkspaceOwner] = useState<{ isOwner: boolean; ownerNome: string | null }>({ isOwner: false, ownerNome: null });
// Endpoint de authz disponível? Se não (backend antigo/erro), a UI NÃO bloqueia —
// o enforcement real é do proxy do backend. Começa true (otimista) até saber.
const [authzAvailable, setAuthzAvailable] = useState(true);
// Endpoint de authz disponível? Fail-CLOSED: se não (backend antigo/erro), a UI TRAVA as
// ações destrutivas (o proxy segue sendo a autoridade). Começa false até confirmar.
const [authzAvailable, setAuthzAvailable] = useState(false);
const [permsModalFor, setPermsModalFor] = useState<Instance | null>(null);
const instanceIdsKey = instances.map((i) => i.id).join(',');
@@ -680,7 +689,7 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
if (r?.available) available = true;
map[id] = r
? { isOwner: r.isOwner, canRescan: r.me.can_rescan, canDelete: r.me.can_delete, ownerNome: r.ownerNome }
: { isOwner: false, canRescan: true, canDelete: true, ownerNome: null };
: { isOwner: false, canRescan: false, canDelete: false, ownerNome: null }; // fail-closed em erro
if (r?.available && r.isOwner) owner = { isOwner: true, ownerNome: r.ownerNome };
else if (r?.available && !owner.ownerNome && r.ownerNome) owner.ownerNome = r.ownerNome;
}
@@ -692,18 +701,18 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
}, [instanceIdsKey]);
// Permissões efetivas de uma instância.
// Sem authz disponível → permissivo (não bloqueia; backend é a autoridade).
// Fail-CLOSED: sem authz disponível (endpoint em erro), trava as ações na UI — o backend
// segue sendo a autoridade, mas a UI não deve expor botões destrutivos por falha transitória.
const permsFor = (inst: Instance): SessionPerms => {
if (!authzAvailable) return { isOwner: false, canRescan: true, canDelete: true, ownerNome: null };
if (!authzAvailable) return { isOwner: false, canRescan: false, canDelete: false, ownerNome: null };
return authzByInstance[inst.id]
?? (workspaceOwner.isOwner
? { ...OWNER_PERMS, ownerNome: workspaceOwner.ownerNome }
: { isOwner: false, canRescan: false, canDelete: false, ownerNome: workspaceOwner.ownerNome });
};
// "Nova Instância" (scan inicial): só o dono. Mas se o endpoint não está
// disponível ainda, não bloqueia na UI (o backend decide).
const canCreateInstance = !authzAvailable || workspaceOwner.isOwner;
// "Nova Instância" (scan inicial): só o dono (fail-closed — o backend também exige dono).
const canCreateInstance = authzAvailable && workspaceOwner.isOwner;
// Já existe uma sessão ativa no motor para esta conta → mostra mensagem de
// sucesso em vez de sugerir novo pareamento.
+143 -47
View File
@@ -15,10 +15,13 @@
*/
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronDown, User, Phone, Send, MessageCircle, Loader2, WifiOff, QrCode } from 'lucide-react';
import { X, ChevronDown, User, Users, Phone, Send, MessageCircle, Loader2, WifiOff, QrCode } from 'lucide-react';
import { HybridBackend } from '../../services/backend';
import { useChatStore } from './store/chatStore';
import { chatApi } from './services/chatApiService';
import ForwardModal from './components/ForwardModal';
import LabelManagerModal from './components/LabelManagerModal';
import { useInstanceStore } from './store/instanceStore';
import { formatPhone } from './utils/inboxUtils';
import { nw } from './services/nwClient';
@@ -29,6 +32,7 @@ import { useChatWindow } from './hooks/inbox/useChatWindow';
import { useMessageInput } from './hooks/inbox/useMessageInput';
import InboxHeader from './components/InboxHeader';
import ConversationSearch from './components/ConversationSearch';
import MessageArea from './components/MessageArea';
import InputBar from './components/InputBar';
import ContactProfile from './components/ContactProfile';
@@ -39,6 +43,10 @@ export interface WhatsChatDrawerProps {
onClose: () => void;
pacienteId?: string | null;
pacienteNome?: string | null;
// Telefone CRU direto (ex.: pedido da fila da Secretária, que pode ser um lead SEM
// cadastro). Quando fornecido, entra como 1ª opção de número e resolve o chat sem
// depender de lookup por cadastro.
phone?: string | null;
onNavigate?: (view: string) => void;
}
@@ -65,8 +73,16 @@ function samePhone(a: string, b: string): boolean {
return da.slice(-8) === db.slice(-8);
}
// Nome curto p/ o header: 1º nome + um pouco do 2º (até 5 letras + … se cortar).
function shortContactName(full: string): string {
const parts = String(full || '').trim().split(/\s+/).filter(Boolean);
if (parts.length <= 1) return parts[0] || full;
const second = parts[1];
return `${parts[0]} ${second.length > 5 ? second.slice(0, 5) + '…' : second}`;
}
// ─── Conteúdo interno (só monta quando aberto → hooks/socket sob demanda) ──────
const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose, pacienteId, pacienteNome, onNavigate }) => {
const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose, pacienteId, pacienteNome, phone, onNavigate }) => {
const { activeInstance, instances, connectedInstances } = useInstanceSelector();
const loadingInstances = useInstanceStore((s) => s.loading);
// Há WhatsApp utilizável? (pelo menos uma instância conectada)
@@ -77,32 +93,65 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
loadingMessages,
handleSendText,
handleLoadMoreHistory,
handleDeleteMessage,
presenceByJid,
activeInstanceId,
} = useNewInboxBridge();
// Permissão de apagar mensagem (dono ou liberado). Fail-safe: false enquanto carrega.
const [canDeleteMsg, setCanDeleteMsg] = useState(false);
// "Limpar conversa" é só do DONO do workspace. Fail-closed.
const [isWaOwner, setIsWaOwner] = useState(false);
const [labelModalOpen, setLabelModalOpen] = useState(false);
useEffect(() => {
let alive = true;
HybridBackend.getWaAccess().then(a => { if (alive) { setCanDeleteMsg(!!(a.isOwner || a.delete_msg)); setIsWaOwner(!!a.isOwner); useChatStore.getState().setOperatorSignature(a.signature ?? null); } }).catch(() => {});
return () => { alive = false; };
}, []);
const {
scrollRef, isTyping, replyingTo, reactionPickerFor, hasMoreHistory, loadingMore,
scrollRef, replyingTo, reactionPickerFor, hasMoreHistory, loadingMore,
isProfileOpen, isSyncingAvatar, isChatMenuOpen,
setReplyingTo, setIsProfileOpen, setIsChatMenuOpen,
handleTyping, handleScroll, handleSelectChat: onChatWindowSelect,
handleReactToMessage, handleSendMedia, handleSendAudio, handleSyncAvatar, toggleReactionPicker,
handleClearConversation,
editingMessage, setEditingMessage, forwardingMessage, setForwardingMessage,
} = useChatWindow({
selectedChat,
activeInstanceId: activeInstance?.instance ?? null,
onLoadMoreHistory: handleLoadMoreHistory,
});
// "digitando..." do CONTATO (presença composing/recording do chat aberto).
const activePresence = selectedChat?.remote_jid ? presenceByJid[selectedChat.remote_jid] : undefined;
const isTyping = !!activePresence && (Date.now() - activePresence.ts <= 8000) && (activePresence.state === 'composing' || activePresence.state === 'recording');
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
selectedChat,
replyingTo,
onClearReply: () => setReplyingTo(null),
onSendText: handleSendText,
editingMessage,
onClearEdit: () => setEditingMessage(null),
onEditText: async (messageId, text) => {
if (!selectedChat?.id) return;
try {
await chatApi.editMessage(String(selectedChat.id), messageId, text);
useChatStore.getState().patchMessage(messageId, { body: text });
} catch (e) { console.error('Erro ao editar:', e); }
},
});
const handleEnterEdit = useCallback((msg: any) => {
setEditingMessage(msg); setReplyingTo(null); setNewMessage(msg?.text ?? msg?.body ?? '');
}, [setEditingMessage, setReplyingTo, setNewMessage]);
// ── Números disponíveis (paciente + grupo familiar) ──────────────────────
const [numeros, setNumeros] = useState<NumeroOpcao[]>([]);
const [selectedPhone, setSelectedPhone] = useState<string>('');
const [numDropdownOpen, setNumDropdownOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// ── Estado de resolução da conversa ──────────────────────────────────────
const [resolving, setResolving] = useState(false);
@@ -126,6 +175,8 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
opts.push({ phone: d, nome, relacao });
};
// 0) Telefone CRU direto (pedido da fila): 1ª opção, funciona mesmo sem cadastro.
if (phone) push(phone, pacienteNome || 'Contato', 'Contato');
// 1) Telefone do próprio paciente — via cadastro (desambigua por id).
if (pacienteNome) {
try {
@@ -150,7 +201,7 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
setSelectedPhone(opts[0]?.phone || '');
})();
return () => { alive = false; };
}, [pacienteId, pacienteNome]);
}, [pacienteId, pacienteNome, phone]);
// Resolve a conversa (chatId) a partir do telefone selecionado, sem enviar nada.
const resolveChat = useCallback(async (phoneDigits: string) => {
@@ -230,48 +281,59 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="fixed inset-y-0 right-0 z-[191] w-full sm:w-[440px] bg-[#f0f2f5] shadow-2xl flex flex-col overflow-hidden"
>
{/* Barra superior do drawer: contato + dropdown de números + fechar */}
<div className="shrink-0 bg-[#008069] text-white px-3 py-2.5 flex items-center gap-2">
{/* Barra superior: nome + dropdown do GRUPO FAMILIAR (troca a conversa) + fechar */}
<div className="shrink-0 bg-[#008069] text-white px-3 py-2 flex items-center gap-2.5">
<MessageCircle className="w-5 h-5 shrink-0" />
<div className="min-w-0 flex-1 relative">
<button
type="button"
onClick={() => numeros.length > 1 && setNumDropdownOpen((v) => !v)}
className={`flex items-center gap-1.5 max-w-full ${numeros.length > 1 ? 'cursor-pointer' : 'cursor-default'}`}
>
<span className="font-bold text-sm truncate">{tituloContato}</span>
{selectedNumero && (
<span className="text-[11px] text-white/70 truncate">
{formatPhone(normalizeSendPhone(selectedNumero.phone))}
</span>
)}
{numeros.length > 1 && <ChevronDown className={`w-4 h-4 shrink-0 transition-transform ${numDropdownOpen ? 'rotate-180' : ''}`} />}
</button>
{/* Nome (truncado) + pill dropdown do grupo familiar — NA MESMA LINHA */}
<div className="flex items-center gap-2 min-w-0">
<p className="font-bold text-sm truncate leading-tight" title={tituloContato}>{shortContactName(tituloContato)}</p>
<button
type="button"
onClick={() => setNumDropdownOpen((v) => !v)}
className="shrink-0 inline-flex items-center gap-1.5 rounded-full bg-white/15 hover:bg-white/25 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide transition-colors"
>
<Users className="w-3.5 h-3.5 shrink-0" />
<span className="truncate max-w-[90px]">{selectedNumero?.relacao || 'Paciente'}</span>
<ChevronDown className={`w-3.5 h-3.5 shrink-0 transition-transform ${numDropdownOpen ? 'rotate-180' : ''}`} />
</button>
</div>
<AnimatePresence>
{numDropdownOpen && numeros.length > 1 && (
<motion.div
initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}
className="absolute left-0 top-full mt-1 w-72 bg-white text-gray-800 rounded-xl shadow-2xl border border-gray-100 py-1.5 z-10"
>
<p className="px-3 py-1 text-[9px] font-black uppercase tracking-widest text-gray-400">Número / grupo familiar</p>
{numeros.map((n) => (
<button
key={n.phone}
onClick={() => { setSelectedPhone(n.phone); setNumDropdownOpen(false); }}
className={`w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2.5 ${n.phone === selectedPhone ? 'bg-teal-50' : ''}`}
>
<div className="w-8 h-8 rounded-full bg-teal-50 flex items-center justify-center shrink-0">
<User size={15} className="text-teal-600" />
</div>
<div className="min-w-0">
<p className="text-xs font-black uppercase truncate">{n.nome}</p>
<p className="text-[10px] text-gray-400 font-bold">
<span className="text-teal-500">{n.relacao}</span> · {formatPhone(normalizeSendPhone(n.phone))}
</p>
</div>
</button>
))}
</motion.div>
{numDropdownOpen && (
<>
<div className="fixed inset-0 z-[5]" onClick={() => setNumDropdownOpen(false)} />
<motion.div
initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}
className="absolute left-0 top-full mt-1.5 w-72 bg-white text-gray-800 rounded-xl shadow-2xl border border-gray-100 py-1.5 z-10 overflow-hidden"
>
<p className="px-3 py-1.5 text-[9px] font-black uppercase tracking-widest text-gray-400 flex items-center gap-1.5">
<Users size={11} className="text-teal-500" /> Grupo familiar
</p>
{numeros.map((n) => (
<button
key={n.phone}
onClick={() => { setSelectedPhone(n.phone); setNumDropdownOpen(false); }}
className={`w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2.5 transition-colors ${n.phone === selectedPhone ? 'bg-teal-50' : ''}`}
>
<div className={`w-9 h-9 rounded-full flex items-center justify-center shrink-0 ${n.phone === selectedPhone ? 'bg-teal-500 text-white' : 'bg-teal-50 text-teal-600'}`}>
<User size={16} />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-black uppercase truncate">{n.nome}</p>
<p className="text-[10px] text-gray-400 font-bold truncate">
<span className="text-teal-500">{n.relacao}</span> · {formatPhone(normalizeSendPhone(n.phone))}
</p>
</div>
{n.phone === selectedPhone && <span className="text-[9px] font-black text-teal-600 uppercase shrink-0">ativo</span>}
</button>
))}
{numeros.length <= 1 && (
<p className="px-3 pt-2 pb-1 mt-1 border-t border-gray-50 text-[10px] text-gray-400 leading-snug">
Cadastre o <b className="text-gray-500">grupo familiar</b> no paciente (mãe, filho, cônjuge) para trocar de conversa aqui e notificar sobre o paciente.
</p>
)}
</motion.div>
</>
)}
</AnimatePresence>
</div>
@@ -378,9 +440,21 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
onSyncAvatar={handleSyncAvatar}
isChatMenuOpen={isChatMenuOpen}
isSyncingAvatar={isSyncingAvatar}
onSearchClick={() => {}}
isOwner={isWaOwner}
onClearConversation={handleClearConversation}
onManageLabels={() => setLabelModalOpen(true)}
onSearchClick={() => setIsSearchOpen(true)}
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
/>
{isSearchOpen && (
<ConversationSearch
messages={messages as any}
onClose={() => setIsSearchOpen(false)}
onQueryChange={setSearchQuery}
hasOlder={hasMoreHistory}
loadOlder={async () => { const r = await handleLoadMoreHistory(); return !!r?.hasMore; }}
/>
)}
<MessageArea
messages={messages as any}
selectedChat={selectedChat}
@@ -388,14 +462,16 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
msgLoading={loadingMessages}
onScroll={handleScroll}
historyLoadingMore={loadingMore}
highlightTerm={isSearchOpen ? searchQuery : ''}
hasMoreHistory={hasMoreHistory}
reactionPickerFor={reactionPickerFor}
onReply={setReplyingTo}
onToggleReactionPicker={toggleReactionPicker}
onReact={handleReactToMessage}
onForward={() => {}}
onDelete={() => {}}
onEdit={() => {}}
onForward={(msg: any) => setForwardingMessage(msg)}
onDelete={(msg: any) => { if (selectedChat && msg?.id) handleDeleteMessage(String(selectedChat.id), String(msg.id)); }}
canDelete={canDeleteMsg}
onEdit={handleEnterEdit}
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
getGroupSenderLabel={(msg: any) => {
if (msg.direction === 'out') return 'Você';
@@ -415,6 +491,8 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
onPresence={handleTyping}
onCancelReply={() => setReplyingTo(null)}
replyingTo={replyingTo}
editingMessage={editingMessage}
onCancelEdit={() => { setEditingMessage(null); setNewMessage(''); }}
selectedChat={selectedChat}
isMobile={true}
onSendMedia={handleSendMedia}
@@ -438,6 +516,24 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
)}
</div>
</motion.aside>
<LabelManagerModal
isOpen={labelModalOpen}
onClose={() => setLabelModalOpen(false)}
chat={selectedChat as any}
onChanged={() => { if (activeInstance?.instance) useChatStore.getState().loadChats(activeInstance.instance); }}
/>
<ForwardModal
isOpen={!!forwardingMessage}
onClose={() => setForwardingMessage(null)}
chats={useChatStore.getState().chats as any}
onForward={async (toChatIds: string[]) => {
if (!forwardingMessage || !selectedChat?.id) return;
try { await chatApi.forward(String(selectedChat.id), String((forwardingMessage as any).id), toChatIds); }
catch (e) { console.error('Erro ao encaminhar:', e); }
setForwardingMessage(null);
}}
/>
</>
);
};
@@ -0,0 +1,125 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Bot } from 'lucide-react';
import { handoffApi, HANDOFF_MINUTES } from '../services/chatApiService';
import { socketService } from '../services/socketService';
interface AiPauseButtonProps {
// jid do contato (o handoff é por conversa, chaveado pelo jid).
jid: string | null | undefined;
// instância atual — p/ saber o estado EFETIVO do liga/desliga (chat/sessão/global).
instanceId?: string | null;
}
const WINDOW_MS = HANDOFF_MINUTES * 60 * 1000;
// Botão do atendente (canto inferior direito) para PAUSAR/RETOMAR a Secretária IA.
// Ao pausar, um contador simples aparece no centro; passados 15 min o motor reativa a
// IA sozinho (e o evento de handoff volta o botão ao normal).
const AiPauseButton: React.FC<AiPauseButtonProps> = ({ jid, instanceId }) => {
const [mode, setMode] = useState<'ia' | 'humano'>('ia');
const [humanAt, setHumanAt] = useState<number | null>(null);
const [remaining, setRemaining] = useState<number>(0);
const [busy, setBusy] = useState(false);
const [secEnabled, setSecEnabled] = useState<boolean | null>(null); // liga/desliga efetivo
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!jid) return;
let alive = true;
handoffApi.get(jid, instanceId).then((r) => {
if (!alive) return;
setMode(r.mode);
setHumanAt(r.humanAt ? new Date(r.humanAt).getTime() : null);
setSecEnabled(r.secEnabled);
}).catch(() => {});
return () => { alive = false; };
}, [jid, instanceId]);
// Atualiza ao vivo se o motor mudar o modo (escalonamento / timeout de retorno).
useEffect(() => {
const onHandoff = (d: any) => {
if (d?.mode !== 'ia' && d?.mode !== 'humano') return;
setMode(d.mode);
setHumanAt(d.mode === 'humano' ? (d.handoffHumanAt ? new Date(d.handoffHumanAt).getTime() : Date.now()) : null);
};
const onEscalated = () => { setMode('humano'); setHumanAt(Date.now()); };
socketService.on('handoff', onHandoff);
socketService.on('escalated', onEscalated);
return () => { socketService.off?.('handoff', onHandoff); socketService.off?.('escalated', onEscalated); };
}, []);
// Contador: roda 1x/s enquanto pausado; ao zerar, otimisticamente volta p/ IA.
useEffect(() => {
if (tickRef.current) { clearInterval(tickRef.current); tickRef.current = null; }
if (mode !== 'humano' || !humanAt) { setRemaining(0); return; }
const compute = () => {
const left = WINDOW_MS - (Date.now() - humanAt);
if (left <= 0) { setRemaining(0); setMode('ia'); setHumanAt(null); }
else setRemaining(left);
};
compute();
tickRef.current = setInterval(compute, 1000);
return () => { if (tickRef.current) clearInterval(tickRef.current); };
}, [mode, humanAt]);
const toggle = useCallback(async () => {
if (!jid || busy) return;
const next = mode === 'ia' ? 'humano' : 'ia';
const prevMode = mode, prevAt = humanAt;
setBusy(true);
setMode(next); setHumanAt(next === 'humano' ? Date.now() : null); // otimista
try { await handoffApi.set(jid, next); }
catch { setMode(prevMode); setHumanAt(prevAt); }
finally { setBusy(false); }
}, [jid, mode, humanAt, busy]);
if (!jid) return null;
const paused = mode === 'humano';
const mmss = () => {
const t = Math.max(0, Math.ceil(remaining / 1000));
return `${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`;
};
// Secretária DESLIGADA p/ este chat (sessão/global/override): não faz sentido
// mostrar "IA ativa" (laranja) nem oferecer pausa — ela já não responde. Botão
// fica apagado/cinza e informa. Só o dono religa (cabeçalho do chat / power).
if (secEnabled === false) {
return (
<button
disabled
title="Secretária IA desligada neste chat — ligue no cabeçalho do chat ou no botão de energia"
className="absolute bottom-6 right-6 z-30 w-11 h-11 rounded-full shadow-lg flex items-center justify-center border bg-white text-gray-300 border-[#d1d7db] cursor-not-allowed opacity-80"
>
<Bot className="w-5 h-5" />
</button>
);
}
return (
<>
{/* Contador central (só quando pausado) */}
{paused && remaining > 0 && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 bg-[#f59e0b]/95 text-white px-3.5 py-1.5 rounded-full shadow-lg text-[13px] font-bold pointer-events-none">
<Bot className="w-4 h-4" />
IA pausada · {mmss()}
</div>
)}
{/* Botão robô: laranja = IA ativa; cinza = pausada */}
<button
onClick={toggle}
disabled={busy}
title={paused ? `Secretária IA pausada (${mmss()}) — clique para retomar` : 'Pausar a Secretária IA por 15 min (assumir a conversa)'}
className={`absolute bottom-6 right-6 z-30 w-11 h-11 rounded-full shadow-lg flex items-center justify-center border transition-all active:scale-90 disabled:opacity-60 ${
paused
? 'bg-white text-[#8696a0] border-[#d1d7db] hover:text-[#54656f] hover:bg-[#f0f2f5]'
: 'bg-[#f59e0b] text-white border-[#f59e0b] hover:bg-[#d97706]'
}`}
>
<Bot className="w-5 h-5" />
</button>
</>
);
};
export default AiPauseButton;
@@ -193,13 +193,27 @@ export default function ChatListItem({
<div className="flex-1 min-w-0 flex flex-col justify-center py-0.5 pr-1 text-left">
<div className="flex items-center justify-between">
<h3 className="text-[16px] leading-[21px] font-normal text-[#111b21] truncate">
{chat.displayName || chat.subject || chat.phone}
{(chat as any).nomeNosso || chat.displayName || chat.subject || chat.phone}
</h3>
<span className={`text-[12px] leading-[14px] flex-shrink-0 ml-2 ${chat.unread_count > 0 ? 'text-[#00a884] font-medium' : 'text-[#667781]'}`}>
{relativeTime}
</span>
</div>
{((chat as any).pendenteSec || (Array.isArray((chat as any).labels) && (chat as any).labels.length > 0)) && (
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
{/* Selo da fila da Secretária: há pedido aguardando a humana confirmar */}
{(chat as any).pendenteSec && (
<span className="text-[10px] font-black uppercase tracking-wide px-1.5 py-[1px] rounded-full bg-amber-500 text-white flex items-center gap-0.5">
<Clock className="w-2.5 h-2.5" /> Pendente
</span>
)}
{Array.isArray((chat as any).labels) && (chat as any).labels.slice(0, 3).map((l: any) => (
<span key={l.id} className="text-[10px] font-medium px-1.5 py-[1px] rounded-full text-white truncate max-w-[100px]" style={{ backgroundColor: l.color }}>{l.name}</span>
))}
</div>
)}
<div className="flex items-center justify-between mt-1 h-5">
<div className="flex items-center gap-1 flex-1 min-w-0">
{(() => {
@@ -0,0 +1,84 @@
// Liga/desliga a Secretária IA SÓ NESTE CHAT (override do dono, precedência máxima:
// vence a sessão e o global). Botão no header do chat + popover com 3 estados.
// Só o dono vê/opera (canPower). Persiste até mudar; "Herdar" remove o override.
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Bot, Check, Loader2, Power, PowerOff, CornerUpLeft } from 'lucide-react';
import { secChatPowerApi, ChatPower } from '../services/secretariaApi';
const byName = (() => {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}')?.nome || 'Dono'; }
catch { return 'Dono'; }
})();
export const ChatSecretariaPower: React.FC<{ instanceId: string | null; jid: string | null; canPower?: boolean }> = ({ instanceId, jid, canPower }) => {
const [cp, setCp] = useState<ChatPower | null>(null);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const carregar = useCallback(async () => {
if (!instanceId || !jid) return;
try { setCp(await secChatPowerApi.get(instanceId, jid)); } catch { /* silencioso */ }
}, [instanceId, jid]);
useEffect(() => { setCp(null); carregar(); }, [carregar]);
// Fecha o popover ao clicar fora.
useEffect(() => {
if (!open) return;
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
if (!instanceId || !jid || !canPower) return null;
const overridden = !!cp?.overridden;
const enabled = cp?.enabled;
// Cor do ícone: verde = ligada só aqui; vermelho = desligada só aqui; neutro = herda.
const tone = !overridden ? 'text-[#54656f]' : enabled ? 'text-emerald-600' : 'text-red-600';
const aplicar = async (val: boolean | null) => {
if (!instanceId || !jid) return;
setBusy(true);
try { await secChatPowerApi.set(instanceId, jid, val, byName); await carregar(); setOpen(false); }
catch { /* mantém aberto */ } finally { setBusy(false); }
};
const Opt = ({ icon, label, active, danger, onClick }: { icon: React.ReactNode; label: string; active: boolean; danger?: boolean; onClick: () => void }) => (
<button onClick={onClick} disabled={busy}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-left hover:bg-[#f5f6f6] transition-colors disabled:opacity-50 ${danger ? 'text-red-600' : 'text-[#111b21]'}`}>
<span className="shrink-0">{icon}</span>
<span className="flex-1">{label}</span>
{active && <Check size={14} className="text-emerald-600 shrink-0" />}
</button>
);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(o => !o)}
title="Secretária IA neste chat"
className={`relative p-2 rounded-full transition-all hover:bg-black/5 ${open ? 'bg-black/10' : ''} ${tone}`}
>
<Bot className="w-5 h-5" />
{overridden && <span className={`absolute top-1.5 right-1.5 w-2 h-2 rounded-full border border-white ${enabled ? 'bg-emerald-500' : 'bg-red-500'}`} />}
</button>
{open && (
<div className="absolute right-0 top-full mt-1 bg-white shadow-2xl rounded-xl border border-gray-100 w-60 z-50 py-1.5 overflow-hidden">
<p className="px-3 pt-1 pb-1.5 text-[10px] font-black uppercase tracking-widest text-gray-400">Secretária neste chat</p>
{busy && <div className="px-3 py-2 flex items-center gap-2 text-xs text-gray-400"><Loader2 size={13} className="animate-spin" /> aplicando</div>}
<Opt icon={<Power size={15} className="text-emerald-600" />} label="Ligar só neste chat" active={overridden && enabled === true} onClick={() => aplicar(true)} />
<Opt icon={<PowerOff size={15} className="text-red-600" />} label="Desligar só neste chat" active={overridden && enabled === false} danger onClick={() => aplicar(false)} />
{overridden && (
<>
<div className="my-1 border-t border-gray-100" />
<Opt icon={<CornerUpLeft size={15} className="text-gray-400" />} label="Herdar padrão (global/sessão)" active={false} onClick={() => aplicar(null)} />
</>
)}
</div>
)}
</div>
);
};
@@ -28,6 +28,7 @@ interface ContactProfileProps {
chat: any;
messages: Message[];
onClose: () => void;
nomeNosso?: string; // nome da NOSSA base (paciente) — precede o pushname do WhatsApp
}
const ContactProfile: React.FC<ContactProfileProps> = ({
@@ -35,6 +36,7 @@ const ContactProfile: React.FC<ContactProfileProps> = ({
chat,
messages,
onClose,
nomeNosso,
}) => {
// Filter media messages
const mediaMessages = useMemo(() => {
@@ -75,7 +77,7 @@ const ContactProfile: React.FC<ContactProfileProps> = ({
{/* Profile Photo */}
<Avatar chat={chat} size={200} className="mb-5 shadow-lg" />
<h1 className="text-[24px] font-normal text-[#111b21] mb-1 text-center">
{chat.lead_name || chat.subject || formatPhone(chat.phone, chat.remote_jid)}
{nomeNosso || chat.lead_name || chat.subject || formatPhone(chat.phone, chat.remote_jid)}
</h1>
<p className="text-[16px] text-[#667781] font-normal mb-1">
{formatPhone(chat.phone, chat.remote_jid)}
@@ -0,0 +1,107 @@
'use client';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Search, ChevronUp, ChevronDown, X, Loader2 } from 'lucide-react';
interface Msg { message_id?: string; id?: string | number; text?: string; transcription?: string | null }
// Barra de busca DENTRO da conversa: filtra as mensagens por texto (corpo +
// transcrição), rola até o resultado e o realça. Navega com ↑/↓ (ou Enter/Shift+
// Enter). Se o termo não estiver nas mensagens já carregadas, CARREGA o histórico
// antigo automaticamente (uma página por vez, até achar ou acabar). Usa o id do DOM
// já existente na bolha (id="msg-<message_id>").
const MAX_PAGES = 40; // teto de páginas antigas a carregar por busca (~evita runaway)
const ConversationSearch: React.FC<{
messages: Msg[];
onClose: () => void;
loadOlder?: () => Promise<boolean>; // carrega 1 página antiga; retorna se ainda há mais
hasOlder?: boolean;
onQueryChange?: (q: string) => void; // avisa o pai p/ realçar o termo nas bolhas
}> = ({ messages, onClose, loadOlder, hasOlder, onQueryChange }) => {
const [q, setQ] = useState('');
const [idx, setIdx] = useState(0);
const [autoLoading, setAutoLoading] = useState(false);
const [pagesLoaded, setPagesLoaded] = useState(0);
const loadingRef = useRef(false);
const matches = useMemo(() => {
const t = q.trim().toLowerCase();
if (!t) return [] as string[];
return messages
.filter((m) => `${m.text ?? ''} ${m.transcription ?? ''}`.toLowerCase().includes(t))
.map((m) => String(m.message_id ?? m.id ?? ''))
.filter(Boolean);
}, [q, messages]);
// Reinicia navegação e o contador de páginas ao mudar o termo; avisa o pai.
useEffect(() => { setIdx(0); setPagesLoaded(0); onQueryChange?.(q); }, [q]);
// Ao desmontar (fechar busca), limpa o realce.
useEffect(() => () => onQueryChange?.(''), []);
// Sem resultados nas mensagens carregadas → puxa mais histórico antigo (1 página
// por vez; o React recomputa `matches` e o efeito re-roda até achar ou acabar).
useEffect(() => {
if (!q.trim() || !loadOlder || !hasOlder || loadingRef.current) return;
if (matches.length > 0 || pagesLoaded >= MAX_PAGES) return;
loadingRef.current = true;
setAutoLoading(true);
let cancelled = false;
loadOlder()
.then(() => { if (!cancelled) setPagesLoaded((p) => p + 1); })
.finally(() => { if (!cancelled) { loadingRef.current = false; setAutoLoading(false); } });
return () => { cancelled = true; loadingRef.current = false; };
}, [q, matches.length, hasOlder, pagesLoaded, loadOlder]);
// Rola até o resultado atual e o realça brevemente.
useEffect(() => {
if (!matches.length) return;
const id = matches[Math.min(idx, matches.length - 1)];
const el = document.getElementById(`msg-${id}`);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('bg-[#00a884]/15', 'rounded-xl', 'transition-colors');
const t = setTimeout(() => el.classList.remove('bg-[#00a884]/15', 'rounded-xl'), 1400);
return () => clearTimeout(t);
}, [idx, matches]);
const go = (d: number) => { if (matches.length) setIdx((i) => (i + d + matches.length) % matches.length); };
// Texto do contador: buscando / n de N / nenhum.
const counter = !q.trim()
? ''
: matches.length
? `${idx + 1}/${matches.length}`
: (autoLoading ? '' : '0');
return (
<div className="shrink-0 flex items-center gap-2 px-3 py-2 bg-[#f0f2f5] border-b border-[#d1d7db] z-20">
<Search className="w-4 h-4 text-[#54656f] shrink-0" />
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') go(e.shiftKey ? -1 : 1);
if (e.key === 'Escape') onClose();
}}
placeholder="Buscar na conversa..."
className="flex-1 min-w-0 bg-transparent outline-none text-sm text-[#111b21] placeholder:text-[#8696a0]"
/>
{autoLoading && !matches.length && (
<span className="flex items-center gap-1 text-[10px] text-[#667781] font-bold shrink-0">
<Loader2 className="w-3 h-3 animate-spin" /> antigas
</span>
)}
{counter && <span className="text-[11px] text-[#667781] font-bold shrink-0 tabular-nums">{counter}</span>}
<button onClick={() => go(-1)} disabled={!matches.length} title="Anterior"
className="p-1 rounded-full hover:bg-black/10 disabled:opacity-40 text-[#54656f] transition-colors"><ChevronUp className="w-4 h-4" /></button>
<button onClick={() => go(1)} disabled={!matches.length} title="Próximo"
className="p-1 rounded-full hover:bg-black/10 disabled:opacity-40 text-[#54656f] transition-colors"><ChevronDown className="w-4 h-4" /></button>
<button onClick={onClose} title="Fechar busca"
className="p-1 rounded-full hover:bg-black/10 text-[#54656f] transition-colors"><X className="w-4 h-4" /></button>
</div>
);
};
export default ConversationSearch;
@@ -0,0 +1,103 @@
import React, { useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { X, Search, Send, Check } from 'lucide-react';
interface ForwardModalProps {
isOpen: boolean;
onClose: () => void;
chats: any[];
onForward: (toChatIds: string[]) => Promise<void> | void;
}
// Modal para escolher um ou mais chats de destino e encaminhar a mensagem selecionada.
const ForwardModal: React.FC<ForwardModalProps> = ({ isOpen, onClose, chats, onForward }) => {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sending, setSending] = useState(false);
const label = (c: any) => c.displayName || c.subject || c.name || c.phone || c.remote_jid || String(c.id);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return chats;
return chats.filter((c) => label(c).toLowerCase().includes(q));
}, [chats, query]);
if (!isOpen || typeof document === 'undefined') return null;
const toggle = (id: string) => {
setSelected((prev) => {
const n = new Set(prev);
n.has(id) ? n.delete(id) : n.add(id);
return n;
});
};
const handleForward = async () => {
if (selected.size === 0 || sending) return;
setSending(true);
try { await onForward(Array.from(selected)); }
finally { setSending(false); setSelected(new Set()); setQuery(''); }
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
<div className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h3 className="text-[15px] font-bold text-[#111b21]">Encaminhar para</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-700 rounded-full transition-colors"><X className="w-5 h-5" /></button>
</div>
<div className="px-4 py-2.5 border-b border-gray-100">
<div className="flex items-center gap-2 bg-[#f0f2f5] rounded-lg px-3 py-2">
<Search className="w-4 h-4 text-[#667781] shrink-0" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Pesquisar conversa"
className="w-full bg-transparent text-[14px] text-[#111b21] placeholder:text-[#667781] focus:outline-none"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filtered.length === 0 && (
<div className="px-4 py-6 text-center text-[13px] text-[#667781]">Nenhuma conversa encontrada.</div>
)}
{filtered.map((c) => {
const id = String(c.id);
const isSel = selected.has(id);
return (
<button
key={id}
onClick={() => toggle(id)}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-[#f5f6f6] transition-colors text-left"
>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors ${isSel ? 'bg-[#00a884] border-[#00a884]' : 'border-gray-300'}`}>
{isSel && <Check className="w-3 h-3 text-white" />}
</div>
<span className="text-[14px] text-[#111b21] truncate">{label(c)}</span>
</button>
);
})}
</div>
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
<span className="text-[12px] text-[#667781]">{selected.size} selecionada(s)</span>
<button
onClick={handleForward}
disabled={selected.size === 0 || sending}
className="flex items-center gap-2 bg-[#00a884] text-white text-[14px] font-bold px-4 py-2 rounded-lg disabled:opacity-40 hover:bg-[#008f6f] transition-colors"
>
<Send className="w-4 h-4" />
{sending ? 'Encaminhando…' : 'Encaminhar'}
</button>
</div>
</div>
</div>,
document.body,
);
};
export default ForwardModal;
@@ -9,12 +9,15 @@ import {
Tag,
X,
ClipboardList,
CalendarClock,
CalendarDays,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
formatPhone,
} from '../utils/inboxUtils';
import Avatar from './ui/Avatar';
import { ChatSecretariaPower } from './ChatSecretariaPower';
interface InboxHeaderProps {
selectedChat: any;
@@ -24,6 +27,14 @@ interface InboxHeaderProps {
isSyncingAvatar: boolean;
isProtocolOpen?: boolean;
hasActiveProtocol?: boolean;
isOwner?: boolean; // só o dono do workspace vê "Limpar conversa" (ação destrutiva). Fail-closed.
nomeNosso?: string; // nome da NOSSA base (paciente) — precede o pushname do WhatsApp
activeInstanceId?: string | null; // instância atual (p/ override da Secretária por chat)
pedidosCount?: number; // badge do botão de fila (0 = sem badge)
onOpenPedidos?: () => void; // abre o modal "Pedidos aguardando confirmação"
onOpenAgenda?: () => void; // abre a agenda (overlay) sem sair do inbox
onClearConversation?: () => void;
onManageLabels?: () => void;
onBack: () => void;
onSearchClick: () => void;
onToggleMenu: () => void;
@@ -40,6 +51,14 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
isSyncingAvatar,
isProtocolOpen,
hasActiveProtocol,
isOwner,
nomeNosso,
activeInstanceId,
pedidosCount = 0,
onOpenPedidos,
onOpenAgenda,
onClearConversation,
onManageLabels,
onBack,
onSearchClick,
onToggleMenu,
@@ -69,7 +88,7 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
<div className="flex flex-col min-w-0">
<h3 className="text-[16px] leading-[21px] font-bold text-[#111b21] flex items-center gap-2 truncate group-hover/header:text-[#00a884] transition-colors">
{selectedChat.lead_name || selectedChat.subject || formatPhone(selectedChat.phone, selectedChat.remote_jid)}
{nomeNosso || selectedChat.lead_name || selectedChat.subject || formatPhone(selectedChat.phone, selectedChat.remote_jid)}
</h3>
<div className="flex items-center gap-1 min-w-0">
{isTyping ? (
@@ -91,6 +110,33 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
</div>
<div className="flex items-center gap-1">
{/* Secretária IA só neste chat (override do dono, precedência máxima) */}
<ChatSecretariaPower instanceId={activeInstanceId ?? null} jid={selectedChat?.remote_jid ?? null} canPower={isOwner} />
{/* Agenda — abre a agenda (overlay) p/ conferir dias/horários vagos sem sair do chat */}
{onOpenAgenda && (
<button
onClick={onOpenAgenda}
className="p-2 rounded-full transition-all text-[#54656f] hover:bg-black/5 hover:text-[#128C7E]"
title="Abrir agenda"
>
<CalendarDays className="w-5 h-5" />
</button>
)}
{/* Fila da Secretária — modal "Pedidos aguardando confirmação" (mesmo da agenda) */}
{onOpenPedidos && (
<button
onClick={onOpenPedidos}
className="relative p-2 rounded-full transition-all text-[#54656f] hover:bg-black/5 hover:text-amber-600"
title="Pedidos aguardando confirmação"
>
<CalendarClock className="w-5 h-5" />
{pedidosCount > 0 && (
<span className="absolute top-1 right-1 min-w-[15px] h-[15px] px-1 rounded-full bg-amber-500 text-white text-[9px] font-black flex items-center justify-center pointer-events-none">
{pedidosCount > 99 ? '99+' : pedidosCount}
</span>
)}
</button>
)}
{onProtocolClick && (
<button
onClick={onProtocolClick}
@@ -121,6 +167,9 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
<AnimatePresence>
{isChatMenuOpen && (
<>
{/* Backdrop: fecha o menu ao clicar fora */}
<div className="fixed inset-0 z-40" onClick={onToggleMenu} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
@@ -138,16 +187,29 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
<RefreshCw className={`w-4 h-4 ${isSyncingAvatar ? 'animate-spin' : ''}`} />
Sincronizar Foto
</button>
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors">
<button
onClick={() => { onToggleMenu(); onManageLabels?.(); }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors"
>
<Tag className="w-4 h-4" />
Gerenciar etiquetas
</button>
<div className="border-t border-gray-100 my-1"></div>
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] font-bold text-red-600 hover:bg-red-50 transition-colors">
<X className="w-4 h-4" />
Limpar conversa
</button>
{/* Ação destrutiva só o dono do workspace (dentista/protético/
biomédico dono da clínica/consultório). Secretária/agente não . */}
{isOwner && (
<>
<div className="border-t border-gray-100 my-1"></div>
<button
onClick={() => { onToggleMenu(); onClearConversation?.(); }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] font-bold text-red-600 hover:bg-red-50 transition-colors"
>
<X className="w-4 h-4" />
Limpar conversa
</button>
</>
)}
</motion.div>
</>
)}
</AnimatePresence>
</div>
+61 -24
View File
@@ -43,6 +43,8 @@ interface InputBarProps {
onSendAudio: (blob: Blob) => Promise<void>;
replyingTo: Message | null;
onCancelReply: () => void;
editingMessage?: Message | null;
onCancelEdit?: () => void;
isMobile: boolean;
selectedChat: any;
instanceId?: string | null;
@@ -59,6 +61,8 @@ const InputBar: React.FC<InputBarProps> = ({
onSendAudio,
replyingTo,
onCancelReply,
editingMessage,
onCancelEdit,
isMobile,
selectedChat,
instanceId,
@@ -205,10 +209,11 @@ const InputBar: React.FC<InputBarProps> = ({
}, []);
const handleSendSticker = useCallback(async (blob: Blob) => {
if (!instanceId || !selectedChat?.remote_jid) return;
if (!selectedChat?.id) return;
setShowStickerPanel(false);
await mediaApi.sendSticker(instanceId, selectedChat.remote_jid, blob);
}, [instanceId, selectedChat]);
// mediaApi do satélite é por chatId (não instanceId/jid).
await mediaApi.sendSticker(String(selectedChat.id), blob);
}, [selectedChat]);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Mention dropdown navigation
@@ -405,6 +410,32 @@ const InputBar: React.FC<InputBarProps> = ({
)}
</AnimatePresence>
{/* Edit banner */}
<AnimatePresence>
{editingMessage && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="mx-4 mb-2 bg-white/80 backdrop-blur-sm border-l-[4px] border-[#f59e0b] rounded-lg px-4 py-3 flex items-start justify-between gap-4 shadow-sm"
>
<div className="min-w-0">
<div className="text-[12px] font-medium text-[#b45309] mb-1">Editando mensagem</div>
<div className="text-sm text-gray-500 font-medium truncate italic">
{editingMessage.text || 'Mensagem'}
</div>
</div>
<button
type="button"
className="bg-gray-100 hover:bg-gray-200 text-gray-500 p-1 rounded-full transition-colors"
onClick={onCancelEdit}
>
<X className="w-3.5 h-3.5 stroke-[3px]" />
</button>
</motion.div>
)}
</AnimatePresence>
{/* Reply Preview */}
<AnimatePresence>
{replyingTo && (
@@ -513,30 +544,36 @@ const InputBar: React.FC<InputBarProps> = ({
Mensagem Rica
</span>
</button>
{/* Emoji */}
<button
onClick={() => { setShowEmojiPicker(true); setShowStickerPanel(false); setShowAttachMenu(false); }}
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
title="Emojis"
>
<Smile className="w-[22px] h-[22px]" />
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
Emoji
</span>
</button>
{/* Figurinhas */}
{instanceId && (
<button
onClick={() => { setShowStickerPanel(true); setShowEmojiPicker(false); setShowAttachMenu(false); }}
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
title="Figurinhas"
>
<Sticker className="w-[22px] h-[22px]" />
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
Figurinhas
</span>
</button>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Emoji — mantém no lugar */}
<button
onClick={() => { setShowEmojiPicker(!showEmojiPicker); setShowStickerPanel(false); }}
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showEmojiPicker ? 'text-[#00a884]' : 'text-[#54656f]'}`}
title="Emojis"
>
<Smile className="w-[26px] h-[26px]" />
</button>
{/* Figurinhas */}
{instanceId && (
<button
onClick={() => { setShowStickerPanel(!showStickerPanel); setShowEmojiPicker(false); }}
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showStickerPanel ? 'text-[#00a884]' : 'text-[#54656f]'}`}
title="Figurinhas"
>
<Sticker className="w-[24px] h-[24px]" />
</button>
)}
</div>
<div className="flex-1 relative">
@@ -589,7 +626,7 @@ const InputBar: React.FC<InputBarProps> = ({
ref={textareaRef}
rows={1}
placeholder="Digite uma mensagem"
className="w-full px-3 py-2.5 bg-white rounded-lg text-[15px] text-[#111b21] border-none focus:ring-0 focus:outline-none placeholder:text-[#667781] resize-none shadow-sm transition-all overflow-hidden leading-[1.4]"
className="w-full px-3.5 py-3 bg-white rounded-xl text-[15px] text-[#111b21] border-none focus:ring-0 focus:outline-none placeholder:text-[#667781] resize-none shadow-sm transition-all overflow-hidden leading-[1.4]"
value={newMessage}
onChange={(e) => {
onNewMessageChange(e.target.value);
@@ -0,0 +1,148 @@
import React, { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { X, Plus, Trash2, Check, Tag } from 'lucide-react';
import { labelApi, type Label } from '../services/chatApiService';
interface LabelManagerModalProps {
isOpen: boolean;
onClose: () => void;
// Se fornecido, mostra atribuição de etiquetas para ESTA conversa.
chat?: { id: any; labels?: Label[] } | null;
// Chamado após qualquer mudança (para o inbox recarregar os chips).
onChanged?: () => void;
}
const PALETTE = ['#00a884', '#ef4444', '#f59e0b', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6', '#64748b'];
const LabelManagerModal: React.FC<LabelManagerModalProps> = ({ isOpen, onClose, chat, onChanged }) => {
const [labels, setLabels] = useState<Label[]>([]);
const [loading, setLoading] = useState(false);
const [newName, setNewName] = useState('');
const [newColor, setNewColor] = useState(PALETTE[0]);
const [assigned, setAssigned] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const list = await labelApi.list();
setLabels(Array.isArray(list) ? list : []);
} catch { /* ignore */ } finally { setLoading(false); }
}, []);
useEffect(() => {
if (!isOpen) return;
load();
setAssigned(new Set((chat?.labels ?? []).map((l) => l.id)));
}, [isOpen, chat, load]);
if (!isOpen || typeof document === 'undefined') return null;
const create = async () => {
if (!newName.trim()) return;
try {
await labelApi.create(newName.trim(), newColor);
setNewName('');
await load();
onChanged?.();
} catch { /* ignore */ }
};
const remove = async (id: string) => {
try { await labelApi.delete(id); setAssigned((s) => { const n = new Set(s); n.delete(id); return n; }); await load(); onChanged?.(); }
catch { /* ignore */ }
};
const toggleAssign = (id: string) => {
setAssigned((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
};
const saveAssignments = async () => {
if (!chat?.id || saving) return;
setSaving(true);
try { await labelApi.setForChat(String(chat.id), Array.from(assigned)); onChanged?.(); onClose(); }
catch { /* ignore */ } finally { setSaving(false); }
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
<div className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[85vh]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h3 className="text-[15px] font-bold text-[#111b21] flex items-center gap-2"><Tag className="w-4 h-4 text-[#00a884]" /> Etiquetas</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-700 rounded-full transition-colors"><X className="w-5 h-5" /></button>
</div>
<div className="flex-1 overflow-y-auto">
{/* Atribuição à conversa */}
{chat && (
<div className="px-4 py-3 border-b border-gray-100">
<div className="text-[12px] font-bold text-[#667781] uppercase tracking-wide mb-2">Nesta conversa</div>
{labels.length === 0 && <div className="text-[13px] text-[#667781]">Nenhuma etiqueta criada ainda.</div>}
<div className="flex flex-wrap gap-2">
{labels.map((l) => {
const on = assigned.has(l.id);
return (
<button key={l.id} onClick={() => toggleAssign(l.id)}
className={`flex items-center gap-1.5 pl-2 pr-2.5 py-1 rounded-full text-[13px] font-medium border transition-all ${on ? 'text-white' : 'text-[#111b21] bg-white'}`}
style={on ? { backgroundColor: l.color, borderColor: l.color } : { borderColor: l.color }}>
{on && <Check className="w-3.5 h-3.5" />}
<span style={!on ? { color: l.color } : undefined}>{l.name}</span>
</button>
);
})}
</div>
</div>
)}
{/* Lista/gestão */}
<div className="px-4 py-3">
<div className="text-[12px] font-bold text-[#667781] uppercase tracking-wide mb-2">Todas as etiquetas</div>
{loading && <div className="text-[13px] text-[#667781]">Carregando</div>}
<div className="space-y-1.5">
{labels.map((l) => (
<div key={l.id} className="flex items-center gap-2.5 py-1">
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ backgroundColor: l.color }} />
<span className="flex-1 text-[14px] text-[#111b21] truncate">{l.name}</span>
<button onClick={() => remove(l.id)} className="p-1 text-gray-300 hover:text-red-500 transition-colors"><Trash2 className="w-4 h-4" /></button>
</div>
))}
</div>
</div>
{/* Criar */}
<div className="px-4 py-3 border-t border-gray-100">
<div className="text-[12px] font-bold text-[#667781] uppercase tracking-wide mb-2">Nova etiqueta</div>
<div className="flex items-center gap-2 mb-2">
<input value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && create()}
placeholder="Nome da etiqueta"
className="flex-1 bg-[#f0f2f5] rounded-lg px-3 py-2 text-[14px] text-[#111b21] placeholder:text-[#667781] focus:outline-none" />
<button onClick={create} disabled={!newName.trim()}
className="flex items-center gap-1 bg-[#00a884] text-white text-[13px] font-bold px-3 py-2 rounded-lg disabled:opacity-40 hover:bg-[#008f6f] transition-colors">
<Plus className="w-4 h-4" /> Criar
</button>
</div>
<div className="flex items-center gap-2">
{PALETTE.map((c) => (
<button key={c} onClick={() => setNewColor(c)}
className={`w-6 h-6 rounded-full transition-transform ${newColor === c ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''}`}
style={{ backgroundColor: c }} />
))}
</div>
</div>
</div>
{chat && (
<div className="px-4 py-3 border-t border-gray-100 flex justify-end">
<button onClick={saveAssignments} disabled={saving}
className="bg-[#00a884] text-white text-[14px] font-bold px-4 py-2 rounded-lg disabled:opacity-40 hover:bg-[#008f6f] transition-colors">
{saving ? 'Salvando…' : 'Salvar nesta conversa'}
</button>
</div>
)}
</div>
</div>,
document.body,
);
};
export default LabelManagerModal;
@@ -118,7 +118,22 @@ export const extractFirstUrl = (text: string | null | undefined): string | null
return m ? m[0] : null;
};
// Renders plain text with URLs converted to clickable <a> tags
// Formatação inline estilo WhatsApp: *negrito*, _itálico_, ~tachado~. Guarda contra
// falso-positivo exigindo não-espaço colado às marcas (evita "3 * 4" virar negrito).
const INLINE_RE = /(\*\S(?:[^*\n]*\S)?\*|_\S(?:[^_\n]*\S)?_|~\S(?:[^~\n]*\S)?~)/g;
const renderInline = (text: string, keyBase: string): React.ReactNode[] => {
if (!text) return [];
return text.split(INLINE_RE).map((part, i) => {
if (!part) return null;
const key = `${keyBase}-${i}`;
if (/^\*\S(?:[^*\n]*\S)?\*$/.test(part)) return <strong key={key} className="font-semibold">{part.slice(1, -1)}</strong>;
if (/^_\S(?:[^_\n]*\S)?_$/.test(part)) return <em key={key}>{part.slice(1, -1)}</em>;
if (/^~\S(?:[^~\n]*\S)?~$/.test(part)) return <s key={key}>{part.slice(1, -1)}</s>;
return <React.Fragment key={key}>{part}</React.Fragment>;
});
};
// Renders text with URLs as <a> tags + WhatsApp inline formatting (bold/italic/strike)
export const renderTextWithLinks = (text: string): React.ReactNode[] => {
const parts = text.split(/(https?:\/\/[^\s<>"{}|\\^`[\]]{4,})/g);
return parts.map((part, i) => {
@@ -136,7 +151,7 @@ export const renderTextWithLinks = (text: string): React.ReactNode[] => {
</a>
);
}
return <span key={i}>{part}</span>;
return <React.Fragment key={i}>{renderInline(part, String(i))}</React.Fragment>;
});
};
@@ -11,6 +11,7 @@ import { normalizeJid, parseSafeDate } from '../utils/inboxUtils';
import { Message } from '../types/inboxTypes';
import { mediaApi } from '../services/chatApiService';
import { useChatStore } from '../store/chatStore';
import AiPauseButton from './AiPauseButton';
// Message interface removed and moved to types.ts
@@ -33,6 +34,7 @@ interface MessageAreaProps {
quickReactions: string[];
getGroupSenderLabel: (msg: Message) => string;
canDelete?: boolean;
highlightTerm?: string;
}
const MessageArea: React.FC<MessageAreaProps> = ({
@@ -54,6 +56,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
quickReactions,
getGroupSenderLabel,
canDelete = true,
highlightTerm,
}) => {
const [showScrollBottom, setShowScrollBottom] = useState(false);
const lastScrollTop = useRef(0);
@@ -144,6 +147,8 @@ const MessageArea: React.FC<MessageAreaProps> = ({
return (
<>
<div className="flex-1 relative flex flex-col min-h-0 bg-transparent overflow-hidden">
{/* Botão do atendente: pausar/retomar a Secretária IA nesta conversa. */}
<AiPauseButton jid={selectedChat?.remote_jid} instanceId={selectedChat?.instance_name ?? selectedChat?.instanceId} />
<div
className="flex-1 overflow-y-auto px-4 md:px-14 py-6 flex flex-col custom-scrollbar relative z-10"
@@ -219,6 +224,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
selectedChat={selectedChat}
onOpenMedia={(m) => setViewerMsgId(m.message_id)}
onRedownloadMedia={handleRedownloadMedia}
highlightTerm={highlightTerm}
/>
</React.Fragment>
);
@@ -237,7 +243,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 10 }}
onClick={() => scrollToBottom()}
className="absolute bottom-6 right-6 w-11 h-11 bg-white text-[#54656f] rounded-full shadow-lg flex items-center justify-center hover:bg-[#f0f2f5] transition-all z-30 border border-[#d1d7db] active:scale-90"
className="absolute bottom-[80px] right-6 w-11 h-11 bg-white text-[#54656f] rounded-full shadow-lg flex items-center justify-center hover:bg-[#f0f2f5] transition-all z-30 border border-[#d1d7db] active:scale-90"
>
<ArrowDown className="w-5 h-5" />
{selectedChat?.unread_count > 0 && (
@@ -104,7 +104,7 @@ const AudioBubble: React.FC<AudioBubbleProps> = ({ msg, fullMediaUrl, isOut, Tim
onEnded={() => { setPlaying(false); setCurrentTime(0) }}
preload="metadata"
/>
<div className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px]">
<div className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] max-w-full">
{/* Play/pause */}
<button
onClick={toggle}
@@ -341,7 +341,9 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
// o trecho casado vira <mark>. Sem termo/sem match → comportamento normal.
const renderTextHL = (text?: string | null): React.ReactNode => {
const t = (highlightTerm ?? '').trim();
const src = text ?? '';
// Assinatura do operador: 1ª linha "*NOME*" (negrito WhatsApp) seguida de \n vira
// "*NOME:*" — exibe "NOME:" em negrito antes da quebra. Idempotente (não duplica :).
const src = (text ?? '').replace(/^\*([^*\n]+?):?\*(\n)/, '*$1:*$2');
if (!t) return renderTextWithLinks(src);
const lower = src.toLowerCase();
const lt = t.toLowerCase();
@@ -750,13 +752,13 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
<div>
<div
onClick={mediaUnavailable ? undefined : handleRedownload}
className={`flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed]'}`}
className={`flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] max-w-full transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed]'}`}
>
{isRedownloading
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin shrink-0" />
: <div className="w-10 h-10 rounded-full bg-[#8696a0] flex items-center justify-center shrink-0"><Mic className="w-4 h-4 text-white" /></div>
}
<span className="text-[12px] text-[#667781]">
<span className="text-[12px] text-[#667781] min-w-0 truncate">
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Áudio não disponível' : 'Toque para baixar áudio'}
</span>
</div>
@@ -898,7 +900,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
{/* Authentic WhatsApp Message Menu Button */}
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className={`absolute top-1 right-1 p-1 text-slate-500 opacity-0 group-hover:opacity-100 hover:text-slate-300 transition-opacity z-30`}
className={`absolute top-1 right-1 p-1 text-slate-500 hover:text-slate-300 transition-opacity z-30 ${(isMenuOpen || reactionPickerFor === msg.message_id) ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100'}`}
>
<ChevronDown className="w-5 h-5" />
</button>
@@ -121,6 +121,8 @@ function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: Protoc
<AnimatePresence>
{showActions && (
<>
<div className="fixed inset-0 z-[9]" onClick={() => setShowActions(false)} />
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
@@ -141,6 +143,7 @@ function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: Protoc
)
})}
</motion.div>
</>
)}
</AnimatePresence>
</div>
@@ -16,7 +16,7 @@ const fmt = (iso?: string | null) => {
catch { return ''; }
};
export const SessaoSecretariaPower: React.FC<{ instanceId: string | null }> = ({ instanceId }) => {
export const SessaoSecretariaPower: React.FC<{ instanceId: string | null; canPower?: boolean }> = ({ instanceId, canPower }) => {
const [sp, setSp] = useState<SessionPower | null>(null);
const [open, setOpen] = useState(false);
const [reason, setReason] = useState('');
@@ -30,8 +30,11 @@ export const SessaoSecretariaPower: React.FC<{ instanceId: string | null }> = ({
useEffect(() => { carregar(); }, [carregar]);
if (!instanceId) return null;
const enabled = sp ? sp.enabled : true;
// Fail-closed: só o dono controla o liga/desliga da Secretária. Para os demais o
// botão nem aparece (antes, o GET dava 403 e o estado caía no fail-open "ligada").
if (!instanceId || !canPower) return null;
const known = sp !== null; // estado real carregado?
const enabled = known ? sp!.enabled : false; // desconhecido NUNCA vira "ligada"
const toggle = async () => {
if (!sp?.can_toggle) return;
@@ -51,10 +54,12 @@ export const SessaoSecretariaPower: React.FC<{ instanceId: string | null }> = ({
return (
<>
<button
title={enabled ? 'Secretária IA ligada nesta sessão — clique para gerenciar' : 'Secretária IA DESLIGADA nesta sessão'}
title={!known ? 'Secretária IA — carregando estado…' : enabled ? 'Secretária IA ligada nesta sessão — clique para gerenciar' : 'Secretária IA DESLIGADA nesta sessão'}
onClick={() => { setErro(null); setOpen(true); }}
className={`w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors ${
enabled ? 'text-emerald-600 hover:bg-[#d9dbdf]/60' : 'text-white bg-red-600 hover:bg-red-700 animate-pulse'
!known ? 'text-gray-300 hover:bg-[#d9dbdf]/60'
: enabled ? 'text-emerald-600 hover:bg-[#d9dbdf]/60'
: 'text-white bg-red-600 hover:bg-red-700 animate-pulse'
}`}
>
<Power className="w-5 h-5" />
@@ -42,7 +42,9 @@ export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState<any>(null);
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8008';
// Same-origin (NÃO localhost do PC — isso disparava o prompt de "acessar rede local").
// A troca de engine é recurso do standalone; no satélite degrada gracioso (404).
const API_URL = '';
const authHeader = (): Record<string, string> => {
const t = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
return t ? { Authorization: `Bearer ${t}` } : {};
@@ -6,7 +6,6 @@ import { Heart, Clock, Loader2 } from 'lucide-react';
import { stickerApi, type StickerRecord } from '../services/chatApiService';
import { getStickerFromCache, setStickerInCache } from '../hooks/useStickerCache';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003';
const LIST_VERSION_KEY = 'nw:sticker-list-version';
interface StickerPanelProps {
@@ -15,10 +14,10 @@ interface StickerPanelProps {
onSend: (blob: Blob) => void;
}
// Converte URL Wasabi → URL do proxy do backend
// Converte path Wasabi → URL same-origin do proxy nw (NÃO localhost do PC).
function stickerSrcUrl(wasabiPath: string): string {
const clean = wasabiPath.startsWith('/') ? wasabiPath.slice(1) : wasabiPath;
return `${API}/api/storage/view/${clean}`;
return `/api/nw/media/${clean}`;
}
// Carrega sticker: tenta IndexedDB, depois proxy, depois persiste no cache
@@ -5,20 +5,25 @@ import {
Users,
Settings,
BrainCircuit,
CalendarClock,
} from 'lucide-react';
import { getAvatarProxyUrl } from '../utils/inboxUtils';
import { SessaoSecretariaPower } from './SessaoSecretariaPower';
export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings';
export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings' | 'fila';
interface ThinSidebarProps {
activeTab: TabType;
setActiveTab: (tab: TabType) => void;
activeInstance: any; // We'll pass the active instance to show the avatar
onNavigate?: (view: string) => void; // satélite: navegação entre as views do plugin
// Fila da Secretária: nº de conversas com pedido pendente (badge da aba). 0 = sem badge.
filaCount?: number;
// Só o dono controla o liga/desliga da Secretária (botão de power). Fail-closed.
canPower?: boolean;
}
export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, onNavigate }: ThinSidebarProps) {
export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, onNavigate, filaCount = 0, canPower = false }: ThinSidebarProps) {
const isConnected = activeInstance?.status === 'connected' || activeInstance?.status === 'open';
const renderIcon = (id: TabType, IconComponent: any, title: string) => {
@@ -51,6 +56,18 @@ export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, o
{/* Top Icons */}
<div className="flex-1 w-full flex flex-col items-center">
{renderIcon('chats', MessageCircle, 'Conversas')}
{/* Fila da Secretária: conversas com pedido aguardando confirmação da humana */}
<div className="relative">
{renderIcon('fila', CalendarClock, 'Fila da Secretária')}
{filaCount > 0 && (
<span
className="absolute -top-0.5 right-0 min-w-[18px] h-[18px] px-1 rounded-full bg-amber-500 text-white text-[10px] font-black flex items-center justify-center pointer-events-none shadow"
title={`${filaCount} pendente(s)`}
>
{filaCount > 99 ? '99+' : filaCount}
</span>
)}
</div>
{renderIcon('broadcasts', Megaphone, 'Transmissões')}
{renderIcon('groups', Users, 'Sessões')}
{/* Secretária IA — abre a tela de configuração da secretária virtual */}
@@ -66,7 +83,7 @@ export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, o
{/* Bottom Icons */}
<div className="w-full flex flex-col items-center pb-2">
{/* Liga/desliga a Secretária IA APENAS desta sessão (número atual) */}
<SessaoSecretariaPower instanceId={activeInstance?.instance ?? null} />
<SessaoSecretariaPower instanceId={activeInstance?.instance ?? null} canPower={canPower} />
{/* Configurações — abre o painel na área central (aba 'settings') */}
{renderIcon('settings', Settings, 'Configurações')}
@@ -10,7 +10,8 @@
* - abertura de perfil / menu de chat
*/
import { useState, useRef, useCallback } from 'react'
import { mediaApi } from '../../services/chatApiService'
import { mediaApi, chatApi, avatarApi } from '../../services/chatApiService'
import { useChatStore } from '../../store/chatStore'
import type { NewWhatsChat } from '../../types/inboxTypes'
import type { Message } from '../../types/inboxTypes'
@@ -39,20 +40,32 @@ export function useChatWindow({
const [isProfileOpen, setIsProfileOpen] = useState(false)
const [isSyncingAvatar, setIsSyncingAvatar] = useState(false)
const [isChatMenuOpen, setIsChatMenuOpen] = useState(false)
const [editingMessage, setEditingMessage] = useState<Message | null>(null)
const [forwardingMessage, setForwardingMessage] = useState<Message | null>(null)
// ── Handlers ────────────────────────────────────────────────────────────────
// Chamado quando O OPERADOR digita no input. NÃO deve acender o "digitando" — esse
// indicador é da presença `composing` do CONTATO (outro lado), não da minha. Antes,
// digitar no input marcava isTyping=true localmente, mostrando "digitando" para mim
// mesmo. Mantido como no-op até o motor repassar a presença do contato pelo stream.
// Envia MINHA presença "composing" ao contato ao digitar (throttle ~3s) e "paused"
// depois de ~4s de ociosidade. Não altera o indicador local (que é do CONTATO).
const lastComposingRef = useRef(0)
const pausedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleTyping = useCallback(() => {
if (!selectedChat) return
if (!isTypingRef.current) {
isTypingRef.current = true
setIsTyping(true)
const chatId = selectedChat?.id ? String(selectedChat.id) : null
if (!chatId) return
const now = Date.now()
if (now - lastComposingRef.current > 3000) {
lastComposingRef.current = now
chatApi.typing(chatId, 'composing').catch(() => {})
}
if (typingTimeout.current) clearTimeout(typingTimeout.current)
typingTimeout.current = setTimeout(() => {
isTypingRef.current = false
setIsTyping(false)
}, 3000)
if (pausedTimerRef.current) clearTimeout(pausedTimerRef.current)
pausedTimerRef.current = setTimeout(() => {
lastComposingRef.current = 0
chatApi.typing(chatId, 'paused').catch(() => {})
}, 4000)
}, [selectedChat])
const handleScroll = useCallback(async () => {
@@ -70,39 +83,54 @@ export function useChatWindow({
setHasMoreHistory(true)
}, [])
const handleReactToMessage = useCallback((_msg: Message, _emoji: string) => {
// Reage a uma mensagem: envia ao WhatsApp e atualiza o store na hora (otimista).
// Toggle: clicar no mesmo emoji já reagido remove a reação. Reverte em caso de erro.
const handleReactToMessage = useCallback(async (msg: Message, emoji: string) => {
setReactionPickerFor(null)
// Será implementado via endpoint /api/instances/:id/react
}, [])
const handleSendMedia = useCallback(async (file: File, caption?: string) => {
if (!selectedChat || !activeInstanceId) return
const chatId = selectedChat?.id ? String(selectedChat.id) : null
if (!chatId || !msg?.id) return
const current = (msg as any).reactions?.me as string | undefined
const next = current === emoji ? '' : emoji
useChatStore.getState().reactMessage(chatId, String(msg.id), next)
try {
await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, caption || undefined, undefined)
await chatApi.react(chatId, String(msg.id), next)
} catch (err) {
console.error('Erro ao reagir:', err)
useChatStore.getState().reactMessage(chatId, String(msg.id), current || '') // reverte
}
}, [selectedChat])
// O mediaApi do satélite é por chatId (a ext resolve jid/instância): sendMedia
// (chatId, file, caption?) e sendAudio(chatId, blob). Antes passávamos a assinatura
// antiga do motor (instanceId, jid, ...) e o blob caía no lugar errado → o FormData
// recebia o jid (string) em vez do Blob e quebrava o envio.
const handleSendMedia = useCallback(async (file: File, caption?: string) => {
if (!selectedChat?.id) return
try {
await mediaApi.sendMedia(String(selectedChat.id), file, caption || undefined)
} catch (err) {
console.error('Erro ao enviar mídia:', err)
}
}, [selectedChat, activeInstanceId])
}, [selectedChat])
const handleSendAudio = useCallback(async (blob: Blob) => {
if (!selectedChat || !activeInstanceId) return
if (!selectedChat?.id) return
try {
await mediaApi.sendAudio(activeInstanceId, selectedChat.remote_jid, blob)
await mediaApi.sendAudio(String(selectedChat.id), blob)
} catch (err) {
console.error('Erro ao enviar áudio:', err)
}
}, [selectedChat, activeInstanceId])
}, [selectedChat])
const handleSyncAvatar = useCallback(async () => {
if (!selectedChat || !activeInstanceId) return
setIsSyncingAvatar(true)
try {
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003'
const token = localStorage.getItem('token')
await fetch(
`${API}/api/inbox/avatar/${encodeURIComponent(selectedChat.remote_jid)}?instance=${activeInstanceId}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
// Via proxy nw (NÃO localhost do PC — isso disparava o prompt de "acessar rede local").
const avatar = await avatarApi.refresh(activeInstanceId, selectedChat.remote_jid)
if (avatar && selectedChat.id) useChatStore.getState().patchChat(String(selectedChat.id), { contactAvatarUrl: avatar } as any)
} catch (err) {
console.error('Erro ao sincronizar foto:', err)
} finally {
setIsSyncingAvatar(false)
}
@@ -112,6 +140,23 @@ export function useChatWindow({
setReactionPickerFor(prev => prev === id ? null : id)
}, [])
// "Limpar conversa" (só o dono — a visibilidade do botão já é gateada por isOwner).
// Remove todas as mensagens do chat no motor e esvazia a lista local. Não revoga no
// WhatsApp; é limpeza local do inbox (análogo ao "Limpar conversa" do WhatsApp).
const handleClearConversation = useCallback(async () => {
if (!selectedChat?.id) return
const id = String(selectedChat.id)
if (typeof window !== 'undefined' &&
!window.confirm('Limpar esta conversa? Todas as mensagens serão removidas do inbox. Esta ação não pode ser desfeita.')) return
try {
await chatApi.clearConversation(id)
useChatStore.getState().setMessages(id, [])
} catch (err) {
console.error('Erro ao limpar conversa:', err)
if (typeof window !== 'undefined') window.alert('Não foi possível limpar a conversa.')
}
}, [selectedChat])
return {
// Refs
scrollRef,
@@ -130,6 +175,10 @@ export function useChatWindow({
setReplyingTo,
setIsProfileOpen,
setIsChatMenuOpen,
editingMessage,
setEditingMessage,
forwardingMessage,
setForwardingMessage,
// Handlers
handleTyping,
@@ -140,5 +189,6 @@ export function useChatWindow({
handleSendAudio,
handleSyncAvatar,
toggleReactionPicker,
handleClearConversation,
}
}
@@ -18,6 +18,21 @@ export interface LegacyInstance {
phone: string | null
avatar: string | null
profilePictureUrl: string | null
// Seletor multi-conta:
role: string | null
label: string | null
area: string | null
notes: string | null
clinicaId: string | null
ownerNome: string | null
own: boolean
}
// Conta ativa da inbox — lida pelo nwClient p/ decidir o x-nw-clinica das rotas de
// inbox (clinicaId null = conta própria → o proxy fala como o próprio usuário).
const INBOX_ACCOUNT_KEY = 'nw:inbox-account'
function persistInboxAccount(clinicaId: string | null, instanceId: string) {
try { localStorage.setItem(INBOX_ACCOUNT_KEY, JSON.stringify({ clinicaId, instanceId })) } catch { /* ignore */ }
}
export interface MappedInstance {
@@ -46,11 +61,18 @@ export function useInstanceSelector() {
const instancesLegacy = useMemo<LegacyInstance[]>(() =>
instances.map((i) => ({
instance: i.id,
nome: i.name,
nome: i.label || i.name,
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
phone: i.phone,
avatar: i.avatar ?? null,
profilePictureUrl: i.avatar ?? null,
role: i.role ?? null,
label: i.label ?? null,
area: i.area ?? null,
notes: i.notes ?? null,
clinicaId: i.clinicaId ?? null,
ownerNome: i.ownerNome ?? null,
own: i.own ?? true,
})),
[instances]
)
@@ -83,20 +105,38 @@ export function useInstanceSelector() {
useEffect(() => {
if (!activeId && instances.length > 0) {
const first = instances[0]
persistInboxAccount(first.clinicaId ?? null, first.id)
setActiveId(first.id)
socketService.joinInstance(first.id)
}
}, [instances, activeId, setActiveId])
// Mantém nw:inbox-account SEMPRE em sincronia com a instância ativa — inclusive
// quando activeId é restaurado do localStorage (aí o auto-select acima não roda).
// Sem isto, o nw:inbox-account fica com a clínica de uma seleção anterior e o
// inbox da conta própria vai com x-nw-clinica errado → proxy reescreve → 404.
useEffect(() => {
if (!activeId || instances.length === 0) return
const inst = instances.find((i) => i.id === activeId)
if (inst) {
persistInboxAccount(inst.clinicaId ?? null, activeId)
socketService.syncAccount() // realtime no tenant certo (própria vs. clínica)
}
}, [activeId, instances])
// ── Troca de instância ────────────────────────────────────────────────────
const selectInstance = useCallback((legacyInstance: LegacyInstance | { instance: string }) => {
const id = legacyInstance.instance
if (id === activeId) return
// Fixa a conta ativa ANTES de carregar os chats: o nwClient usa isso p/ decidir
// o x-nw-clinica (conta própria vs. caixa da clínica) nas rotas de inbox.
const clinicaId = instances.find((i) => i.id === id)?.clinicaId ?? null
persistInboxAccount(clinicaId, id)
resetChats([])
setActiveId(id)
socketService.joinInstance(id)
}, [activeId, setActiveId, resetChats])
}, [activeId, setActiveId, resetChats, instances])
// ── Refresh ────────────────────────────────────────────────────────────────
@@ -11,11 +11,14 @@ import type { NewWhatsChat } from '../../types/inboxTypes'
interface UseMessageInputOptions {
selectedChat: NewWhatsChat | null
replyingTo?: { message_id: string } | null
editingMessage?: { id: string | number } | null
onEditText?: (messageId: string, text: string) => Promise<void>
onClearEdit?: () => void
onClearReply?: () => void
onSendText: (text: string, mentions?: string[], replyToMessageId?: string) => Promise<void>
}
export function useMessageInput({ selectedChat, replyingTo, onClearReply, onSendText }: UseMessageInputOptions) {
export function useMessageInput({ selectedChat, replyingTo, editingMessage, onEditText, onClearEdit, onClearReply, onSendText }: UseMessageInputOptions) {
const [newMessage, setNewMessage] = useState('')
const [mentions, setMentions] = useState<string[]>([])
@@ -23,13 +26,20 @@ export function useMessageInput({ selectedChat, replyingTo, onClearReply, onSend
e?.preventDefault()
if (!selectedChat || !newMessage.trim()) return
const text = newMessage
// Modo edição: salva a edição da mensagem em vez de enviar uma nova.
if (editingMessage) {
setNewMessage('')
onClearEdit?.()
await onEditText?.(String(editingMessage.id), text)
return
}
const mentionJids = mentions.length > 0 ? [...mentions] : undefined
const replyToMessageId = replyingTo?.message_id
setNewMessage('')
setMentions([])
onClearReply?.()
await onSendText(text, mentionJids, replyToMessageId)
}, [selectedChat, newMessage, mentions, replyingTo, onSendText, onClearReply])
}, [selectedChat, newMessage, mentions, replyingTo, editingMessage, onEditText, onClearEdit, onSendText, onClearReply])
return {
newMessage,
@@ -61,7 +61,7 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
is_pinned: c.isPinned,
is_archived: c.isArchived,
bot_paused: c.botPaused ?? null,
labels: [],
labels: (c as any).labels ?? [],
}
}
@@ -88,6 +88,7 @@ function messagesToLegacy(msgs: ReturnType<typeof useChatStore.getState>['messag
quoted_message_text: m.replyTo?.body ?? null,
quoted_participant: m.replyTo ? (m.replyTo.fromMe ? 'me' : null) : null,
participant: m.pushName ?? null,
reactions: m.reactions ?? null,
senderJid: m.senderJid ?? null,
sender_jid: m.senderJid ?? null,
remote_jid: m.chatId,
@@ -118,7 +119,9 @@ export function useNewInboxBridge() {
// ── Carregar chats quando instância ativa muda ─────────────────────────────
useEffect(() => {
const { activeInstanceId } = instanceStore
if (!activeInstanceId) return
// Sem instância ativa (ex.: superadmin, ou instância resetada por não ser
// acessível) → esvazia a lista para NÃO deixar chats de outra sessão na tela.
if (!activeInstanceId) { chatStore.setChats([]); return }
chatStore.loadChats(activeInstanceId)
socketService.joinInstance(activeInstanceId)
// Reconcilia nomes em background (cache 30min — leve em escala)
@@ -1,7 +1,7 @@
// Shims dos services do motor sobre a ext API do satélite (/api/nw/v1/*).
// Mantém a MESMA interface que o chatApiService.ts do motor expõe, normalizando
// os shapes da ext API para o formato que os stores/hooks portados esperam.
import { nw } from './nwClient';
import { nw, nwToken } from './nwClient';
// ─── Instâncias ── (ext /sessions) ──────────────────────────────────────────
export const instanceApi = {
@@ -13,6 +13,29 @@ export const instanceApi = {
delete: (id: string) => nw.delete(`/sessions/${id}`),
};
// ─── Contas da inbox ── (/api/nw/accounts, fora do proxy /nw/v1) ─────────────
// Números que o usuário logado pode operar: os PRÓPRIOS + os LIBERADOS pelo dono
// (can_inbox). Cada item traz o papel (sec_numbers) p/ o seletor e o ícone (i).
export interface InboxAccount {
instanceId: string; phone: string | null; name: string | null;
status: string | null; avatar: string | null;
role: string | null; label: string | null; area: string | null; notes: string | null;
clinicaId: string | null; ownerEmail: string | null; ownerNome: string | null;
own: boolean; canInbox: boolean;
}
export const accountsApi = {
list: async (): Promise<InboxAccount[]> => {
try {
const res = await fetch(`${nw.base()}/nw/accounts`, {
headers: { Authorization: `Bearer ${nwToken() ?? ''}` },
});
if (!res.ok) return [];
const j = await res.json();
return Array.isArray(j) ? j : [];
} catch { return []; }
},
};
// ─── Avatar self-heal ── (ext /contacts/:jid/avatar/refresh) ─────────────────
// A URL do CDN do WhatsApp expira e passa a 404; aqui pedimos ao motor para
// re-buscar a foto atual pela conexão viva. Retorna a URL nova ou null.
@@ -25,6 +48,33 @@ export const avatarApi = {
},
};
// ─── Handoff IA/humano ── (ext /sec/handoff/:jid — pausar/retomar a Secretária) ──
export const handoffApi = {
// secEnabled = estado efetivo do liga/desliga (chat/sessão/global) p/ o botão de
// pausa não mostrar "IA ativa" quando a Secretária está desligada. null = desconhecido.
get: async (jid: string, instanceId?: string | null): Promise<{ mode: 'ia' | 'humano'; humanAt: string | null; secEnabled: boolean | null }> => {
try {
const q = instanceId ? `?instance_id=${encodeURIComponent(instanceId)}` : '';
const r = await nw.get(`/sec/handoff/${encodeURIComponent(jid)}${q}`);
return { mode: r?.mode === 'humano' ? 'humano' : 'ia', humanAt: r?.handoffHumanAt ?? null, secEnabled: r?.sec_enabled ?? null };
}
catch { return { mode: 'ia', humanAt: null, secEnabled: null }; }
},
set: (jid: string, mode: 'ia' | 'humano') => nw.patch(`/sec/handoff/${encodeURIComponent(jid)}`, { mode }),
};
// Janela de pausa antes do retorno automático da IA (igual ao motor: HANDOFF_TIMEOUT_MS).
export const HANDOFF_MINUTES = 15;
// ─── Etiquetas ── (ext /labels + /inbox/:chatId/labels) ─────────────────────
export interface Label { id: string; name: string; color: string }
export const labelApi = {
list: (): Promise<Label[]> => nw.get('/labels'),
create: (name: string, color: string): Promise<Label> => nw.post('/labels', { name, color }),
update: (id: string, patch: { name?: string; color?: string }) => nw.patch(`/labels/${id}`, patch),
delete: (id: string) => nw.delete(`/labels/${id}`),
setForChat: (chatId: string, labelIds: string[]) => nw.put(`/inbox/${chatId}/labels`, { labelIds }),
};
// ─── Chats ── (ext /inbox → shape achatado; normaliza p/ mapBackendChat) ────
export const chatApi = {
list: async (instanceId: string, opts: { search?: string; archived?: boolean; limit?: number } = {}) => {
@@ -55,6 +105,25 @@ export const chatApi = {
archive: (chatId: string, archived: boolean) => nw.patch(`/inbox/${chatId}/archive`, { archived }),
pin: (chatId: string, pinned: boolean) => nw.patch(`/inbox/${chatId}/pin`, { pinned }),
delete: (chatId: string) => nw.delete(`/inbox/${chatId}`),
// Apaga UMA mensagem para todos (revoke no WhatsApp). messageId = id interno (uuid).
deleteMessage: (chatId: string, messageId: string) => nw.delete(`/inbox/${chatId}/messages/${messageId}`),
// "Limpar conversa": remove TODAS as mensagens do chat no motor (mantém o chat). Local
// (não revoga no WhatsApp). Gate de dono/can_delete_msg validado no proxy nw.
clearConversation: (chatId: string) => nw.delete(`/inbox/${chatId}/messages`),
// Assina a presença do contato (composing/recording) — chamar ao abrir o chat.
subscribePresence: (chatId: string) => nw.post(`/inbox/${chatId}/presence`),
// Reage a uma mensagem (emoji ''= remove). messageId = id interno (uuid).
react: (chatId: string, messageId: string, emoji: string) =>
nw.post(`/inbox/${chatId}/messages/${messageId}/react`, { emoji }),
// Edita uma mensagem de texto enviada por você.
editMessage: (chatId: string, messageId: string, text: string) =>
nw.patch(`/inbox/${chatId}/messages/${messageId}`, { text }),
// Encaminha uma mensagem para outros chats (ids internos).
forward: (chatId: string, messageId: string, toChatIds: string[]) =>
nw.post(`/inbox/${chatId}/messages/${messageId}/forward`, { toChatIds }),
// Envia minha presença ao contato (composing/paused).
typing: (chatId: string, state: 'composing' | 'paused' | 'recording') =>
nw.post(`/inbox/${chatId}/typing`, { state }),
};
// ─── Mensagens ── (ext devolve array; envolve em {messages, hasMore}) ───────
@@ -83,24 +152,34 @@ export const messageApi = {
nw.post(`/inbox/${chatId}/send`, { text, ...(replyToMessageId ? { replyToMessageId } : {}) }),
};
// Lê um Blob/File como base64 puro (sem o prefixo data:...;base64,).
const toBase64 = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const r = new FileReader();
r.onloadend = () => resolve(String(r.result).split(',')[1] || '');
r.onerror = () => reject(new Error('Falha ao ler o arquivo'));
r.readAsDataURL(blob);
});
// ─── Mídia ── (ext /inbox/:chatId/send-media | send-audio) ──────────────────
// Imagem/vídeo/documento/figurinha vão via MULTIPART (o proxy encaminha o stream ao
// motor — sem limite de base64). Áudio (nota de voz) vai em base64/JSON: é pequeno e
// o motor converte p/ OGG/Opus via ffmpeg.
export const mediaApi = {
sendMedia: (chatId: string, file: File, caption?: string, replyToMessageId?: string) => {
sendMedia: (chatId: string, file: File, caption?: string) => {
const fd = new FormData();
fd.append('file', file);
if (caption) fd.append('caption', caption);
if (replyToMessageId) fd.append('replyToMessageId', replyToMessageId);
return nw.post(`/inbox/${chatId}/send-media`, fd);
},
sendAudio: (chatId: string, blob: Blob) => {
const fd = new FormData();
fd.append('audio', blob, 'audio.ogg');
return nw.post(`/inbox/${chatId}/send-audio`, fd);
sendAudio: async (chatId: string, blob: Blob) => {
const audio = await toBase64(blob);
return nw.post(`/inbox/${chatId}/send-audio`, { audio, mimetype: blob.type || 'audio/ogg' });
},
// Sticker: reaproveita o endpoint de mídia (a ext detecta image/webp → sticker).
sendSticker: (chatId: string, blob: Blob) => {
const fd = new FormData();
fd.append('file', new File([blob], 'sticker.webp', { type: 'image/webp' }));
fd.append('sticker', 'true');
return nw.post(`/inbox/${chatId}/send-media`, fd);
},
// Re-download sob demanda: ~95% das mídias vêm com mediaUrl NULL (lazy). O motor
+30 -5
View File
@@ -9,14 +9,39 @@ export function nwToken(): string | null {
try { return localStorage.getItem(TOKEN_KEY); } catch { return null; }
}
// Clínica do workspace ativo — escopo por-requisição (modelo de canal).
function activeClinicaId(): string | null {
// Clínica do workspace ativo (agenda/secretária) — escopo por-requisição (modelo de canal).
function activeWorkspaceId(): string | null {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_ACTIVE_WORKSPACE') || '{}')?.id || null; } catch { return null; }
}
function authHeaders(extra?: Record<string, string>): Record<string, string> {
// Conta ativa da INBOX (seletor de número multi-conta). Quando o usuário escolhe um
// número no seletor, guardamos { clinicaId, instanceId }. clinicaId null = conta
// PRÓPRIA (sem x-nw-clinica → o proxy fala com o motor como o próprio usuário).
export const INBOX_ACCOUNT_KEY = 'nw:inbox-account';
function inboxAccountClinica(): { set: boolean; clinicaId: string | null } {
try {
const raw = localStorage.getItem(INBOX_ACCOUNT_KEY);
if (!raw) return { set: false, clinicaId: null };
const a = JSON.parse(raw);
return a && a.instanceId ? { set: true, clinicaId: a.clinicaId ?? null } : { set: false, clinicaId: null };
} catch { return { set: false, clinicaId: null }; }
}
// Resolve o x-nw-clinica da requisição. Rotas de inbox usam SEMPRE a CONTA ATIVA do
// seletor (nunca o workspace global da agenda) — assim a caixa própria não herda a
// clínica selecionada na agenda, o que faria o proxy reescrever para o dono errado
// e o motor devolver 404. clinicaId null (conta própria/não-setado) → sem header.
// As demais rotas (agenda/secretária) seguem o workspace global.
function clinicaForPath(path: string): string | null {
if (/^\/(inbox|conversations)(\/|$|\?)/.test(path)) {
return inboxAccountClinica().clinicaId;
}
return activeWorkspaceId();
}
function authHeaders(path: string, extra?: Record<string, string>): Record<string, string> {
const t = nwToken();
const c = activeClinicaId();
const c = clinicaForPath(path);
return {
...(t ? { Authorization: `Bearer ${t}` } : {}),
...(c ? { 'x-nw-clinica': c } : {}),
@@ -28,7 +53,7 @@ async function req(method: string, path: string, body?: any): Promise<any> {
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
const res = await fetch(`${API}/nw/v1${path}`, {
method,
headers: authHeaders(isForm ? undefined : (body != null ? { 'Content-Type': 'application/json' } : undefined)),
headers: authHeaders(path, isForm ? undefined : (body != null ? { 'Content-Type': 'application/json' } : undefined)),
body: body == null ? undefined : (isForm ? body : JSON.stringify(body)),
});
const text = await res.text();
@@ -86,8 +86,8 @@ export interface CalendarSlot {
// ─── Power (liga/desliga global — kill-switch auto_reply) ──────────────────────
export const secPowerApi = {
get: () => api.get<{ enabled: boolean }>(`${BASE}/power`).then((r) => r.data),
set: (enabled: boolean) => api.post<{ enabled: boolean }>(`${BASE}/power`, { enabled }).then((r) => r.data),
get: () => api.get<SessionPower>(`${BASE}/power`).then((r) => r.data),
set: (enabled: boolean, reason = '', by = '') => api.post<{ enabled: boolean }>(`${BASE}/power`, { enabled, reason, by }).then((r) => r.data),
}
// Liga/desliga a Secretária POR SESSÃO (instância atual do /wa-inbox).
@@ -106,6 +106,21 @@ export const secSessionPowerApi = {
api.post<{ ok: boolean; enabled: boolean }>(`${BASE}/session-power`, { instance_id: instanceId, enabled, reason, by }).then((r) => r.data),
}
// Override POR CHAT (decisão do dono, precedência máxima). overridden=false → herda
// a cascata global/sessão; enabled null nesse caso. set(enabled=null) remove o override.
export interface ChatPower {
overridden: boolean
enabled: boolean | null
changed_by: string | null
changed_at: string | null
}
export const secChatPowerApi = {
get: (instanceId: string, jid: string) =>
api.get<ChatPower>(`${BASE}/chat-power`, { params: { instance_id: instanceId, jid } }).then((r) => r.data),
set: (instanceId: string, jid: string, enabled: boolean | null, by: string) =>
api.post<{ ok: boolean; overridden: boolean; enabled: boolean | null }>(`${BASE}/chat-power`, { instance_id: instanceId, jid, enabled, by }).then((r) => r.data),
}
// ─── Agents ───────────────────────────────────────────────────────────────────
export const secAgentApi = {
@@ -21,6 +21,16 @@ class SocketShim {
private listeners = new Map<string, Set<Listener>>();
private retry: ReturnType<typeof setTimeout> | null = null;
private closedByUser = false;
private currentClinica: string | null = null;
// Clínica da conta ativa da inbox (nw:inbox-account). null = conta própria.
private readClinica(): string | null {
try {
const raw = localStorage.getItem('nw:inbox-account');
if (!raw) return null;
return JSON.parse(raw)?.clinicaId ?? null;
} catch { return null; }
}
connect(_token?: string) {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
@@ -33,6 +43,11 @@ class SocketShim {
try { u = new URL(`${abs}/nw/v1/stream`); } catch { return; }
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
u.searchParams.set('token', token);
// Stream por-tenant: p/ a caixa da clínica, o backend usa ?clinica= para reescrever
// a conta-alvo (dono) quando o usuário tem can_inbox. null = conta própria.
const clinica = this.readClinica();
this.currentClinica = clinica;
if (clinica) u.searchParams.set('clinica', clinica);
let ws: WebSocket;
try { ws = new WebSocket(u.toString()); } catch { return; }
@@ -73,6 +88,20 @@ class SocketShim {
this._emit('message:update', m);
break;
}
case 'message.reaction':
// Reação recebida numa mensagem: { messageId, emoji, sender }.
this._emit('message:reaction', { messageId: data?.messageId, emoji: data?.emoji, sender: data?.sender });
break;
case 'message.media_ready':
this._emit('message:media_ready', { messageId: data?.messageId, mediaUrl: data?.mediaUrl });
break;
case 'message.transcription':
this._emit('message:transcription', { messageId: data?.messageId, transcription: data?.transcription });
break;
case 'presence':
// Presença do CONTATO (composing/recording/paused/available) por jid.
this._emit('presence:update', { jid: data?.jid, state: data?.state });
break;
case 'conversation.handoff': this._emit('handoff', data); break;
case 'conversation.escalated': this._emit('escalated', data); break;
default: break;
@@ -88,6 +117,16 @@ class SocketShim {
this.ws = null;
}
// Reabre o stream se a conta ativa (clínica) mudou. O realtime é por-tenant, então
// alternar entre a caixa própria e a da clínica exige reconectar no tenant certo.
syncAccount() {
if (this.readClinica() === this.currentClinica && this.ws) return;
if (this.retry) clearTimeout(this.retry);
try { this.ws?.close(); } catch { /* ignore */ }
this.ws = null;
this.connect();
}
// Satélite não envia comandos pelo stream (é read-only); mantidos p/ compat.
emit(_event: string, ..._args: any[]) { /* no-op */ }
joinInstance(_instanceId: string) { /* no-op — stream é tenant-wide */ }
+75 -1
View File
@@ -8,6 +8,11 @@ import { immer } from 'zustand/middleware/immer'
import { socketService } from '../services/socketService'
import { chatApi, messageApi } from '../services/chatApiService'
// Registra os listeners do socket UMA vez por sessão. Sem isto, cada mount do bridge
// (inbox do plugin ou drawer da agenda) reempilha handlers no socketService (que só
// acumula), fazendo message:new rodar N vezes → unreadCount inflado e reprocessamento.
let _chatListenersReady = false
// ─── Mapper: resposta aninhada do backend → Chat plano do store ────────────────
function mapBackendChat(raw: any): Chat {
@@ -42,6 +47,7 @@ function mapBackendChat(raw: any): Chat {
lastMessageSender: raw.lastMessage?.pushName ?? raw.lastMessageSender ?? null,
protocolNumber: raw.protocol?.number ?? raw.protocolNumber ?? null,
botPaused: raw.botPaused ?? false,
labels: Array.isArray(raw.labels) ? raw.labels : [],
}
}
@@ -71,6 +77,7 @@ export interface Chat {
lastMessageSender: string | null // pushName do remetente (grupos: quem enviou a última msg)
protocolNumber: string | null
botPaused: boolean
labels: Array<{ id: string; name: string; color: string }>
}
export interface Message {
@@ -97,6 +104,7 @@ export interface Message {
timestamp: string
pushName?: string | null
senderJid?: string | null
reactions?: Record<string, string> | null // { remetente: emoji } — 'me' = minha reação
}
// ─── Estado ───────────────────────────────────────────────────────────────────
@@ -107,8 +115,14 @@ interface ChatState {
activeChatId: string | null
messages: Record<string, Message[]>
messagesLoading: boolean
// Presença do contato por jid (composing/recording/paused/available) + timestamp.
presenceByJid: Record<string, { state: string; ts: number }>
// Assinatura do operador ("Nome") — usada para exibir "*Nome:*" na msg otimista.
operatorSignature: string | null
// Sync
setPresence: (jid: string, state: string) => void
setOperatorSignature: (sig: string | null) => void
setChats: (chats: Chat[]) => void
setChatsLoading: (v: boolean) => void
upsertChat: (chat: Chat) => void
@@ -120,12 +134,14 @@ interface ChatState {
appendMessage: (msg: Message) => void
patchMessage: (msgId: string, patch: Partial<Message>) => void
updateMediaReady: (msgId: string, mediaUrl: string) => void
reactMessage: (chatId: string, msgId: string, emoji: string, sender?: string) => void
// Async
loadChats: (instanceId: string, opts?: { search?: string; archived?: boolean }) => Promise<void>
loadMessages: (chatId: string, opts?: { before?: string }) => Promise<{ hasMore: boolean }>
selectChat: (chatId: string) => Promise<void>
sendMessage: (instanceId: string, jid: string, text: string, replyToMessageId?: string, mentions?: string[]) => Promise<void>
deleteMessage: (chatId: string, messageId: string) => Promise<void>
// Socket
initSocketListeners: () => void
@@ -195,6 +211,15 @@ export const useChatStore = create<ChatState>()(
activeChatId: null,
messages: {},
messagesLoading: false,
presenceByJid: {},
operatorSignature: null,
// Presença do contato (composing/recording/…): guarda estado + timestamp por jid.
setPresence: (jid, state) =>
set((s) => { if (jid) s.presenceByJid[jid] = { state, ts: Date.now() } }),
setOperatorSignature: (sig) =>
set((s) => { s.operatorSignature = sig }),
// ── Chats ─────────────────────────────────────────────────────────────────
@@ -262,6 +287,17 @@ export const useChatStore = create<ChatState>()(
}
}),
// Reação (otimista + eco do WS): emoji vazio remove a reação do remetente.
reactMessage: (chatId, msgId, emoji, sender = 'me') =>
set((s) => {
const list = s.messages[chatId]
const msg = list?.find((m) => m.id === msgId || m.messageId === msgId)
if (!msg) return
const r = { ...(msg.reactions || {}) }
if (emoji) r[sender] = emoji; else delete r[sender]
msg.reactions = r
}),
// ── Async ─────────────────────────────────────────────────────────────────
loadChats: async (instanceId, opts = {}) => {
@@ -350,13 +386,19 @@ export const useChatStore = create<ChatState>()(
? get().messages[chatId]?.find((m) => m.messageId === replyToMessageId) ?? null
: null
// Otimista com a assinatura "*Nome:*\n" JÁ aplicada (mesma que o proxy adiciona no
// servidor) — evita o flicker de a msg aparecer sem "Nome:" e "puxar" depois do eco.
// IMPORTANTE: envia o texto CRU (o proxy adiciona a assinatura; não duplicar aqui).
const sig = get().operatorSignature
const optimisticBody = sig ? `*${sig}:*\n${text}` : text
const optimistic: Message = {
id: `opt-${Date.now()}`,
chatId,
messageId: `opt-${Date.now()}`,
fromMe: true,
type: 'TEXT',
body: text,
body: optimisticBody,
mediaUrl: null,
mimeType: null,
fileName: null,
@@ -387,9 +429,21 @@ export const useChatStore = create<ChatState>()(
}
},
// Apaga a mensagem para todos (revoke no WhatsApp). Só remove do estado se o
// backend confirmar — o erro sobe para a UI (ex.: não é sua / instância off).
deleteMessage: async (chatId, messageId) => {
await chatApi.deleteMessage(chatId, messageId)
set((s) => {
if (s.messages[chatId]) s.messages[chatId] = s.messages[chatId].filter((m) => m.id !== messageId)
})
},
// ── Socket ────────────────────────────────────────────────────────────────
initSocketListeners: () => {
// Idempotente: só registra uma vez (evita listeners duplicados entre mounts).
if (_chatListenersReady) return
_chatListenersReady = true
// IMPORTANTE: sempre usar get() dentro dos callbacks para ler estado atual.
// Capturar `const store = get()` fora criaria uma closure stale.
@@ -423,6 +477,26 @@ export const useChatStore = create<ChatState>()(
get().patchMessage(messageId, { status })
})
socketService.on('presence:update', ({ jid, state }: { jid?: string; state?: string }) => {
if (jid && state) get().setPresence(jid, state)
})
// Reação recebida: acha a mensagem pelo messageId (WA) em qualquer chat carregado.
socketService.on('message:reaction', ({ messageId, emoji, sender }: { messageId?: string; emoji?: string; sender?: string }) => {
if (!messageId) return
set((s) => {
for (const list of Object.values(s.messages)) {
const msg = list.find((m) => m.messageId === messageId)
if (msg) {
const r = { ...(msg.reactions || {}) }
if (emoji) r[sender || 'contato'] = emoji; else delete r[sender || 'contato']
msg.reactions = r
break
}
}
})
})
socketService.on('chat:upsert', (raw: any) => {
get().upsertChat(mapBackendChat(raw))
})
+40 -5
View File
@@ -4,10 +4,13 @@
*/
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { instanceApi } from '../services/chatApiService'
import { instanceApi, accountsApi } from '../services/chatApiService'
import { socketService } from '../services/socketService'
import { notify } from './notificationStore'
// Registra os listeners do socket UMA vez por sessão (evita acúmulo entre mounts).
let _instanceListenersReady = false
export type InstanceStatus = 'DISCONNECTED' | 'CONNECTING' | 'QR_PENDING' | 'CONNECTED' | 'BANNED'
export interface Instance {
@@ -16,6 +19,15 @@ export interface Instance {
status: InstanceStatus
phone: string | null
avatar: string | null
// Metadados do seletor multi-conta (vêm de /api/nw/accounts):
role?: string | null // papel (sec_numbers): doctor/clinic/…
label?: string | null // rótulo livre ("Consultt Clinic Recepção")
area?: string | null
notes?: string | null
clinicaId?: string | null // workspace da conta (null = conta própria)
ownerEmail?: string | null
ownerNome?: string | null
own?: boolean // true = número da própria conta do usuário
}
interface InstanceState {
@@ -85,10 +97,24 @@ export const useInstanceStore = create<InstanceState>()(
loadInstances: async () => {
set((s) => { s.loading = true })
try {
const data = await instanceApi.list()
const list: Instance[] = (Array.isArray(data) ? data : (data.instances ?? [])).map((i: any) => ({
...i,
avatar: i.avatar ?? null,
// Fonte: /api/nw/accounts — inclui os números PRÓPRIOS e os LIBERADOS pelo
// dono (can_inbox), cada um com papel/rótulo. Assim o seletor da inbox mostra
// a caixa própria E a da clínica sem trocar de login.
const data = await accountsApi.list()
const list: Instance[] = data.map((a) => ({
id: a.instanceId,
name: a.name || a.phone || a.instanceId,
status: (a.status as InstanceStatus) || 'DISCONNECTED',
phone: a.phone ?? null,
avatar: a.avatar ?? null,
role: a.role ?? null,
label: a.label ?? null,
area: a.area ?? null,
notes: a.notes ?? null,
clinicaId: a.clinicaId ?? null,
ownerEmail: a.ownerEmail ?? null,
ownerNome: a.ownerNome ?? null,
own: a.own ?? true,
}))
set((s) => {
s.loading = false
@@ -104,6 +130,13 @@ export const useInstanceStore = create<InstanceState>()(
}
return fromDb
})
// Segurança: se a instância ativa persistida NÃO está na lista acessível
// (ex.: superadmin após outro login, ou acesso revogado), reseta — para
// não abrir a inbox de uma sessão que este usuário não pode ver.
if (s.activeInstanceId && !list.some((i) => i.id === s.activeInstanceId)) {
s.activeInstanceId = null
try { localStorage.removeItem('activeInstanceId') } catch { /* ignore */ }
}
})
} catch {
set((s) => { s.loading = false })
@@ -139,6 +172,8 @@ export const useInstanceStore = create<InstanceState>()(
// ── Socket ────────────────────────────────────────────────────────────────
initSocketListeners: () => {
if (_instanceListenersReady) return
_instanceListenersReady = true
socketService.on('instance:status', ({
instanceId,
status,
+16
View File
@@ -0,0 +1,16 @@
// Papéis de número (sec_numbers) — rótulo em PT + cor. Usado pelo seletor de número
// da inbox e pelo ícone (i) de legenda. Espelha o ROLE_META da Secretária, porém
// leve (sem ícones lucide), para badges e pontos coloridos.
export const ROLE_LABELS: Record<string, { label: string; color: string }> = {
secretary_virtual: { label: 'Secretária Virtual', color: '#8b5cf6' },
clinic: { label: 'Clínica / Recepção', color: '#3b82f6' },
doctor: { label: 'Dentista / Dr.', color: '#10b981' },
specialist: { label: 'Especialista', color: '#a855f7' },
manager: { label: 'Gerente', color: '#f97316' },
reserve: { label: 'Reserva / Sala', color: '#eab308' },
human_secretary: { label: 'Secretária', color: '#f43f5e' },
}
export function roleMeta(role?: string | null): { label: string; color: string } {
return (role && ROLE_LABELS[role]) || { label: 'Sem papel definido', color: '#9ca3af' }
}
+5 -19
View File
@@ -147,6 +147,7 @@ const VitrineProtetico: React.FC<{ proteticoId: string }> = ({ proteticoId }) =>
);
};
import { PageHeader } from '../../components/PageHeader.tsx';
import { SegmentedTabs } from '../../components/SegmentedTabs.tsx';
import { useToast } from '../../contexts/ToastContext.tsx';
import { useConfirm } from '../../contexts/ConfirmContext.tsx';
import { HybridBackend } from '../../services/backend.ts';
@@ -229,9 +230,8 @@ const CAT_META: Record<CatModelo, { label: string; icon: any }> = {
const inputCls = 'w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold text-gray-700 outline-none focus:border-teal-400';
const labelCls = 'text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1';
// Estilo da busca (versão nova) — rótulos title-case e campos maiores/arredondados.
const inputNew = 'w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm font-semibold text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors';
const labelNew = 'text-[11px] font-bold text-gray-500 mb-1.5 block';
// Estilo padrão de filtros — fonte única em components/filterStyles.
import { inputNew, labelNew } from '../../components/filterStyles.tsx';
// ─── Modal: enviar proposta ──────────────────────────────────────────────────
const PropostaModal: React.FC<{ prof: Profissional; onClose: () => void; onSent: () => void }> = ({ prof, onClose, onSent }) => {
@@ -705,22 +705,8 @@ export const ProfissionaisPlugin: React.FC = () => {
/>
{/* Tabs */}
<div className="flex gap-2 flex-wrap">
{TABS.filter(t => t.show).map(t => {
const Icon = t.icon;
const active = tab === t.id;
return (
<button key={t.id} onClick={() => setTab(t.id)}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider transition-all ${active ? 'text-white shadow-md' : 'bg-white text-gray-500 border border-gray-200 hover:bg-gray-50'}`}
style={active ? { backgroundColor: COLOR } : {}}>
<Icon size={14} /> {t.label}
{t.id === 'propostas' && pendentesRecebidas > 0 && (
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded-full ${active ? 'bg-white/25' : 'bg-amber-100 text-amber-700'}`}>{pendentesRecebidas}</span>
)}
</button>
);
})}
</div>
<SegmentedTabs accent={COLOR} value={tab} onChange={setTab}
tabs={TABS.filter(t => t.show).map(t => ({ id: t.id, label: t.label, icon: t.icon, badge: t.id === 'propostas' && pendentesRecebidas > 0 ? pendentesRecebidas : undefined }))} />
{/* BUSCAR — bloqueia se faltar endereço/CEP da origem */}
{tab === 'buscar' && enderecoOk === false && (
+593 -140
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
DoorOpen, MapPin, Search, RefreshCw, Plus, Pencil, Trash2, X, Save,
CalendarClock, CheckCircle2, XCircle, Clock, Building2, Briefcase, Home, Inbox, Send
CalendarClock, CheckCircle2, XCircle, Clock, Building2, Briefcase, Home, Inbox, Send, Eraser, ChevronUp, ChevronLeft, ChevronRight, FileText
} from 'lucide-react';
import { PageHeader } from '../../components/PageHeader.tsx';
import { useToast } from '../../contexts/ToastContext.tsx';
@@ -41,6 +41,7 @@ interface Sala {
valor_diaria: string | null;
valor_semanal: string | null;
valor_mensal: string | null;
contrato?: string | null;
hora_consulta?: boolean;
turno_consulta?: boolean;
diaria_consulta?: boolean;
@@ -106,8 +107,88 @@ const precos = (s: Sala): Array<[string, string]> => {
.map(([l, v]) => [l, fmtValor(v as string | null) || 'Sob consulta'] as [string, string]);
};
// ── Calendário de reserva ────────────────────────────────────────────────────
const MODELOS: Array<{ id: string; label: string; valor: keyof Sala; consulta: keyof Sala }> = [
{ id: 'hora', label: 'Hora', valor: 'valor_hora', consulta: 'hora_consulta' },
{ id: 'turno', label: 'Período', valor: 'valor_turno', consulta: 'turno_consulta' },
{ id: 'diaria', label: 'Dia', valor: 'valor_diaria', consulta: 'diaria_consulta' },
{ id: 'semanal', label: 'Semana', valor: 'valor_semanal', consulta: 'semanal_consulta' },
{ id: 'mensal', label: 'Mês', valor: 'valor_mensal', consulta: 'mensal_consulta' },
];
const TURNOS: Record<string, { label: string; ini: number; fim: number }> = {
manha: { label: 'Manhã', ini: 8, fim: 12 },
tarde: { label: 'Tarde', ini: 13, fim: 18 },
noite: { label: 'Noite', ini: 18, fim: 22 },
};
const WEEKDAYS = ['D', 'S', 'T', 'Q', 'Q', 'S', 'S'];
const MESES = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'];
const pad2 = (n: number) => String(n).padStart(2, '0');
const toLocalInput = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
const mesmoDia = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
// Células do mês (com padding p/ alinhar ao dia da semana).
const monthGrid = (ref: Date): (Date | null)[] => {
const first = new Date(ref.getFullYear(), ref.getMonth(), 1);
const dias = new Date(ref.getFullYear(), ref.getMonth() + 1, 0).getDate();
const cells: (Date | null)[] = [];
for (let i = 0; i < first.getDay(); i++) cells.push(null);
for (let d = 1; d <= dias; d++) cells.push(new Date(ref.getFullYear(), ref.getMonth(), d));
return cells;
};
// Início/fim conforme o modelo escolhido.
const computeRange = (dia: Date, modelo: string, hora: string, qtd: number, turno: string): { ini: Date; fim: Date } => {
const b = new Date(dia); b.setHours(0, 0, 0, 0);
const ini = new Date(b), fim = new Date(b);
const n = Math.max(1, qtd);
if (modelo === 'hora') {
const [h, m] = hora.split(':').map(Number); ini.setHours(h || 8, m || 0, 0, 0);
fim.setTime(ini.getTime() + n * 3600_000);
} else if (modelo === 'turno') {
const t = TURNOS[turno] || TURNOS.manha; ini.setHours(t.ini, 0, 0, 0); fim.setHours(t.fim, 0, 0, 0);
} else if (modelo === 'semanal') {
ini.setHours(8, 0, 0, 0); fim.setDate(fim.getDate() + (n * 7 - 1)); fim.setHours(18, 0, 0, 0);
} else if (modelo === 'mensal') {
ini.setHours(8, 0, 0, 0); fim.setMonth(fim.getMonth() + n); fim.setDate(fim.getDate() - 1); fim.setHours(18, 0, 0, 0);
} else { // diária — n dias consecutivos
ini.setHours(8, 0, 0, 0); fim.setDate(fim.getDate() + (n - 1)); fim.setHours(18, 0, 0, 0);
}
return { ini, fim };
};
// Quantidade máxima por modelo + rótulo da unidade.
const UNIDADE: Record<string, { sing: string; plur: string; max: number }> = {
hora: { sing: 'hora', plur: 'horas', max: 12 },
diaria: { sing: 'dia', plur: 'dias', max: 30 },
semanal: { sing: 'semana', plur: 'semanas', max: 8 },
mensal: { sing: 'mês', plur: 'meses', max: 12 },
};
// Modelo padrão de contrato de locação — o dono pode editar por sala; se em branco, vale este.
const CONTRATO_MODELO = `CONTRATO DE LOCAÇÃO DE ESPAÇO / SALA
1. OBJETO
A presente locação tem por objeto a cessão temporária do espaço/sala identificado nesta plataforma, para uso profissional durante o período reservado.
2. RESPONSABILIDADE
Todos os procedimentos, atendimentos e serviços realizados no espaço durante o período locado são de INTEIRA E EXCLUSIVA RESPONSABILIDADE DO LOCATÁRIO (quem aluga), abrangendo as esferas civil, criminal, ética, sanitária e trabalhista, perante pacientes, terceiros e órgãos de classe. O LOCADOR (dono da sala) não responde por qualquer ato praticado pelo locatário.
3. USO E CONSERVAÇÃO
O locatário compromete-se a utilizar o espaço e os equipamentos com zelo, devolvendo-os nas mesmas condições em que os recebeu. Eventuais danos serão integralmente ressarcidos pelo locatário.
4. REGULARIDADE PROFISSIONAL
O locatário declara possuir registro profissional ativo e todas as licenças e autorizações exigidas para o exercício de sua atividade, isentando o locador de qualquer responsabilidade a esse respeito.
5. PAGAMENTO
O valor e a forma de pagamento são os informados no pedido de reserva e combinados diretamente entre as partes.
6. CANCELAMENTO
Cancelamentos devem ser comunicados com antecedência razoável, conforme acordado entre as partes.
Ao aceitar, o LOCATÁRIO declara ter lido e concordado integralmente com os termos acima.`;
const inputCls = 'w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold text-gray-700 outline-none focus:border-teal-400';
const labelCls = 'text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1';
// Estilo padrão de filtros — fonte única em components/filterStyles.
import { inputNew, labelNew } from '../../components/filterStyles.tsx';
// ─── Modal: cadastrar / editar sala ──────────────────────────────────────────
const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved: () => void }> = ({ sala, onClose, onSaved }) => {
@@ -127,6 +208,7 @@ const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved:
diariaConsulta: sala?.diaria_consulta === true, semanalConsulta: sala?.semanal_consulta === true,
mensalConsulta: sala?.mensal_consulta === true,
disponivel: sala?.disponivel !== false,
contrato: sala?.contrato ?? CONTRATO_MODELO,
});
const set = (k: string, v: any) => setF(p => ({ ...p, [k]: v }));
@@ -193,6 +275,14 @@ const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved:
<label className={labelCls}>Equipamentos</label>
<input className={inputCls} value={f.equipamentos} onChange={e => set('equipamentos', e.target.value)} placeholder="CADEIRA, FOTOPOLIMERIZADOR, RX..." />
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5"><FileText size={12} /> Contrato de locação</label>
<button type="button" onClick={() => set('contrato', CONTRATO_MODELO)} className="text-[9px] font-black uppercase text-teal-600 hover:text-teal-700">Restaurar modelo</button>
</div>
<textarea className={`${inputCls} font-medium normal-case`} rows={6} value={f.contrato} onChange={e => set('contrato', e.target.value)} placeholder="Termos do contrato que o locatário deverá aceitar ao reservar..." />
<p className="text-[9px] text-gray-400 font-bold uppercase mt-1">Exibido ao locatário no passo de pagamento; ele precisa aceitar para reservar. preenchemos um modelo edite como quiser.</p>
</div>
<div className="md:col-span-2">
<label className={labelCls}>Endereço</label>
<input className={inputCls} value={f.endereco} onChange={e => set('endereco', e.target.value)} placeholder="RUA / NÚMERO / COMPLEMENTO" />
@@ -276,6 +366,53 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
const [profissionais, setProfissionais] = useState<Array<{ usuario_id: string; nome: string }>>([]);
const [profissionalId, setProfissionalId] = useState('');
// ── Calendário de reserva ──
const hoje0 = new Date(); hoje0.setHours(0, 0, 0, 0);
const [mesRef, setMesRef] = useState(new Date(hoje0.getFullYear(), hoje0.getMonth(), 1));
const [diaSel, setDiaSel] = useState<Date | null>(null);
const [diasMulti, setDiasMulti] = useState<Date[]>([]); // modelo "Dia": dias avulsos (1, 5, 20…)
const [hora, setHora] = useState('08:00');
const [qtd, setQtd] = useState(1);
const [turno, setTurno] = useState('manha');
const HORAS = Array.from({ length: 29 }, (_, i) => `${pad2(7 + Math.floor(i / 2))}:${i % 2 ? '30' : '00'}`); // 07:0021:00
const modelosDisp = MODELOS.filter(m => fmtValor(sala[m.valor] as any) || sala[m.consulta]);
const diasOcupados = new Set(ocupadas.map(o => new Date(o.inicio).toDateString()));
const ocupadasDoDia = diaSel ? ocupadas.filter(o => new Date(o.inicio).toDateString() === diaSel.toDateString()) : [];
const rangeFim = diaSel ? computeRange(diaSel, f.modelo, hora, qtd, turno).fim : null;
const diasMultiSorted = [...diasMulti].sort((a, b) => a.getTime() - b.getTime());
const temSelecao = f.modelo === 'diaria' ? diasMulti.length > 0 : !!diaSel;
// ── Wizard (4 passos, formato slide) ──
const [step, setStep] = useState(0);
const [formaPagamento, setFormaPagamento] = useState('pix');
const [showContrato, setShowContrato] = useState(false);
const [contratoAceito, setContratoAceito] = useState(false);
const contratoTexto = (sala.contrato && sala.contrato.trim()) || CONTRATO_MODELO;
const STEPS = ['Dados', 'Calendário', 'Resumo', 'Pagamento'];
const FORMAS: Record<string, string> = { pix: 'PIX', dinheiro: 'Dinheiro', transferencia: 'Transferência', cartao: 'Cartão' };
const modeloLabel = MODELOS.find(m => m.id === f.modelo)?.label || '';
const total = (() => {
const m = MODELOS.find(x => x.id === f.modelo); const v = m ? Number(sala[m.valor]) : NaN;
if (!Number.isFinite(v)) return 0;
if (f.modelo === 'turno') return v;
if (f.modelo === 'diaria') return v * diasMulti.length;
return v * Math.max(1, qtd);
})();
// Modelo padrão = 1º disponível.
useEffect(() => { if (modelosDisp.length && !modelosDisp.some(m => m.id === f.modelo)) setF(p => ({ ...p, modelo: modelosDisp[0].id })); /* eslint-disable-next-line */ }, []);
// Recalcula início/fim conforme dia + modelo + horário.
useEffect(() => {
if (!diaSel) return;
const { ini, fim } = computeRange(diaSel, f.modelo, hora, qtd, turno);
setF(p => ({ ...p, inicio: toLocalInput(ini), fim: toLocalInput(fim) }));
}, [diaSel, f.modelo, hora, qtd, turno]);
// Ao trocar de modelo, volta a quantidade para 1 e limpa a multi-seleção de dias.
useEffect(() => { setQtd(1); setDiasMulti([]); }, [f.modelo]);
// Depois de escolher o(s) dia(s), revela os controles (que ficam abaixo do calendário).
const ctrlRef = useRef<HTMLDivElement>(null);
useEffect(() => { if (diaSel || diasMulti.length) ctrlRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, [diaSel, diasMulti.length, f.modelo]);
useEffect(() => {
apiFetch(`/api/salas/${sala.id}/reservas`)
.then(r => (r.ok ? r.json() : []))
@@ -293,20 +430,32 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
}, [comoUnidade]);
const handleReserve = async () => {
if (!f.inicio || !f.fim) { toast.error('INFORME INÍCIO E FIM.'); return; }
// Modelo "Dia" → uma reserva por dia avulso selecionado; demais → um único intervalo.
const ranges = f.modelo === 'diaria'
? diasMultiSorted.map(d => computeRange(d, 'diaria', hora, 1, turno))
: (f.inicio && f.fim ? [{ ini: new Date(f.inicio), fim: new Date(f.fim) }] : []);
if (!ranges.length) { toast.error(f.modelo === 'diaria' ? 'SELECIONE AO MENOS UM DIA.' : 'INFORME INÍCIO E FIM.'); return; }
if (!contratoAceito) { toast.error('É PRECISO ACEITAR O CONTRATO DE LOCAÇÃO.'); return; }
setSaving(true);
try {
const res = await apiFetch(`/api/salas/${sala.id}/reservas`, {
method: 'POST',
body: JSON.stringify({
inicio: new Date(f.inicio).toISOString(), fim: new Date(f.fim).toISOString(), modelo: f.modelo, observacoes: f.observacoes || null,
clinicaId: comoUnidade && ws?.id ? ws.id : null,
profissionalUsuarioId: comoUnidade && profissionalId ? profissionalId : null,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Erro ao reservar.');
toast.success('RESERVA SOLICITADA! AGUARDE A CONFIRMAÇÃO DO LOCADOR.');
const obs = [f.observacoes, `Forma de pagamento: ${FORMAS[formaPagamento] || formaPagamento}`, 'Contrato de locação: aceito pelo locatário'].filter(Boolean).join(' · ') || null;
const base = {
modelo: f.modelo, observacoes: obs,
clinicaId: comoUnidade && ws?.id ? ws.id : null,
profissionalUsuarioId: comoUnidade && profissionalId ? profissionalId : null,
};
let ok = 0; const erros: string[] = [];
for (const r of ranges) {
const res = await apiFetch(`/api/salas/${sala.id}/reservas`, {
method: 'POST',
body: JSON.stringify({ ...base, inicio: r.ini.toISOString(), fim: r.fim.toISOString() }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) ok++; else erros.push(data.error || 'erro');
}
if (!ok) throw new Error(erros[0] || 'Erro ao reservar.');
toast.success(ranges.length > 1 ? `${ok} RESERVAS SOLICITADAS! AGUARDE A CONFIRMAÇÃO DO LOCADOR.` : 'RESERVA SOLICITADA! AGUARDE A CONFIRMAÇÃO DO LOCADOR.');
if (erros.length) toast.error(`${erros.length} DIA(S) NÃO PUDERAM SER RESERVADOS (JÁ OCUPADOS?).`);
onReserved();
onClose();
} catch (e: any) { toast.error(e.message.toUpperCase()); }
@@ -315,81 +464,331 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl overflow-hidden max-h-[92vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div>
<h3 className="text-sm font-black text-gray-900 uppercase">RESERVAR {sala.nome}</h3>
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: COLOR }}>{[sala.cidade, sala.estado].filter(Boolean).join(' · ')}</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
</div>
<div className="p-6 space-y-4">
{ws?.id && (
<div className="bg-white rounded-[1.5rem] w-full max-w-lg shadow-2xl overflow-hidden max-h-[92vh] flex flex-col">
{/* Header + stepper */}
<div className="px-6 pt-5 pb-4 border-b border-gray-100 shrink-0">
<div className="flex items-center justify-between">
<div>
<label className={labelCls}>Reservar como</label>
<div className="flex gap-2">
{[{ v: false, l: 'PESSOAL' }, { v: true, l: ws.nome?.toUpperCase() || 'UNIDADE' }].map(o => (
<button key={String(o.v)} type="button" onClick={() => setComoUnidade(o.v)}
className={`flex-1 py-2.5 rounded-xl text-[10px] font-black uppercase border transition-all truncate px-2 ${comoUnidade === o.v ? 'text-white border-transparent' : 'bg-gray-50 text-gray-500 border-gray-200 hover:bg-gray-100'}`}
style={comoUnidade === o.v ? { backgroundColor: COLOR } : {}}>
{o.l}
</button>
))}
<h3 className="text-sm font-black text-gray-900 uppercase">Reservar {sala.nome}</h3>
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: COLOR }}>{[sala.cidade, sala.estado].filter(Boolean).join(' · ')}</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
</div>
<div className="flex items-center gap-2 mt-4">
{STEPS.map((s, i) => (
<button key={s} type="button" onClick={() => i < step && setStep(i)} className="flex-1 flex flex-col items-center gap-1.5">
<div className={`w-full h-1.5 rounded-full transition-colors ${i <= step ? '' : 'bg-gray-100'}`} style={i <= step ? { backgroundColor: COLOR } : {}} />
<span className={`text-[8px] font-black uppercase tracking-wide ${i === step ? 'text-gray-700' : 'text-gray-300'}`}>{s}</span>
</button>
))}
</div>
</div>
{/* Slides */}
<div className="flex-1 overflow-hidden min-h-[340px]">
<div className="flex h-full transition-transform duration-300 ease-out" style={{ transform: `translateX(-${step * 100}%)` }}>
{/* PASSO 1 — Dados */}
<div className="w-full shrink-0 overflow-y-auto p-6 space-y-4">
{ws?.id && (
<div>
<label className={labelCls}>Reservar como</label>
<div className="flex gap-2">
{[{ v: false, l: 'PESSOAL' }, { v: true, l: ws.nome?.toUpperCase() || 'UNIDADE' }].map(o => (
<button key={String(o.v)} type="button" onClick={() => setComoUnidade(o.v)}
className={`flex-1 py-2.5 rounded-xl text-[10px] font-black uppercase border transition-all truncate px-2 ${comoUnidade === o.v ? 'text-white border-transparent' : 'bg-gray-50 text-gray-500 border-gray-200 hover:bg-gray-100'}`}
style={comoUnidade === o.v ? { backgroundColor: COLOR } : {}}>{o.l}</button>
))}
</div>
</div>
)}
{comoUnidade && (
<div>
<label className={labelCls}>Profissional que vai usar a sala (opcional)</label>
<select className={inputCls} value={profissionalId} onChange={e => setProfissionalId(e.target.value)}>
<option value="">EU MESMO ({HybridBackend.getCurrentUserName()?.toUpperCase() || 'SOLICITANTE'})</option>
{profissionais.map(p => <option key={p.usuario_id} value={p.usuario_id}>{p.nome?.toUpperCase()}</option>)}
</select>
<p className="text-[9px] text-gray-400 font-bold uppercase mt-1">O anti-conflito verifica a agenda do profissional selecionado</p>
</div>
)}
<div>
<label className={labelCls}>Modelo de locação</label>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2 mt-1">
{(modelosDisp.length ? modelosDisp : [MODELOS[0]]).map(m => {
const ativo = f.modelo === m.id;
const preco = fmtValor(sala[m.valor] as any) || (sala[m.consulta] ? 'sob consulta' : '—');
return (
<button key={m.id} type="button" onClick={() => setF(p => ({ ...p, modelo: m.id }))}
className={`p-2.5 rounded-xl border text-center transition-all ${ativo ? 'text-white border-transparent shadow-sm' : 'bg-gray-50 text-gray-600 border-gray-200 hover:bg-gray-100'}`}
style={ativo ? { backgroundColor: COLOR } : {}}>
<div className="text-[11px] font-black uppercase leading-none">{m.label}</div>
<div className={`text-[9px] font-bold mt-1 leading-none ${ativo ? 'text-white/80' : 'text-gray-400'}`}>{preco}</div>
</button>
);
})}
</div>
</div>
<div>
<label className={labelCls}>Observações</label>
<textarea className={inputCls} rows={4} value={f.observacoes} onChange={e => setF(p => ({ ...p, observacoes: e.target.value }))} placeholder="Alguma informação para o locador…" />
</div>
</div>
)}
{comoUnidade && (
<div>
<label className={labelCls}>Profissional que vai usar a sala (opcional)</label>
<select className={inputCls} value={profissionalId} onChange={e => setProfissionalId(e.target.value)}>
<option value="">EU MESMO ({HybridBackend.getCurrentUserName()?.toUpperCase() || 'SOLICITANTE'})</option>
{profissionais.map(p => <option key={p.usuario_id} value={p.usuario_id}>{p.nome?.toUpperCase()}</option>)}
</select>
<p className="text-[9px] text-gray-400 font-bold uppercase mt-1">O anti-conflito verifica a agenda do profissional selecionado</p>
{/* PASSO 2 — Calendário */}
<div className="w-full shrink-0 overflow-y-auto p-6 space-y-4">
<div className="rounded-2xl border border-gray-100 p-4">
<div className="flex items-center justify-between mb-3">
<button type="button" onClick={() => setMesRef(m => new Date(m.getFullYear(), m.getMonth() - 1, 1))} className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-500"><ChevronLeft size={18} /></button>
<span className="text-xs font-black text-gray-800 uppercase tracking-wide">{MESES[mesRef.getMonth()]} {mesRef.getFullYear()}</span>
<button type="button" onClick={() => setMesRef(m => new Date(m.getFullYear(), m.getMonth() + 1, 1))} className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-500"><ChevronRight size={18} /></button>
</div>
<div className="grid grid-cols-7 gap-1 mb-1">
{WEEKDAYS.map((w, i) => <div key={i} className="text-center text-[9px] font-black text-gray-300 uppercase py-1">{w}</div>)}
</div>
<div className="grid grid-cols-7 gap-1">
{monthGrid(mesRef).map((dia, i) => {
if (!dia) return <div key={i} />;
const passado = dia < hoje0;
const sel = f.modelo === 'diaria'
? diasMulti.some(d => mesmoDia(d, dia))
: !!(diaSel && mesmoDia(dia, diaSel));
const emRange = !!(diaSel && rangeFim && (f.modelo === 'semanal' || f.modelo === 'mensal') && dia > diaSel && dia <= rangeFim);
const ocupado = diasOcupados.has(dia.toDateString());
const onPick = () => {
if (f.modelo === 'diaria') {
setDiasMulti(prev => prev.some(d => mesmoDia(d, dia)) ? prev.filter(d => !mesmoDia(d, dia)) : [...prev, dia]);
} else {
setDiaSel(dia);
}
};
return (
<button key={i} type="button" disabled={passado} onClick={onPick}
className={`relative h-9 rounded-lg text-xs font-bold flex items-center justify-center transition-all ${passado ? 'text-gray-200 cursor-not-allowed' : sel ? 'text-white shadow' : emRange ? 'text-teal-700' : 'text-gray-600 hover:bg-gray-100'}`}
style={sel ? { backgroundColor: COLOR } : emRange ? { backgroundColor: `${COLOR}22` } : {}}>
{dia.getDate()}
{ocupado && !sel && <span className="absolute bottom-1 w-1 h-1 rounded-full bg-amber-400" />}
</button>
);
})}
</div>
</div>
{temSelecao && (
<div ref={ctrlRef} className="rounded-2xl border-2 p-4 space-y-3" style={{ borderColor: `${COLOR}33`, backgroundColor: `${COLOR}0a` }}>
<div className="flex items-center justify-between">
<div>
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Selecionado</p>
<p className="text-xs font-black text-gray-800">
{f.modelo === 'diaria'
? `${diasMulti.length} ${diasMulti.length === 1 ? 'dia' : 'dias'}`
: `${diaSel?.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}${(f.modelo === 'semanal' || f.modelo === 'mensal') && rangeFim ? `${rangeFim.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}` : ''}`}
</p>
</div>
<span className="text-[10px] font-black uppercase px-2.5 py-1 rounded-full" style={{ backgroundColor: `${COLOR}18`, color: COLOR }}>{modeloLabel}</span>
</div>
{f.modelo === 'hora' && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Início</label>
<select className={inputCls} value={hora} onChange={e => setHora(e.target.value)}>
{HORAS.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</div>
<div>
<label className={labelCls}>Duração</label>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setQtd(q => Math.max(1, q - 1))} className="w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 font-black text-lg leading-none"></button>
<span className="flex-1 text-center text-sm font-black text-gray-800">{qtd}h</span>
<button type="button" onClick={() => setQtd(q => Math.min(12, q + 1))} className="w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 font-black text-lg leading-none">+</button>
</div>
</div>
</div>
)}
{f.modelo === 'turno' && (
<div>
<label className={labelCls}>Período</label>
<div className="grid grid-cols-3 gap-2">
{Object.entries(TURNOS).map(([k, t]) => (
<button key={k} type="button" onClick={() => setTurno(k)}
className={`py-2 rounded-xl text-[11px] font-black uppercase border transition-all ${turno === k ? 'text-white border-transparent' : 'bg-white text-gray-500 border-gray-200 hover:bg-gray-100'}`}
style={turno === k ? { backgroundColor: COLOR } : {}}>
{t.label}<div className="text-[8px] font-bold opacity-70">{pad2(t.ini)}h{pad2(t.fim)}h</div>
</button>
))}
</div>
</div>
)}
{(f.modelo === 'semanal' || f.modelo === 'mensal') && (
<div>
<label className={labelCls}>Quantidade de {UNIDADE[f.modelo]?.plur}</label>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setQtd(q => Math.max(1, q - 1))} className="w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 font-black text-lg leading-none"></button>
<span className="flex-1 text-center text-sm font-black text-gray-800">{qtd} {qtd === 1 ? UNIDADE[f.modelo]?.sing : UNIDADE[f.modelo]?.plur}</span>
<button type="button" onClick={() => setQtd(q => Math.min(UNIDADE[f.modelo]?.max || 12, q + 1))} className="w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 font-black text-lg leading-none">+</button>
</div>
</div>
)}
{f.modelo === 'diaria' && (
<div>
<label className={labelCls}>Dias selecionados ({diasMulti.length})</label>
<div className="flex flex-wrap gap-1.5">
{diasMultiSorted.map((d, i) => {
const ocup = diasOcupados.has(d.toDateString());
return (
<button key={i} type="button" onClick={() => setDiasMulti(prev => prev.filter(x => !mesmoDia(x, d)))}
className="flex items-center gap-1 pl-2.5 pr-1.5 py-1 rounded-full text-[10px] font-black text-white" style={{ backgroundColor: ocup ? '#d97706' : COLOR }}>
{d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}
<X size={11} />
</button>
);
})}
</div>
<p className="text-[9px] font-bold text-gray-400 uppercase mt-1.5">Toque no calendário para adicionar/remover dias · cada dia vira uma reserva (08h18h)</p>
</div>
)}
{ocupadasDoDia.length > 0 && (
<div className="p-3 rounded-xl bg-amber-50 border border-amber-100">
<p className="text-[9px] font-black text-amber-600 uppercase tracking-widest mb-1.5 flex items-center gap-1"><Clock size={11} /> ocupado neste dia</p>
<div className="space-y-1 max-h-24 overflow-y-auto">
{ocupadasDoDia.map(o => (
<p key={o.id} className="text-[10px] font-bold text-amber-700">
{new Date(o.inicio).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}{new Date(o.fim).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })} ({o.status})
</p>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Início *</label>
<input type="datetime-local" className={inputCls} value={f.inicio} onChange={e => setF(p => ({ ...p, inicio: e.target.value }))} />
</div>
<div>
<label className={labelCls}>Fim *</label>
<input type="datetime-local" className={inputCls} value={f.fim} onChange={e => setF(p => ({ ...p, fim: e.target.value }))} />
</div>
</div>
<div>
<label className={labelCls}>Modelo de locação</label>
<select className={inputCls} value={f.modelo} onChange={e => setF(p => ({ ...p, modelo: e.target.value }))}>
{precos(sala).length === 0 && <option value="hora">HORA</option>}
{precos(sala).map(([l]) => {
const map: Record<string, string> = { HORA: 'hora', TURNO: 'turno', 'DIÁRIA': 'diaria', SEMANA: 'semanal', 'MÊS': 'mensal' };
return <option key={l} value={map[l]}>{l}</option>;
})}
</select>
</div>
<div>
<label className={labelCls}>Observações</label>
<textarea className={inputCls} rows={2} value={f.observacoes} onChange={e => setF(p => ({ ...p, observacoes: e.target.value }))} />
</div>
{ocupadas.length > 0 && (
<div className="p-3 rounded-xl bg-amber-50 border border-amber-100">
<p className="text-[9px] font-black text-amber-600 uppercase tracking-widest mb-1.5 flex items-center gap-1"><Clock size={11} /> Horários ocupados</p>
<div className="space-y-1 max-h-28 overflow-y-auto">
{ocupadas.map(o => (
<p key={o.id} className="text-[10px] font-bold text-amber-700">{fmtData(o.inicio)} {fmtData(o.fim)} ({o.status})</p>
{/* PASSO 3 — Resumo detalhado + históricos */}
<div className="w-full shrink-0 overflow-y-auto p-6 space-y-4">
<div className="rounded-2xl border border-gray-100 overflow-hidden">
{([
['Sala', sala.nome],
['Local', [sala.cidade, sala.estado].filter(Boolean).join(' · ') || '—'],
['Modelo', modeloLabel],
...(f.modelo === 'diaria'
? [
['Quantidade', `${diasMulti.length} ${diasMulti.length === 1 ? 'dia' : 'dias'}`],
['Dias', diasMultiSorted.map(d => d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })).join(', ') || '—'],
]
: [
...(UNIDADE[f.modelo] ? [['Quantidade', `${qtd} ${qtd === 1 ? UNIDADE[f.modelo].sing : UNIDADE[f.modelo].plur}`]] : []),
['Início', f.inicio ? fmtData(new Date(f.inicio).toISOString()) : '—'],
['Fim', f.fim ? fmtData(new Date(f.fim).toISOString()) : '—'],
]) as Array<[string, string]>,
['Reservar como', comoUnidade ? (ws?.nome || 'Unidade') : 'Pessoal'],
] as Array<[string, string]>).map(([k, v]) => (
<div key={k} className="flex items-center justify-between px-4 py-2.5 border-b border-gray-50 last:border-0">
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{k}</span>
<span className="text-xs font-bold text-gray-800 text-right max-w-[62%] truncate">{v}</span>
</div>
))}
<div className="flex items-center justify-between px-4 py-3 bg-teal-50/60">
<span className="text-[10px] font-black text-teal-600 uppercase tracking-widest">Total estimado</span>
<span className="text-base font-black text-teal-700">{total > 0 ? fmtValor(String(total)) : 'Sob consulta'}</span>
</div>
</div>
<div>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1"><Clock size={11} /> Histórico de reservas desta sala</p>
{ocupadas.length === 0 ? (
<p className="text-[11px] text-gray-300 font-bold uppercase py-2">Sem reservas registradas</p>
) : (
<div className="space-y-1.5 max-h-44 overflow-y-auto">
{ocupadas.slice().sort((a, b) => new Date(b.inicio).getTime() - new Date(a.inicio).getTime()).map(o => (
<div key={o.id} className="flex items-center justify-between text-[11px] bg-gray-50 rounded-lg px-3 py-1.5">
<span className="font-bold text-gray-600">{fmtData(o.inicio)} {new Date(o.fim).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}</span>
<span className={`text-[9px] font-black uppercase px-1.5 py-0.5 rounded-full ${o.status === 'confirmada' ? 'bg-emerald-100 text-emerald-700' : o.status === 'pendente' ? 'bg-amber-100 text-amber-700' : 'bg-gray-200 text-gray-500'}`}>{o.status}</span>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* PASSO 4 — Pagamento */}
<div className="w-full shrink-0 overflow-y-auto p-6 space-y-4">
<div className="rounded-2xl bg-gradient-to-br from-teal-50 to-white border border-teal-100 p-5 text-center">
<p className="text-[10px] font-black text-teal-600 uppercase tracking-widest">Total{modeloLabel ? ` · ${modeloLabel}` : ''}</p>
<p className="text-3xl font-black text-teal-700 mt-1">{total > 0 ? fmtValor(String(total)) : 'Sob consulta'}</p>
{total > 0 && f.modelo === 'diaria' && diasMulti.length > 1 && <p className="text-[10px] text-teal-500 font-bold mt-1">{diasMulti.length} dias × {fmtValor(String(Number(sala.valor_diaria)))}</p>}
{total > 0 && f.modelo !== 'diaria' && UNIDADE[f.modelo] && qtd > 1 && <p className="text-[10px] text-teal-500 font-bold mt-1">{qtd} {UNIDADE[f.modelo].plur} × {fmtValor(String(Number(sala[MODELOS.find(m => m.id === f.modelo)!.valor])))}</p>}
</div>
<div>
<label className={labelCls}>Forma de pagamento</label>
<div className="grid grid-cols-2 gap-2 mt-1">
{Object.entries(FORMAS).map(([k, l]) => (
<button key={k} type="button" onClick={() => setFormaPagamento(k)}
className={`py-2.5 rounded-xl text-[11px] font-black uppercase border transition-all ${formaPagamento === k ? 'text-white border-transparent' : 'bg-gray-50 text-gray-500 border-gray-200 hover:bg-gray-100'}`}
style={formaPagamento === k ? { backgroundColor: COLOR } : {}}>{l}</button>
))}
</div>
</div>
<div>
<label className={labelCls}>Contrato de locação</label>
<button type="button" onClick={() => setShowContrato(true)}
className="w-full flex items-center justify-between rounded-xl border p-3.5 transition-colors hover:bg-gray-50"
style={{ borderColor: contratoAceito ? '#16a34a55' : `${COLOR}44`, backgroundColor: contratoAceito ? '#16a34a0d' : `${COLOR}0a` }}>
<span className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase"><FileText size={15} style={{ color: contratoAceito ? '#16a34a' : COLOR }} /> Ler contrato de locação</span>
<span className="text-[10px] font-black uppercase flex items-center gap-1" style={{ color: contratoAceito ? '#16a34a' : COLOR }}>
{contratoAceito ? <><CheckCircle2 size={13} /> Aceito</> : 'Abrir'}
</span>
</button>
<label className="flex items-start gap-2 mt-2 px-1 cursor-pointer">
<input type="checkbox" checked={contratoAceito} onChange={e => setContratoAceito(e.target.checked)} className="mt-0.5 w-4 h-4 accent-teal-600 shrink-0" />
<span className="text-[11px] font-bold text-gray-600 leading-snug">Li e concordo com o contrato de locação os procedimentos realizados são de minha inteira responsabilidade.</span>
</label>
</div>
<div className="rounded-xl bg-gray-50 border border-gray-100 p-3.5 text-[11px] text-gray-500 leading-relaxed">
<span className="font-black text-gray-600 uppercase">Como funciona: </span>ao solicitar, o locador recebe seu pedido e confirma. O pagamento é combinado diretamente com ele após a confirmação sua preferência ({FORMAS[formaPagamento]}) vai junto no pedido.
</div>
</div>
</div>
</div>
<div className="px-6 pb-6 flex gap-3">
<button onClick={onClose} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">CANCELAR</button>
<button onClick={handleReserve} disabled={saving} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-colors flex items-center justify-center gap-2 disabled:opacity-60" style={{ backgroundColor: COLOR }}>
{saving ? <RefreshCw size={14} className="animate-spin" /> : <CalendarClock size={14} />} SOLICITAR RESERVA
{/* Footer nav */}
<div className="px-6 py-4 border-t border-gray-100 flex gap-3 shrink-0">
<button onClick={() => (step === 0 ? onClose() : setStep(s => s - 1))} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">
{step === 0 ? 'Cancelar' : 'Voltar'}
</button>
{step < 3 ? (
<button onClick={() => setStep(s => s + 1)} disabled={step === 1 && !temSelecao}
className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-opacity hover:opacity-90 disabled:opacity-40 flex items-center justify-center gap-2" style={{ backgroundColor: COLOR }}>
Próximo <ChevronRight size={14} />
</button>
) : (
<button onClick={handleReserve} disabled={saving || !temSelecao || !contratoAceito}
className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-opacity hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2" style={{ backgroundColor: COLOR }}>
{saving ? <RefreshCw size={14} className="animate-spin" /> : <CalendarClock size={14} />} Solicitar Reserva
</button>
)}
</div>
</div>
{/* Modal: contrato de locação */}
{showContrato && (
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowContrato(false)}>
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl max-h-[85vh] flex flex-col overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between shrink-0">
<h4 className="text-sm font-black text-gray-900 uppercase flex items-center gap-2"><FileText size={16} style={{ color: COLOR }} /> Contrato de locação</h4>
<button onClick={() => setShowContrato(false)} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-500"><X size={16} /></button>
</div>
<div className="p-5 overflow-y-auto text-[11px] text-gray-600 leading-relaxed whitespace-pre-wrap font-medium">{contratoTexto}</div>
<div className="p-4 border-t border-gray-100 shrink-0 flex gap-2">
<button onClick={() => setShowContrato(false)} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50">Fechar</button>
<button onClick={() => { setContratoAceito(true); setShowContrato(false); }} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase hover:opacity-90 flex items-center justify-center gap-2" style={{ backgroundColor: COLOR }}><CheckCircle2 size={14} /> Li e concordo</button>
</div>
</div>
</div>
)}
</div>
);
};
@@ -516,8 +915,15 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
const [fCep, setFCep] = useState('');
const [fRaio, setFRaio] = useState('');
const [fArea, setFArea] = useState('');
const [fBusca, setFBusca] = useState('');
const [showFiltros, setShowFiltros] = useState(true);
const [areas, setAreas] = useState<{ slug: string; nome: string }[]>([]);
useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []);
const limparFiltros = () => { setFTipo(''); setFEstado(''); setFCidade(''); setFCep(''); setFRaio(''); setFArea(''); setFBusca(''); };
// Busca por texto (nome/cidade/bairro) aplicada sobre o resultado do backend.
const salasFiltradas = fBusca.trim()
? salas.filter(s => `${s.nome} ${(s as any).cidade || ''} ${(s as any).bairro || ''}`.toLowerCase().includes(fBusca.trim().toLowerCase()))
: salas;
// minhas
const [minhas, setMinhas] = useState<Sala[]>([]);
// reservas
@@ -588,6 +994,11 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
finally { setLoading(false); }
}, []);
// Trocar de rota (/alugar-salas ↔ /minhas-salas) reaproveita este componente e
// só muda a prop `view` — o tab do useState não reage sozinho. Reseta a aba p/ a
// do contexto novo (senão a URL muda mas o conteúdo continua na aba anterior).
useEffect(() => { setTab(view === 'minhas' ? 'minhas' : 'marketplace'); }, [view]);
useEffect(() => {
if (tab === 'marketplace') fetchMarketplace();
if (tab === 'minhas') fetchMinhas();
@@ -641,23 +1052,26 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
: 'Alugue salas equipadas ou disponibilize as suas para outros profissionais.'}
/>
{/* Tabs */}
<div className="flex gap-2 flex-wrap">
{TABS.map(t => {
const Icon = t.icon;
const active = tab === t.id;
return (
<button key={t.id} onClick={() => setTab(t.id)}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider transition-all ${active ? 'text-white shadow-md' : 'bg-white text-gray-500 border border-gray-200 hover:bg-gray-50'}`}
style={active ? { backgroundColor: COLOR } : {}}>
<Icon size={14} /> {t.label}
{t.id === 'reservas' && pendentesRecebidas > 0 && (
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded-full ${active ? 'bg-white/25' : 'bg-amber-100 text-amber-700'}`}>{pendentesRecebidas}</span>
)}
</button>
);
})}
</div>
{/* Tabs segmented control: no mobile ocupa a largura toda e distribui igual;
no desktop encolhe ao conteúdo. */}
{TABS.length > 1 && (
<div className="flex gap-1 bg-gray-100/80 p-1 rounded-2xl w-full sm:w-fit">
{TABS.map(t => {
const Icon = t.icon;
const active = tab === t.id;
return (
<button key={t.id} onClick={() => setTab(t.id)}
className={`flex-1 sm:flex-none flex items-center justify-center gap-2 px-3 sm:px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider transition-all ${active ? 'bg-white shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
style={active ? { color: COLOR } : {}}>
<Icon size={14} className="shrink-0" /> <span className="truncate">{t.label}</span>
{t.id === 'reservas' && pendentesRecebidas > 0 && (
<span className="text-[9px] font-black px-1.5 py-0.5 rounded-full text-white" style={{ backgroundColor: COLOR }}>{pendentesRecebidas}</span>
)}
</button>
);
})}
</div>
)}
{/* MARKETPLACE */}
{tab === 'marketplace' && enderecoOk === false && (
@@ -666,58 +1080,92 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
{tab === 'marketplace' && enderecoOk !== false && (
<>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-4">
<div>
<label className={labelCls}>Área</label>
<select className={inputCls} value={fArea} onChange={e => setFArea(e.target.value)}>
<option value="">Todas</option>
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
</select>
{/* Busca por nome/cidade/bairro (sobre o resultado) */}
<div className="relative mb-5">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
<input
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-200 rounded-2xl text-sm font-medium text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors"
value={fBusca} onChange={e => setFBusca(e.target.value)}
placeholder="Buscar sala por nome, cidade ou bairro..." />
</div>
{showFiltros && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className={labelNew}>Área</label>
<select className={inputNew} value={fArea} onChange={e => setFArea(e.target.value)}>
<option value="">Todas</option>
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
</select>
</div>
<div>
<label className={labelNew}>Tipo</label>
<select className={inputNew} value={fTipo} onChange={e => setFTipo(e.target.value)}>
<option value="">Todos</option>
<option value="clinica">Sala Clínica</option>
<option value="consultorio">Sala Consultório</option>
<option value="independente">Sala Independente</option>
</select>
</div>
<div>
<label className={labelNew}>Estado</label>
<select className={inputNew} value={fEstado} onChange={e => setFEstado(e.target.value)}>
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'Todos'}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:items-end">
<div>
<label className={labelNew}>Cidade</label>
<input className={`${inputNew} uppercase`} value={fCidade} onChange={e => setFCidade(e.target.value.toUpperCase())} placeholder="CAMPO GRANDE" />
</div>
<div>
<label className={labelNew}>Meu CEP</label>
<input className={inputNew} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="79000-000" />
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className={`${labelNew} mb-0`}>Raio de Busca</label>
<span className="text-[10px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700">{fRaio || 0} KM</span>
</div>
<input type="range" min="0" max="200" step="5" value={fRaio || 0}
onChange={e => setFRaio(e.target.value === '0' ? '' : e.target.value)}
className="w-full accent-teal-600 cursor-pointer" />
{!fCep && <p className="text-[10px] text-gray-400 mt-1">Informe o CEP para filtrar por raio.</p>}
</div>
</div>
</div>
<div>
<label className={labelCls}>Tipo</label>
<select className={inputCls} value={fTipo} onChange={e => setFTipo(e.target.value)}>
<option value="">Todos</option>
<option value="clinica">SALA CLÍNICA</option>
<option value="consultorio">SALA CONSULTÓRIO</option>
<option value="independente">SALA INDEPENDENTE</option>
</select>
</div>
<div>
<label className={labelCls}>Estado</label>
<select className={inputCls} value={fEstado} onChange={e => setFEstado(e.target.value)}>
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'Todos os estados'}</option>)}
</select>
</div>
<div>
<label className={labelCls}>Cidade</label>
<input className={`${inputCls} uppercase`} value={fCidade} onChange={e => setFCidade(e.target.value.toUpperCase())} placeholder="CAMPO GRANDE" />
</div>
<div>
<label className={labelCls}>Meu CEP (busca por raio)</label>
<input className={inputCls} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="00000-000" />
</div>
<div>
<label className={labelCls}>Raio (km)</label>
<input type="number" min="1" className={inputCls} value={fRaio} onChange={e => setFRaio(e.target.value)} placeholder="10" />
)}
<div className="flex items-center justify-between mt-5 pt-4 border-t border-gray-50">
<button onClick={() => setShowFiltros(s => !s)}
className="flex items-center gap-1.5 text-xs font-bold text-gray-500 hover:text-gray-700 transition-colors">
<ChevronUp size={14} className={`transition-transform ${showFiltros ? '' : 'rotate-180'}`} />
{showFiltros ? 'Ocultar Filtros' : 'Mostrar Filtros'}
</button>
<div className="flex items-center gap-4">
<button onClick={limparFiltros} className="flex items-center gap-1.5 text-xs font-bold text-gray-500 hover:text-gray-700 transition-colors">
<Eraser size={13} /> Limpar Filtros
</button>
<button onClick={fetchMarketplace} disabled={loading}
className="flex items-center gap-2 px-6 py-3 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-opacity disabled:opacity-60 hover:opacity-90" style={{ backgroundColor: COLOR }}>
{loading ? <RefreshCw size={14} className="animate-spin" /> : <Search size={14} />} Buscar
</button>
</div>
</div>
<button onClick={fetchMarketplace} disabled={loading}
className="flex items-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-colors disabled:opacity-60 hover:opacity-90" style={{ backgroundColor: COLOR }}>
{loading ? <RefreshCw size={14} className="animate-spin" /> : <Search size={14} />} BUSCAR
</button>
</div>
{loading ? (
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
) : salas.length === 0 ? (
) : salasFiltradas.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<DoorOpen size={40} className="mx-auto mb-3 opacity-40" />
<p className="font-bold text-sm uppercase">Nenhuma sala encontrada</p>
<p className="text-xs mt-1">Ajuste os filtros ou aguarde novos anúncios</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{salas.map(s => <SalaCard key={s.id} sala={s} onReserve={() => setReservingSala(s)} />)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{salasFiltradas.map(s => <SalaCard key={s.id} sala={s} onReserve={() => setReservingSala(s)} />)}
</div>
)}
</>
@@ -726,10 +1174,15 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
{/* MINHAS SALAS */}
{tab === 'minhas' && (
<>
<button onClick={() => setEditingSala('new')}
className="flex items-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
<Plus size={14} /> CADASTRAR SALA
</button>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight">
{minhas.length > 0 ? `${minhas.length} sala${minhas.length > 1 ? 's' : ''} cadastrada${minhas.length > 1 ? 's' : ''}` : 'Suas salas'}
</h3>
<button onClick={() => setEditingSala('new')}
className="w-full sm:w-auto flex items-center justify-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-opacity hover:opacity-90 shadow-sm" style={{ backgroundColor: COLOR }}>
<Plus size={16} /> CADASTRAR SALA
</button>
</div>
{loading ? (
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
) : minhas.length === 0 ? (
@@ -739,7 +1192,7 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
<p className="text-xs mt-1">Disponibilize uma sala e receba solicitações de reserva</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{minhas.map(s => <SalaCard key={s.id} sala={s} mine onEdit={() => setEditingSala(s)} onDelete={() => handleDelete(s)} />)}
</div>
)}