Compare commits

..

55 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
VPS 4 Builder b67fa15c55 feat(superadmin): página "Secretária IA" com motivos de liga/desliga por sessão
Nova aba no super admin (/superadmin/secretaria): tabela com quem ligou/desligou a
Secretária de cada número, quando e o MOTIVO do desligamento. Endpoint
/api/superadmin/secretaria-desligamentos (superadminGuard) busca o log global no
motor (resolve um e-mail de dono pareado; log é global). Item no Sidebar + rotas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:02:39 +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
VPS 4 Builder cfeac891fb feat(wa-inbox): botão liga/desliga da Secretária por sessão na thin sidebar
Botão Power na thin sidebar do /wa-inbox age SÓ na sessão/número aberto (verde=on,
vermelho pulsante=off). Abre modal com textarea de motivo (obrigatório p/ desligar),
histórico de quem ligou/desligou e aviso de rate-limit (1x/hora). secSessionPowerApi
+ SessaoSecretariaPower.tsx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:39:41 +02:00
VPS 4 Builder f30b0ab732 feat(agenda): linha da meia-hora dividindo cada hora em 2 (estilo Google)
Grade de 30min com rótulo só na hora (slotDuration 30min + slotLabelInterval 1h);
a linha de :30 fica tracejada/fraca (fc-timegrid-slot-minor), distinta da linha
cheia da hora — como na agenda do Google.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:34:02 +02:00
VPS 4 Builder ddcc639ab6 feat(agenda): coluna "Agenda Clínica" no desktop com a estrutura do mobile
As ações da coluna de controles no desktop eram só uma fileira de ícones sem
rótulo (difícil saber o que era cada um). Agora o desktop herda a estrutura do
mobile: botões empilhados com ícone + nome (Atividade da equipe, Pedidos pendentes,
Configurações, Meus Horários, etc.), badges reposicionados junto ao ícone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:22:04 +02:00
VPS 4 Builder 882af16458 fix(dashboard): escopo por papel — dentista não vê agenda da clínica nem financeiro
/api/dashboard/stats devolvia a agenda inteira da clínica (nomes de pacientes de
todos os dentistas) + faturamento/despesas/growth no payload para QUALQUER membro,
inclusive dentista. Pior: cache por clínica (dashboard:stats:<clinica>) servia o
mesmo JSON a todos os papéis. Agora: dentista vê só a própria agenda; financeiro
só p/ admin/donoclinica/donoconsultorio/funcionario; cache por escopo (papel/dentista).
Verificado: dentista recém-cadastrado (murilo) → agenda vazia + financeiro 0; dono → tudo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:11:46 +02:00
VPS 4 Builder 4dea9ab28a fix(newwhats): papel do número no /wa-inbox p/ usuário sem conta no motor
Os papéis (sec_numbers) são resolvidos no motor pela conta (x-nw-email). Um
usuário que só existe no scoreodonto (ex.: recepcionista) não tem conta no motor,
então buscar os papéis com o e-mail DELE retorna "conta não encontrada" → todos os
números liberados apareciam como "Sem papel definido". Agora o papel de cada sessão
liberada é buscado com o e-mail do DONO da sessão (cache por e-mail). Verificado:
recep passa a ver o número como role=clinic ("Clínica / Recepção · Rui Cesar").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:24:16 +02:00
VPS 4 Builder 26f39a60f3 feat(newwhats): endpoints de encaixe (analisar + mover) p/ adiantar consulta
- GET /encaixe/analisar: ocupação do horário desejado (quem está nele) + horário
  livre mais cedo (flex-inclusivo) p/ oferecer/encaixar.
- POST /encaixe/mover: remarca um agendamento (o A aceitou adiantar), com checagem
  de conflito no destino (findConflict ganha excludeId p/ ignorar o próprio).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:08:49 +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
VPS 4 Builder f806835ae3 fix(newwhats): commita proxy.js/server.js do auto-provisionamento (main inconsistente)
O index.js (já no main) importa createAccountsRoute/provisionNewwhatsAccount do
proxy.js, mas esses exports nunca foram commitados — só existiam no working tree.
Isso quebrava o boot do backend na imagem de produção (SyntaxError: proxy.js does
not provide an export named 'createAccountsRoute') → 502. Commita proxy.js (exports
createAccountsRoute/provisionNewwhatsAccount/operatorSignature/createWsTunnel) e
server.js (usa operatorSignature + parser 12mb em /api/nw/v1), deixando o main
consistente com o backend que já roda em dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:34:07 +02:00
63 changed files with 4984 additions and 582 deletions
+556 -13
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';
@@ -99,7 +100,7 @@ async function janelasClinicaNoDia(pool, clinicaId, dateStr, dow, clinicHours) {
// Overbooking: MESMA regra do server.js (agrupa o dentista por usuario_id/email,
// para o mesmo profissional atuando em clínicas diferentes). Cópia da lógica —
// se a regra do scoreodonto mudar, revisar aqui também.
async function findConflict(db, dentistaId, start, end) {
async function findConflict(db, dentistaId, start, end, excludeId = null) {
if (!dentistaId || !start) return null;
const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]);
let ids = [dentistaId];
@@ -117,11 +118,79 @@ async function findConflict(db, dentistaId, start, end) {
WHERE dentistaid = ANY($1)
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))
AND start_time < $3 AND COALESCE(end_time, start_time) > $2
AND ($4::text IS NULL OR id <> $4)
LIMIT 1`,
[ids, start, end || start]);
[ids, start, end || start, excludeId]);
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();
@@ -165,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
@@ -175,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);
@@ -264,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); };
@@ -309,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 });
}
@@ -376,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 {
@@ -391,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 }); }
@@ -407,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 }); }
});
@@ -473,6 +666,116 @@ export function createAgendaBridge(pool) {
}
});
// ── GET /encaixe/analisar ─────────────────────────────────────────────────────
// Fluxo de "adiantar consulta": dado um dia (e horário desejado), diz se o horário
// está OCUPADO (por quem) e qual o horário LIVRE mais cedo que dá para oferecer/
// encaixar — incluindo a janela FLEX (zona cinza), pois na contenção a Secretária
// pode usá-la. Query: clinica_id, dentista_id?, data (YYYY-MM-DD), hora? (HH:MM).
router.get('/encaixe/analisar', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
const data = String(req.query.data || '');
if (!clinicaId || !data) return res.status(400).json({ error: 'clinica_id e data obrigatórios' });
const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null;
const horaDesejada = req.query.hora ? String(req.query.hora).slice(0, 5) : null;
try {
const day = new Date(`${data}T00:00:00`);
const dow = day.getDay();
const dParams = [clinicaId]; let dWhere = 'clinica_id = $1 AND ativo IS DISTINCT FROM 0';
if (dentistaId) { dParams.push(dentistaId); dWhere += ` AND id = $${dParams.length}`; }
const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams);
if (!dentistas.length) return res.json({ mais_cedo: null, contestado: null });
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, data, dow, clinicHours);
if (fechado || !clinicWins.length) return res.json({ mais_cedo: null, contestado: null, motivo: 'fechado' });
// Jornada FLEX-inclusiva (usa hora_*_flex quando houver — a zona cinza entra no encaixe).
const { rows: horarios } = await pool.query(
`SELECT dentista_id,
COALESCE(hora_inicio_flex, hora_inicio) AS hora_inicio,
COALESCE(hora_fim_flex, hora_fim) AS hora_fim
FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1 AND dia_semana = $2`, [clinicaId, dow]);
const hoursBy = new Map();
for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push([hm(h.hora_inicio), hm(h.hora_fim)]); hoursBy.set(h.dentista_id, a); }
const start0 = new Date(day); start0.setHours(0, 0, 0, 0);
const end0 = new Date(start0); end0.setDate(end0.getDate() + 1);
const { rows: busy } = await pool.query(
`SELECT id, dentistaid, pacientenome, pacientecelular, start_time, end_time FROM agendamentos
WHERE clinica_id=$1 AND start_time>=$2 AND start_time<$3
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))`,
[clinicaId, start0.toISOString(), end0.toISOString()]);
const busyBy = new Map();
for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); }
// Quem ocupa o horário desejado (direto, sem depender do grid).
let contestado = null;
if (horaDesejada) {
const alvo = new Date(`${data}T${horaDesejada}:00`);
const occ = busy.find((b) => new Date(b.start_time) <= alvo && new Date(b.end_time || b.start_time) > alvo)
|| busy.find((b) => fmt(new Date(b.start_time)).slice(11, 16) === horaDesejada);
if (occ) {
const dn = dentistas.find((d) => d.id === occ.dentistaid);
contestado = { agendamento_id: occ.id, dentista_id: occ.dentistaid, dentista_nome: dn?.nome || null,
paciente_nome: occ.pacientenome || null, paciente_celular: occ.pacientecelular || null,
start: fmt(new Date(occ.start_time)), hora: fmt(new Date(occ.start_time)).slice(11, 16) };
}
}
// Slots LIVRES do dia (flex-inclusivo).
const now = new Date(); const dur = SLOT_MIN; const livres = [];
for (const dent of dentistas) {
const dw = (hoursBy.get(dent.id) && hoursBy.get(dent.id).length) ? hoursBy.get(dent.id) : clinicWins;
const windows = interseccao(clinicWins, dw);
const bs = busyBy.get(dent.id) || [];
for (const [ini, fim] of windows) {
const [hi, mi] = String(ini).split(':').map(Number);
const [hf, mf] = String(fim).split(':').map(Number);
let t = new Date(day); t.setHours(hi, mi || 0, 0, 0);
const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0);
while (t.getTime() + dur * 60000 <= limit.getTime()) {
const s = new Date(t), e = new Date(t.getTime() + dur * 60000); t = e;
if (s <= now) continue;
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
livres.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e), hora: fmt(s).slice(11, 16) });
}
}
}
livres.sort((a, b) => a.start.localeCompare(b.start));
// mais_cedo = livre imediatamente ANTES do desejado (o mais perto por baixo); senão o 1º livre.
let mais_cedo = null;
if (horaDesejada) {
const antes = livres.filter((s) => s.hora < horaDesejada);
mais_cedo = antes.length ? antes[antes.length - 1] : (livres[0] || null);
} else {
mais_cedo = livres[0] || null;
}
res.json({ mais_cedo, contestado, total_livres: livres.length });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /encaixe/mover ───────────────────────────────────────────────────────
// Move um agendamento existente para um novo horário (o A aceitou adiantar).
// Body: { clinica_id, agendamento_id, start, end? }.
router.post('/encaixe/mover', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.agendamento_id || !b.start) return res.status(400).json({ error: 'clinica_id, agendamento_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 {
await client.query('BEGIN');
const { rows } = await client.query('SELECT id, dentistaid FROM agendamentos WHERE id=$1 AND clinica_id=$2 FOR UPDATE', [b.agendamento_id, b.clinica_id]);
const ag = rows[0];
if (!ag) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Agendamento não encontrado.' }); }
const conflict = await findConflict(client, ag.dentistaid, b.start, end, b.agendamento_id);
if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário de destino já ocupado.' }); }
await client.query('UPDATE agendamentos SET start_time=$1, end_time=$2, updated_at=NOW() WHERE id=$3', [b.start, end, b.agendamento_id]);
await client.query('COMMIT');
res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch { /* ignore */ } res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// ════════ Cadastro de pacientes (Secretária super inteligente) ════════════════
const soDigitos = (v) => String(v || '').replace(/\D/g, '');
function idadeDe(dob) {
@@ -516,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);
@@ -557,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 } });
@@ -579,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.
+195 -28
View File
@@ -39,6 +39,18 @@ async function resolveOwnerId(pool, clinicaId) {
} catch { return null; }
}
// Resolve o dono do workspace COM e-mail. Retorna { ownerId, email } ou null.
// Usado para a inbox compartilhada: a caixa da clínica vive na conta (tenant) do
// dono no motor, então falamos com o motor usando o e-mail DELE.
async function resolveOwner(pool, clinicaId) {
const ownerId = await resolveOwnerId(pool, clinicaId);
if (!ownerId) return null;
try {
const { rows } = await pool.query('SELECT lower(email) AS email FROM usuarios WHERE id = $1', [ownerId]);
return { ownerId, email: rows[0]?.email || null };
} catch { return { ownerId, email: null }; }
}
// Autoriza (ou nega 403) uma ação sensível de sessão de WhatsApp.
// Mapeamento das ações do motor:
// POST /sessions → scan INICIAL (criar) → só o dono
@@ -48,18 +60,19 @@ async function resolveOwnerId(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.' };
@@ -80,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.',
};
}
@@ -115,7 +131,7 @@ export async function provisionNewwhatsAccount(pool, cfg, email) {
// Monta a assinatura do operador para prefixar mensagens: primeiro nome; se houver
// outro membro do workspace com o MESMO primeiro nome, desambigua com "Nome I." (inicial
// do primeiro sobrenome). Retorna null se não houver nome.
async function operatorSignature(pool, userId, clinicaId) {
export async function operatorSignature(pool, userId, clinicaId) {
if (!pool || !userId) return null;
try {
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [userId]);
@@ -140,8 +156,11 @@ 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);
@@ -160,8 +179,9 @@ async function guardAreaAccess(pool, tail, userId, clinicaId) {
// Só o dono ou quem tiver can_delete_msg. (Complementa o gate de acesso ao inbox.)
async function guardInboxDestructive(pool, method, tail, userId, clinicaId) {
const isDeleteChat = method === 'DELETE' && /^\/inbox\/[^/]+\/?(\?|$)/.test(tail);
const isClearMsgs = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/?(\?|$)/.test(tail); // limpar conversa (todas as msgs)
const isDeleteMsg = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/[^/]+/.test(tail);
if (!isDeleteChat && !isDeleteMsg) return { ok: true };
if (!isDeleteChat && !isClearMsgs && !isDeleteMsg) return { ok: true };
if (!pool || !clinicaId) return { ok: true };
const ownerId = await resolveOwnerId(pool, clinicaId);
if (ownerId && ownerId === userId) return { ok: true };
@@ -195,27 +215,58 @@ 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 });
// Assinatura do operador: prefixa o texto enviado com "*Nome*\n" (identifica
// quem respondeu numa caixa compartilhada). Só no envio de texto por humano.
// Assinatura do operador: prefixa o texto enviado com "*Nome:*\n" (identifica quem
// respondeu numa caixa compartilhada). O ":" fica DENTRO do negrito para que TODOS
// os dispositivos (contato/outros operadores) e jids vejam "Nome:" em negrito
// consistente — não só a nossa UI. Só no envio de texto por humano.
if (req.method === 'POST' && /^\/inbox\/[^/]+\/send$/.test(tail) && req.body && typeof req.body.text === 'string' && req.body.text.trim()) {
const sig = await operatorSignature(pool, userId, clinicaId);
if (sig) req.body.text = `*${sig}*\n${req.body.text}`;
if (sig) req.body.text = `*${sig}:*\n${req.body.text}`;
}
// Conta-alvo no motor. Por padrão é a do próprio usuário (e-mail do JWT). Mas a
// inbox da clínica vive na conta (tenant) do DONO — então, nas rotas de inbox,
// se o usuário NÃO é dono do workspace e chegou até aqui (guardAreaAccess já
// exigiu can_inbox), falamos com o motor como o dono. Assim, logado como ele
// mesmo, o usuário vê a caixa do número da clínica no seletor — sem trocar de
// login. Só a inbox comuta a conta; demais rotas seguem com o e-mail próprio.
let targetEmail = email;
if (/^\/(inbox|conversations)(\/|$|\?)/.test(tail) && clinicaId) {
const owner = await resolveOwner(pool, clinicaId);
if (owner && owner.ownerId !== userId && owner.email) targetEmail = owner.email;
}
const url = `${cfg.motorUrl}/api/ext/v1${tail}`;
// Upload de arquivo grande: o corpo é multipart e NÃO passa pelo body-parser JSON
// (o stream chega intacto). Encaminhamos o stream cru ao motor, preservando o
// Content-Type (com boundary) — sem base64, sem limite de JSON.
const isMultipart = /multipart\/form-data/i.test(req.headers['content-type'] || '');
const opts = { method: req.method, headers: {
'Content-Type': 'application/json',
'x-nw-key': cfg.integrationKey,
'x-nw-email': email,
'x-nw-email': targetEmail,
// clínica do workspace ativo (modelo de canal) — repassa ao motor se veio.
...(req.headers['x-nw-clinica'] ? { 'x-nw-clinica': String(req.headers['x-nw-clinica']) } : {}),
...(isMultipart
? { 'content-type': req.headers['content-type'] }
: { 'Content-Type': 'application/json' }),
} };
if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) {
if (isMultipart) {
opts.body = req; // stream cru do upload → motor
opts.duplex = 'half'; // exigido pelo fetch (undici) ao enviar stream
} else if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) {
opts.body = JSON.stringify(req.body);
}
@@ -224,8 +275,8 @@ export function createRestProxy(pool) {
let text = await r.text();
// Self-heal: a conta ainda não foi espelhada no motor. Provisiona (idempotente)
// e refaz o request UMA vez — assim "quando a pessoa for mexer, tudo funciona".
if (r.status === 404 && /conta n[ãa]o encontrada/i.test(text) && email) {
const ok = await provisionNewwhatsAccount(pool, cfg, email);
if (r.status === 404 && /conta n[ãa]o encontrada/i.test(text) && targetEmail) {
const ok = await provisionNewwhatsAccount(pool, cfg, targetEmail);
if (ok) { r = await fetch(url, opts); text = await r.text(); }
}
res.status(r.status);
@@ -236,6 +287,99 @@ export function createRestProxy(pool) {
};
}
// ── GET /api/nw/accounts — contas de WhatsApp que o usuário logado pode operar ──
//
// Alimenta o seletor de número da inbox (multi-conta, SEM trocar de login) e o
// ícone (i) de papéis. Junta: (1) as sessões da PRÓPRIA conta do usuário no motor
// e (2) as sessões LIBERADAS pelo dono (can_inbox por sessão em wa_session_authz),
// buscadas no motor com o e-mail do dono. Cada item traz o papel (sec_numbers).
export function createAccountsRoute(pool) {
return async function accountsRoute(req, res) {
const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) {
return res.status(503).json({ error: 'Integração NewWhats não configurada.' });
}
const auth = authFromReq(req);
if (!auth) return res.status(401).json({ error: 'Sessão inválida (faça login no scoreodonto).' });
const { email, userId } = auth;
const motorGet = async (path, asEmail) => {
try {
const r = await fetch(`${cfg.motorUrl}/api/ext/v1${path}`, {
headers: { 'x-nw-key': cfg.integrationKey, 'x-nw-email': asEmail },
});
if (!r.ok) return [];
const j = await r.json();
return Array.isArray(j) ? j : [];
} catch { return []; }
};
try {
// Papéis (sec_numbers) são resolvidos no motor pela CONTA (x-nw-email). Um
// usuário sem conta no motor (ex.: recepcionista que só existe no scoreodonto)
// não tem números próprios — então o papel de cada número LIBERADO precisa ser
// buscado com o e-mail do DONO da sessão, não do usuário logado. Cache por e-mail.
const numbersCache = new Map();
const numbersFor = async (asEmail) => {
if (!numbersCache.has(asEmail)) {
const nums = await motorGet('/secretaria/numbers', asEmail);
numbersCache.set(asEmail, new Map(nums.map((n) => [n.instance_id, n])));
}
return numbersCache.get(asEmail);
};
const metaFrom = (roleMap, instanceId, fallbackName) => {
const n = roleMap.get(instanceId) || {};
return { role: n.role || null, label: n.label || fallbackName || null, area: n.area || null, notes: n.notes || null };
};
const out = [];
const seen = new Set();
const pushWith = (s, roleMap, extra) => {
if (!s || seen.has(s.id)) return;
seen.add(s.id);
out.push({
instanceId: s.id, phone: s.phone || null, name: s.name || null,
status: s.status || null, avatar: s.avatar || null,
...metaFrom(roleMap, s.id, s.name), ...extra,
});
};
// 1) Sessões PRÓPRIAS (conta do próprio usuário no motor) — papel da própria conta.
const ownRoles = await numbersFor(email);
for (const s of await motorGet('/sessions', email)) {
pushWith(s, ownRoles, { clinicaId: null, ownerEmail: email, ownerNome: null, own: true, canInbox: true });
}
// 2) Sessões LIBERADAS por outros donos (can_inbox por sessão).
const { rows: grants } = await pool.query(
`SELECT DISTINCT clinica_id, instance_id FROM wa_session_authz
WHERE usuario_id = $1 AND can_inbox = true`, [userId]);
const byClinica = new Map();
for (const g of grants) {
if (!byClinica.has(g.clinica_id)) byClinica.set(g.clinica_id, new Set());
byClinica.get(g.clinica_id).add(g.instance_id);
}
for (const [clinicaId, allowed] of byClinica) {
const owner = await resolveOwner(pool, clinicaId);
if (!owner || !owner.email || owner.ownerId === userId) continue; // dono próprio já veio em (1)
let ownerNome = null;
try {
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [owner.ownerId]);
ownerNome = rows[0]?.nome || null;
} catch { /* opcional */ }
const ownerRoles = await numbersFor(owner.email); // papéis dos números vêm da conta do DONO
for (const s of await motorGet('/sessions', owner.email)) {
if (allowed.has(s.id)) pushWith(s, ownerRoles, { clinicaId, ownerEmail: owner.email, ownerNome, own: false, canInbox: true });
}
}
res.json(out);
} catch (e) {
res.status(502).json({ error: `Falha ao montar contas: ${e.message}` });
}
};
}
// ── Túnel WebSocket: /api/nw/v1/stream (browser) → motor /api/ext/v1/stream ──
//
// O inbox em tempo real do motor (QR, status da sessão, mensagens novas) roda
@@ -249,24 +393,47 @@ export function createRestProxy(pool) {
// Motor: autentica o /api/ext/v1/stream pela x-nw-key + x-nw-email (a chave é a
// mestra; o tenant é resolvido pelo email do usuário) e exige o path EXATO, sem
// query — por isso reescrevemos um request-line limpo. O email vem do JWT.
export function createWsTunnel() {
return function wsTunnel(req, clientSocket, head) {
//
// Conta-alvo do stream: por padrão a própria. Se o browser mandar ?clinica= (a
// conta ativa do seletor) e o usuário não for o dono mas tiver can_inbox nela,
// o stream passa a ser o do DONO — assim a caixa compartilhada recebe mensagens
// novas em tempo real. Mesma regra do proxy REST (reescrita de x-nw-email).
export function createWsTunnel(pool) {
return async function wsTunnel(req, clientSocket, head) {
const fail = (statusLine) => {
try { clientSocket.write(`HTTP/1.1 ${statusLine}\r\n\r\n`); } catch { /* socket já morto */ }
clientSocket.destroy();
};
// Auth: JWT do scoreodonto no query string (?token=).
let token;
try { token = new URL(req.url, 'http://localhost').searchParams.get('token'); }
catch { return fail('400 Bad Request'); }
let email;
// Auth: JWT do scoreodonto no query string (?token=); conta ativa em ?clinica=.
let token, clinica;
try {
const u = new URL(req.url, 'http://localhost');
token = u.searchParams.get('token');
clinica = u.searchParams.get('clinica');
} catch { return fail('400 Bad Request'); }
let email, userId;
try {
const p = jwt.verify(token, process.env.JWT_SECRET);
email = (p.email || '').toLowerCase();
userId = p.userId || null;
} catch { return fail('401 Unauthorized'); }
if (!email) return fail('401 Unauthorized');
// Reescrita da conta-alvo p/ a caixa compartilhada (só leitura via can_inbox).
let targetEmail = email;
if (clinica && pool && userId) {
try {
const owner = await resolveOwner(pool, clinica);
if (owner && owner.email && owner.ownerId !== userId) {
const { rows } = await pool.query(
'SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND can_inbox = true LIMIT 1',
[clinica, userId]);
if (rows.length) targetEmail = owner.email;
}
} catch { /* mantém a própria conta */ }
}
const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) return fail('503 Service Unavailable');
@@ -289,7 +456,7 @@ export function createWsTunnel() {
`${req.method} /api/ext/v1/stream HTTP/1.1\r\n` +
`Host: ${host}\r\n` +
`x-nw-key: ${cfg.integrationKey}\r\n` +
`x-nw-email: ${email}\r\n` +
`x-nw-email: ${targetEmail}\r\n` +
`${headers}\r\n\r\n`
);
if (head && head.length) proxySocket.write(head);
+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 };
+324 -35
View File
@@ -12,6 +12,8 @@ import path from 'path';
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';
@@ -165,6 +167,9 @@ try {
// EXPRESS
// =============================================================================
const app = express();
// Envio de mídia pelo plugin WhatsApp vai em base64 (JSON) — precisa de mais folga
// que o limite global. Este parser roda ANTES do global (2mb) só para /api/nw/v1.
app.use('/api/nw/v1', express.json({ limit: '12mb' }));
app.use(express.json({ limit: '2mb' }));
app.use(cors({
origin: process.env.CORS_ORIGIN || '*',
@@ -839,9 +844,12 @@ app.get('/api/nw/access', tenantGuard, async (req, res) => {
const clinicaId = req.clinicaId;
const meId = req.authUser.userId;
if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' });
// Assinatura do operador (mesma do proxy) — o frontend usa para exibir "Nome:" na
// mensagem otimista NA HORA, sem esperar o eco do servidor (evita o flicker).
const signature = await operatorSignature(pool, meId, clinicaId);
const owner = await resolveWorkspaceOwner(clinicaId);
if (owner && owner.ownerId === meId) {
return res.json({ isOwner: true, inbox: true, secretaria: true, delete_msg: true });
return res.json({ isOwner: true, inbox: true, secretaria: true, delete_msg: true, signature });
}
const { rows } = await pool.query(
`SELECT bool_or(can_inbox) AS inbox, bool_or(can_secretaria) AS secretaria, bool_or(can_delete_msg) AS delete_msg
@@ -851,6 +859,7 @@ app.get('/api/nw/access', tenantGuard, async (req, res) => {
inbox: !!rows[0]?.inbox,
secretaria: !!rows[0]?.secretaria,
delete_msg: !!rows[0]?.delete_msg,
signature,
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
@@ -876,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);
@@ -885,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 }); }
});
@@ -1008,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();
@@ -1919,7 +1960,25 @@ app.get('/api/dashboard/stats', tenantGuard, async (req, res) => {
try {
const clinicaId = req.clinicaId;
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const cacheKey = `dashboard:stats:${clinicaId}`;
// Escopo por PAPEL: um dentista vê só a PRÓPRIA agenda; o financeiro só é
// devolvido a dono/admin/funcionário. Sem isso, um dentista recém-cadastrado
// via a agenda inteira da clínica + os financeiros no payload (mesmo com o
// front escondendo os cards). O cache passa a ser por ESCOPO (não só clínica).
let role = null, dentistaIds = [];
try {
const { rows } = await pool.query('SELECT role FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2 LIMIT 1', [req.authUser.userId, clinicaId]);
role = rows[0]?.role || null;
} catch { /* segue */ }
const isDentista = role === 'dentista';
const verFinanceiro = ['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(role);
if (isDentista) {
try {
const { rows } = await pool.query('SELECT id FROM dentistas WHERE usuario_id = $1 AND clinica_id = $2', [req.authUser.userId, clinicaId]);
dentistaIds = rows.map((r) => r.id);
} catch { /* segue */ }
}
const scope = isDentista ? `dent:${dentistaIds.join('_') || 'none'}` : (verFinanceiro ? 'full' : 'basic');
const cacheKey = `dashboard:stats:${clinicaId}:${scope}`;
if (redis) {
const cachedStats = await redis.get(cacheKey);
if (cachedStats) {
@@ -1929,43 +1988,55 @@ app.get('/api/dashboard/stats', tenantGuard, async (req, res) => {
}
const { rows: pacientes } = await pool.query('SELECT COUNT(*) as count FROM pacientes WHERE clinica_id = $1', [clinicaId]);
const { rows: agendamentos } = await pool.query('SELECT COUNT(*) as count FROM agendamentos WHERE clinica_id = $1', [clinicaId]);
const { rows: leads } = await pool.query('SELECT COUNT(*) as count FROM leads WHERE clinica_id = $1', [clinicaId]);
const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE (tipo = 'RECEITA' OR tipo IS NULL) AND clinica_id = $1", [clinicaId]);
const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA' AND clinica_id = $1", [clinicaId]);
// Growth data (last 6 months)
const { rows: growth } = await pool.query(`
SELECT
TO_CHAR(datavencimento, 'YYYY-MM') as mes,
SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita,
SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa
FROM financeiro
WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months' AND clinica_id = $1
GROUP BY TO_CHAR(datavencimento, 'YYYY-MM')
ORDER BY mes ASC
`, [clinicaId]);
// Filtro de agenda: dentista → só os próprios agendamentos.
const agFilter = (col, params) => {
let w = `${col === 'a' ? 'a.' : ''}clinica_id = $1`;
if (isDentista) {
if (!dentistaIds.length) return { where: w + ' AND false', params };
params.push(dentistaIds);
w += ` AND ${col === 'a' ? 'a.' : ''}dentistaid = ANY($${params.length})`;
}
return { where: w, params };
};
const agp = agFilter('', [clinicaId]);
const { rows: agendamentos } = await pool.query(`SELECT COUNT(*) as count FROM agendamentos WHERE ${agp.where}`, agp.params);
// Recent appointments with patient and dentist names
// Financeiro só p/ dono/admin/funcionário.
let faturamentoTotal = 0, despesasTotal = 0, growth = [];
if (verFinanceiro) {
const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE (tipo = 'RECEITA' OR tipo IS NULL) AND clinica_id = $1", [clinicaId]);
const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA' AND clinica_id = $1", [clinicaId]);
const { rows: g } = await pool.query(`
SELECT TO_CHAR(datavencimento, 'YYYY-MM') as mes,
SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita,
SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa
FROM financeiro
WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months' AND clinica_id = $1
GROUP BY TO_CHAR(datavencimento, 'YYYY-MM') ORDER BY mes ASC`, [clinicaId]);
faturamentoTotal = parseFloat(faturamento[0].total);
despesasTotal = parseFloat(pendente[0].total);
growth = g;
}
// Agenda recente com os mesmos limites por papel.
const rec = agFilter('a', [clinicaId]);
const { rows: recent } = await pool.query(`
SELECT a.id, a.pacientenome, a.start_time, a.status,
d.nome as "dentistaNome"
FROM agendamentos a
LEFT JOIN dentistas d ON a.dentistaid = d.id
WHERE a.clinica_id = $1
ORDER BY a.start_time DESC
LIMIT 5
`, [clinicaId]);
SELECT a.id, a.pacientenome, a.start_time, a.status, d.nome as "dentistaNome"
FROM agendamentos a LEFT JOIN dentistas d ON a.dentistaid = d.id
WHERE ${rec.where}
ORDER BY a.start_time DESC LIMIT 5`, rec.params);
const responseData = {
stats: {
totalPacientes: parseInt(pacientes[0].count),
totalAgendamentos: parseInt(agendamentos[0].count),
totalLeads: parseInt(leads[0].count),
faturamentoTotal: parseFloat(faturamento[0].total),
despesasTotal: parseFloat(pendente[0].total)
faturamentoTotal,
despesasTotal
},
growth: growth,
growth,
recentAgendamentos: recent
};
@@ -2105,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 {
@@ -6137,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`,
@@ -6148,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,
@@ -6662,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`,
@@ -7500,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 {
@@ -7564,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 });
@@ -7588,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 });
@@ -7624,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}`; }
@@ -7642,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);
@@ -9400,6 +9658,37 @@ app.get('/api/superadmin/login-log', superadminGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Super admin: motivos de LIGAR/DESLIGAR a Secretária por sessão (histórico global,
// vem do motor — sec_session_power_log). Requer integração newwhats configurada.
app.get('/api/superadmin/secretaria-desligamentos', superadminGuard, async (req, res) => {
try {
const { getConfig } = await import('./newwhats/config.js');
const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) return res.json({ configurado: false, log: [] });
// O log é GLOBAL no motor; qualquer conta de dono pareada serve para autenticar.
const { rows } = await pool.query(`SELECT u.email FROM usuarios u JOIN vinculos v ON v.usuario_id = u.id WHERE v.role IN ('donoclinica','donoconsultorio') ORDER BY u.email LIMIT 1`);
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 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 }); }
});
// Defaults mesclados sob o JSON salvo: chaves novas (biomedico, add-ons de plugin)
// aparecem na tabela mesmo p/ configs gravadas antes delas existirem.
const PRICING_DEFAULTS = {
@@ -10125,7 +10414,7 @@ const wss = new WebSocketServer({ noServer: true });
// Dispatcher único de upgrade WS: roteia por path. O `ws` em modo {server,path}
// aborta com 400 qualquer upgrade de outro path, então centralizamos aqui para o
// /api/ws (app) coexistir com o túnel do NewWhats (/api/nw/v1/stream → motor).
const nwWsTunnel = createWsTunnel();
const nwWsTunnel = createWsTunnel(pool);
httpServer.on('upgrade', (req, socket, head) => {
let pathname;
try { pathname = new URL(req.url, 'http://localhost').pathname; }
+64 -4
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';
@@ -121,6 +122,7 @@ export type ViewKey =
| 'superadmin-logs'
| 'superadmin-financeiro'
| 'superadmin-notificacoes'
| 'superadmin-secretaria'
| 'diretorio-profissionais';
// ------- URL <-> VIEW mapping -------
@@ -181,6 +183,7 @@ const ROUTE_MAP: Record<string, ViewKey> = {
'/superadmin/logs': 'superadmin-logs',
'/superadmin/financeiro': 'superadmin-financeiro',
'/superadmin/notificacoes': 'superadmin-notificacoes',
'/superadmin/secretaria': 'superadmin-secretaria',
'/aguardando-convite': 'aguardando-convite',
'/diretorio-profissionais': 'diretorio-profissionais',
'/proteses': 'proteses',
@@ -248,6 +251,7 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
'superadmin-logs': '/superadmin/logs',
'superadmin-financeiro': '/superadmin/financeiro',
'superadmin-notificacoes': '/superadmin/notificacoes',
'superadmin-secretaria': '/superadmin/secretaria',
'diretorio-profissionais': '/diretorio-profissionais',
proteses: '/proteses',
};
@@ -315,6 +319,13 @@ const MobileBottomNav: React.FC<{
const App: React.FC = () => {
const ver = useAppVersion();
const [isSidebarOpen, setSidebarOpen] = useState(false);
// Evita que a sidebar ANIME (slide-in) a cada reload no desktop: a transição só liga
// após o 1º paint, então no load ela já nasce posicionada; toggles do usuário animam.
const [uiMounted, setUiMounted] = useState(false);
useEffect(() => { setUiMounted(true); }, []);
// Gaveta de controles da Agenda no MOBILE (título/AGENDAR/ações) — comporta-se como
// a sidebar: some por padrão p/ dar o máximo de espaço ao calendário; abre pelo ⋯.
const [agendaControlsOpen, setAgendaControlsOpen] = useState(false);
// Recolher a sidebar no DESKTOP (persistente) — dá largura ao conteúdo (ex.: agenda).
const [isSidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
try { return localStorage.getItem('scoreodonto:sidebar-collapsed') === '1'; } catch { return false; }
@@ -477,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) {
@@ -508,7 +550,7 @@ const App: React.FC = () => {
case 'leads': return <LeadsView />;
case 'public': return <PublicContactForm />;
case 'pacientes': return <PatientsView />;
case 'agenda': return <AgendaView onNavigate={handleNavigate} sidebarCollapsed={isSidebarCollapsed} onToggleSidebar={() => toggleSidebarCollapsed(!isSidebarCollapsed)} />;
case 'agenda': return <AgendaView onNavigate={handleNavigate} sidebarCollapsed={isSidebarCollapsed} onToggleSidebar={() => toggleSidebarCollapsed(!isSidebarCollapsed)} controlsOpen={agendaControlsOpen} onCloseControls={() => setAgendaControlsOpen(false)} />;
case 'ortodontia': return <OrthoView />;
case 'financeiro': return <FinanceiroView />;
case 'dentistas': return <DentistasView />;
@@ -561,6 +603,7 @@ const App: React.FC = () => {
case 'superadmin-logs': return <SuperAdminView tab="logs" />;
case 'superadmin-financeiro': return <SuperAdminView tab="financeiro" />;
case 'superadmin-notificacoes': return <SuperAdminView tab="notificacoes" />;
case 'superadmin-secretaria': return <SuperAdminView tab="secretaria" />;
// Plugin Profissionais ativo absorve o diretório antigo (mesma rota, view nova).
case 'diretorio-profissionais': return isPluginActive('profissionais-marketplace') ? <ProfissionaisPlugin /> : <DiretorioProfissionaisView />;
case 'login':
@@ -580,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}; }
@@ -599,7 +648,7 @@ const App: React.FC = () => {
></div>
)}
<div className={`fixed inset-y-0 left-0 z-30 transform transition-transform duration-300 ${isSidebarCollapsed ? 'md:hidden' : 'md:relative md:translate-x-0'} ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<div className={`fixed inset-y-0 left-0 z-30 transform ${uiMounted ? 'transition-transform duration-300' : ''} ${isSidebarCollapsed ? 'md:hidden' : 'md:relative md:translate-x-0'} ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<Sidebar
activeTab={currentView}
setActiveTab={(t) => handleNavigate(t as ViewKey)}
@@ -652,7 +701,18 @@ const App: React.FC = () => {
</div>
<span className="font-bold text-gray-800 text-sm tracking-tight uppercase">SCOREODONTO</span>
</div>
<div className="w-8"></div>
{/* ⋯ controles da Agenda (só nesta view) — abre a gaveta; senão, espaçador */}
{currentView === 'agenda' ? (
<button
onClick={() => setAgendaControlsOpen(true)}
className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
title="Controles da agenda"
>
<MoreHorizontal size={24} />
</button>
) : (
<div className="w-8"></div>
)}
</div>
</>
)}
+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>
);
}
+9 -7
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import {
Users, Calendar as CalendarIcon, LayoutDashboard,
DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown, MessageCircle, Bot, Smartphone
DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown, MessageCircle, Bot, Smartphone, Power
} from 'lucide-react';
import { ToothIcon } from './ToothIcon.tsx';
import { HybridBackend } from '../services/backend.ts';
@@ -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
@@ -83,6 +84,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
{ id: 'superadmin-logs', label: 'ACESSOS', icon: Activity },
{ id: 'superadmin-financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'superadmin-notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
{ id: 'superadmin-secretaria', label: 'SECRETÁRIA IA', icon: Power },
{ id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen },
{ id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
@@ -121,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');
};
+77 -24
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;
@@ -658,60 +685,60 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
</button>
)}
{/* Ações — mobile: empilhadas, ícone + nome, largura cheia; desktop: fileira de ícones */}
<div className="flex flex-col lg:flex-row gap-2 w-full">
<div className="flex flex-col gap-2 w-full">
{(currentRole !== 'paciente') && (
<button onClick={() => { setShowAtividade(true); loadAtividade(); onCloseControls?.(); }} title="Atividade da equipe hoje"
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<History size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Atividade da equipe</span>
<span className="text-sm font-bold">Atividade da equipe</span>
</button>
)}
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
<button onClick={handleImportarGoogle} disabled={importando} title="Importar agendamentos do Google para o sistema"
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-emerald-600 border border-gray-200 rounded-xl transition-all hover:border-emerald-200 hover:shadow-sm disabled:opacity-50">
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-emerald-600 border border-gray-200 rounded-xl transition-all hover:border-emerald-200 hover:shadow-sm disabled:opacity-50">
{importando ? <Loader2 size={20} className="animate-spin shrink-0" /> : <Download size={20} className="shrink-0" />}
<span className="lg:hidden text-sm font-bold">Importar do Google</span>
<span className="text-sm font-bold">Importar do Google</span>
</button>
)}
{['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(currentRole) && (
<button onClick={() => { setShowPedidos(true); onCloseControls?.(); }} title="Pedidos de horário aguardando confirmação (encaminhados pela Secretária IA)"
className="relative w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
<CalendarClock size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Pedidos pendentes</span>
{pedidosCount > 0 && <span className="absolute -top-1 -right-1 lg:top-0 lg:right-1 bg-amber-500 text-white text-[10px] font-black rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 shadow">{pedidosCount}</span>}
<span className="text-sm font-bold">Pedidos pendentes</span>
{pedidosCount > 0 && <span className="absolute -top-1 -right-1 bg-amber-500 text-white text-[10px] font-black rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 shadow">{pedidosCount}</span>}
</button>
)}
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
<button onClick={() => { setIsSettingsOpen(true); onCloseControls?.(); }} title="Configurações da agenda"
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<GearIcon size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Configurações</span>
<span className="text-sm font-bold">Configurações</span>
</button>
)}
{souDentista && (
<button onClick={() => { setShowMeusHorarios(true); onCloseControls?.(); }} title="Configurar meus dias e horários de atendimento"
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<Clock size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Meus Horários</span>
<span className="text-sm font-bold">Meus Horários</span>
</button>
)}
{souDentista && (
<button onClick={() => { setShowInteresses(true); loadInteresses(); onCloseControls?.(); }} title="Pedidos de interesse na sua agenda"
className="relative w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<Bell size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Interesses na agenda</span>
<span className="text-sm font-bold">Interesses na agenda</span>
{interessesAguardando > 0 && (
<span className="absolute top-1.5 left-6 lg:left-auto lg:-top-1 lg:-right-1 bg-red-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{interessesAguardando}</span>
<span className="absolute top-1.5 left-6 bg-red-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{interessesAguardando}</span>
)}
</button>
)}
{(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setShowPendencias(true); loadPendencias(); onCloseControls?.(); }} title="Pacientes a reagendar"
className="relative w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
<CalendarClock size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Pacientes a reagendar</span>
<span className="text-sm font-bold">Pacientes a reagendar</span>
{pendencias.length > 0 && (
<span className="absolute top-1.5 left-6 lg:left-auto lg:-top-1 lg:-right-1 bg-amber-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{pendencias.length}</span>
<span className="absolute top-1.5 left-6 bg-amber-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{pendencias.length}</span>
)}
</button>
)}
@@ -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' }
@@ -758,6 +790,8 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
buttonText={{ today: 'HOJE', month: 'MÊS', week: 'SEMANA', day: 'DIA' }}
slotMinTime="08:00:00"
slotMaxTime="19:00:00"
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
allDaySlot={false}
events={events}
selectable={true}
@@ -811,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)}>
@@ -902,8 +949,13 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
.fc .fc-col-header-cell-cushion { font-size: .65rem; padding: 2px; }
.fc .fc-timegrid-slot-label-cushion, .fc .fc-timegrid-axis-cushion { font-size: .6rem; }
.fc .fc-timegrid-event .fc-event-main { padding: 1px 2px; }
.fc-footer-toolbar.fc-toolbar { margin-top: .5rem; }
}
/* Linha da MEIA-HORA (:30): divide a hora em 2, fraca/tracejada como no Google
(a linha cheia da hora vem do border padrão das células). */
.fc .fc-timegrid-slot-minor {
border-top: 1px dashed #eef1f4 !important;
}
.fc-footer-toolbar.fc-toolbar { margin-top: .5rem; }
`}</style>
<AppointmentModal
isOpen={isModalOpen}
@@ -917,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 && (
@@ -936,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' ? (
+72 -2
View File
@@ -5,7 +5,7 @@ import {
CheckCircle2, XCircle, Clock, Search, ChevronLeft, ChevronRight,
TrendingUp, Stethoscope, FlaskConical, User, ToggleLeft, ToggleRight,
Save, AlertTriangle, Wifi, WifiOff, Briefcase, Trash2, Bell, Send,
MapPin, Phone, MoreVertical, X, ArrowLeft, UserX, Sparkles, DoorOpen, UserSearch
MapPin, Phone, MoreVertical, X, ArrowLeft, UserX, Sparkles, DoorOpen, UserSearch, Power
} from 'lucide-react';
const apiFetch = (url: string, opts: RequestInit = {}) => {
@@ -74,7 +74,7 @@ const KPI: React.FC<{ icon: React.ElementType; label: string; value: string | nu
);
};
type SuperAdminTab = 'overview' | 'users' | 'clinicas' | 'logs' | 'financeiro' | 'notificacoes';
type SuperAdminTab = 'overview' | 'users' | 'clinicas' | 'logs' | 'financeiro' | 'notificacoes' | 'secretaria';
/* ═══════════════════════════════════════════════════ MAIN VIEW */
export const SuperAdminView: React.FC<{ tab?: SuperAdminTab }> = ({ tab = 'overview' }) => {
@@ -86,6 +86,76 @@ export const SuperAdminView: React.FC<{ tab?: SuperAdminTab }> = ({ tab = 'overv
{tab === 'logs' && <TabLogs />}
{tab === 'financeiro' && <TabFinanceiro />}
{tab === 'notificacoes' && <TabNotificacoes />}
{tab === 'secretaria' && <TabSecretaria />}
</div>
);
};
/* ═══════════════════════════════════════════════════ TAB SECRETÁRIA (liga/desliga por sessão) */
const TabSecretaria: React.FC = () => {
const [log, setLog] = useState<any[]>([]);
const [configurado, setConfigurado] = useState(true);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
apiFetch('/api/superadmin/secretaria-desligamentos')
.then(r => r.json())
.then(d => { setLog(Array.isArray(d.log) ? d.log : []); setConfigurado(d.configurado !== false); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const totalOff = log.filter(l => !l.enabled).length;
return (
<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 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>
{!configurado ? (
<div className="bg-amber-50 text-amber-700 text-sm rounded-2xl p-4 flex items-center gap-2"><AlertTriangle size={16} /> Integração com o motor da Secretária não está configurada neste ambiente.</div>
) : loading ? (
<div className="text-center text-gray-400 py-16">Carregando</div>
) : log.length === 0 ? (
<div className="text-center text-gray-400 py-16">Nenhum registro de liga/desliga ainda.</div>
) : (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
<table className="w-full text-sm min-w-[640px]">
<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">Escopo</th>
<th className="text-left px-4 py-3">Quando</th>
<th className="text-left px-4 py-3">Motivo</th>
</tr>
</thead>
<tbody>
{log.map((l, i) => (
<tr key={i} className={`border-t border-gray-50 ${!l.enabled ? 'bg-red-50/30' : ''}`}>
<td className="px-4 py-3">
{l.enabled
? <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">
{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>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
+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>
@@ -0,0 +1,139 @@
// Liga/desliga a Secretária IA APENAS desta sessão (instância aberta no /wa-inbox).
// Rate-limit 1x/hora, motivo obrigatório ao desligar, histórico de quem apertou.
// Fica na thin sidebar; abre um modal com o textarea de motivo + histórico.
import React, { useState, useEffect, useCallback } from 'react';
import { Power, X, Loader2, History, AlertTriangle } from 'lucide-react';
import { secSessionPowerApi, SessionPower } from '../services/secretariaApi';
const userName = (() => {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}')?.nome || 'Usuário'; }
catch { return 'Usuário'; }
})();
const fmt = (iso?: string | null) => {
if (!iso) return '';
try { return new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); }
catch { return ''; }
};
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('');
const [busy, setBusy] = useState(false);
const [erro, setErro] = useState<string | null>(null);
const carregar = useCallback(async () => {
if (!instanceId) return;
try { setSp(await secSessionPowerApi.get(instanceId)); } catch { /* silencioso */ }
}, [instanceId]);
useEffect(() => { carregar(); }, [carregar]);
// 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;
const proximo = !enabled;
if (proximo === false && !reason.trim()) { setErro('Escreva o motivo para desligar.'); return; }
setBusy(true); setErro(null);
try {
await secSessionPowerApi.set(instanceId, proximo, reason.trim(), userName);
setReason('');
await carregar();
} catch (e: any) {
setErro(e?.message || 'Não foi possível alterar.');
await carregar();
} finally { setBusy(false); }
};
return (
<>
<button
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 ${
!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" />
</button>
{open && (
<div className="fixed inset-0 bg-black/60 z-[80] flex items-center justify-center backdrop-blur-md p-4" onClick={() => setOpen(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 ${enabled ? '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 desta sessão</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-1">{enabled ? 'Ligada' : 'Desligada'} · este número</p>
</div>
</div>
<button onClick={() => setOpen(false)} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={20} /></button>
</div>
{erro && <div className="px-6 py-2 text-xs font-bold bg-red-50 text-red-600">{erro}</div>}
<div className="p-6 space-y-4 overflow-y-auto">
{!enabled && sp?.reason && (
<div className="text-xs bg-red-50 text-red-700 rounded-xl p-3">
<span className="font-black">Desligada.</span> Motivo: {sp.reason}
{sp.changed_by && <div className="text-red-400 mt-1">por {sp.changed_by} · {fmt(sp.changed_at)}</div>}
</div>
)}
{/* Motivo (só ao desligar) */}
{enabled && (
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Motivo para desligar</label>
<textarea value={reason} onChange={(e) => setReason(e.target.value)} rows={3}
placeholder="Ex.: revisando as respostas da IA; horário de pico; treinamento…"
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" />
</div>
)}
{/* Rate-limit */}
{!sp?.can_toggle && (
<p className="text-[11px] text-amber-600 font-bold flex items-center gap-1.5"><AlertTriangle size={13} /> é possível alternar 1x por hora. Disponível às {fmt(sp?.next_toggle_at)}.</p>
)}
<button onClick={toggle} disabled={busy || !sp?.can_toggle}
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 ${
enabled ? 'bg-red-600 hover:bg-red-700 text-white' : 'bg-emerald-600 hover:bg-emerald-700 text-white'
}`}>
{busy ? <Loader2 size={16} className="animate-spin" /> : <Power size={16} />}
{enabled ? 'Desligar nesta sessão' : 'Ligar nesta sessão'}
</button>
{/* Histórico */}
{!!sp?.history?.length && (
<div>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5 mb-2"><History size={12} /> Histórico</p>
<div className="space-y-1.5 max-h-40 overflow-y-auto">
{sp.history.map((h, i) => (
<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 ? 'Ligou' : 'Desligou'}</span>
<span className="text-gray-400"> · {h.by || '—'} · {fmt(h.at)}</span>
{!h.enabled && h.reason && <div className="text-gray-500 leading-snug">{h.reason}</div>}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
</>
);
};
@@ -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,19 +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) => {
@@ -50,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 */}
@@ -64,6 +82,8 @@ 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} 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,39 @@ 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).
export interface SessionPower {
enabled: boolean
reason: string | null
changed_by: string | null
changed_at: string | null
can_toggle: boolean
next_toggle_at: string | null
history: { enabled: boolean; reason: string | null; by: string | null; at: string }[]
}
export const secSessionPowerApi = {
get: (instanceId: string) => api.get<SessionPower>(`${BASE}/session-power`, { params: { instance_id: instanceId } }).then((r) => r.data),
set: (instanceId: string, enabled: boolean, reason: string, by: string) =>
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 ───────────────────────────────────────────────────────────────────
@@ -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>
)}