Compare commits

..

46 Commits

Author SHA1 Message Date
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
ruicesar 30ea2f0e10 Merge pull request 'feat(secretaria): agente/cérebro por número na aba Números' (#2) from feat/newwhats-satelite into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 52s
2026-07-06 16:07:07 +02:00
VPS 4 Builder ec76dfeec2 feat(newwhats): checklist trata telefone compartilhado e profissional não cadastrado
Fecha furos de identificação relatados:
- Telefone compartilhado na família: nova pergunta telefone_compartilhado +
  cautelas IDENTIDADE/TELEFONE COMPARTILHADO — a IA nunca assume quem fala pelo
  número (ex.: cadastrado no filho, mas quem fala é a mãe; ou pergunta do marido)
  e confirma nome + para quem é antes de agendar; se não bater, encaminha ao humano.
- Profissional não cadastrado: cautela para citações de dentista fora da lista
  (ex.: prótese com Dr. de fora) — encaminha ao humano e pede no resumo que o
  dono cadastre o profissional.
- Pendência de config quando telefone_compartilhado=Sim (completar cadastro dos familiares).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:51:09 +02:00
VPS 4 Builder 40bed96515 feat(newwhats): agenda inteligente — especialidades, situações, dúvidas, checklist do dono e pendências
Satélite da Secretária IA:
- /contexto (dentistas+especialidades+situações+cautelas) e /duvida (encaminhar
  ao humano sem agendar); agenda_pedidos ganha tipo=duvida + resumo.
- Situações da clínica: tabela agenda_situacoes + config (dono) + aba na UI.
- Checklist do dono (checklist-defs.js + agenda_checklist): perguntas Sim/Não
  conferidas contra o banco (dentistas/pacientes) → pendências de config +
  cautelas p/ a IA não chutar.
- Área de PENDÊNCIAS DO DONO (PendenciasDono.tsx): engloba config + fila
  operacional (horários/dúvidas); botão+badge no /wa-secretaria (só o dono).
- Fila humana (PedidosPendentes): cards de dúvida + resolver; /resolver.
- Botão DESLIGAR global da Secretária no ThinNav (secPowerApi, visível a todos).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:40:51 +02:00
VPS 4 Builder f4c2c6861a feat(agenda): GET /dentista-contato — resolve WhatsApp do dentista (Parte A)
Endpoint para a Secretária IA acionar o dentista responsável num encaixe sub-hora.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 06:31:33 +02:00
VPS 4 Builder 668d672531 feat(agenda): 1h de espaçamento + filtro manhã/tarde no /slots
- Espaçamento/duração padrão do slot passa de 30 para 60 min (1h entre atendimentos).
- /slots aceita ?periodo=manha|tarde (a Secretária pergunta o período antes de oferecer
  e busca só aquele período).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 05:59:32 +02:00
VPS 4 Builder ca2cfa1f35 feat(agenda): janela IA-livre vs estendida (zona cinza) + fila da secretária humana
- Duplicar horário para todos os dias (botão na grade).
- Janela do dentista com DOIS limites: "IA agenda livre" [inicio,fim] e "aceito
  confirmar também" (mais cedo/mais tarde) — colunas hora_inicio_flex/hora_fim_flex.
- Zona cinza: quando o cliente pede um horário fora da janela livre, a Secretária IA
  NÃO agenda — registra um pedido (POST /pedido) e diz "vou confirmar e retorno".
- Fila da secretária humana: tabela agenda_pedidos + endpoints (listar/confirmar/
  recusar); tela PedidosPendentes (confirmar → vira agendamento real, com escolha do
  dentista); botão com BADGE de contagem na Agenda (poll a cada 30 min) para staff.

Testado em dev: dentista configura flex; IA registra pedido de 8h30 e responde "vou
verificar com a secretária"; humana confirma → agendamento criado.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 05:29:25 +02:00
VPS 4 Builder 773e8d4bf2 fix(agenda): fuso automático (America/Sao_Paulo) + dentista configura seus horários
- TZ=America/Sao_Paulo no backend (compose): a agenda deixa de rodar em UTC —
  horários/slots passam a ser Brasília automaticamente (corrige deslocamento de 3h).
- Acesso do DENTISTA: novo botão "Meus Horários" na agenda do dentista, abrindo
  a config direto nos horários/folgas DELE (HorariosConfig com initialTab/soDentista,
  restrito ao próprio registro). Antes só admin/donoclinica alcançavam a config.
- Config da clínica: gate passa a incluir donoconsultorio (dono de consultório).

Obs.: AgendaView.tsx também traz trabalho acumulado da agenda que estava no
working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 04:44:38 +02:00
VPS 4 Builder 0a324bf8c8 feat(secretaria): folga do dentista por data (A) + salas alugadas na agenda (B)
A — Folga/férias do dentista por DATA: tabela dentistas_folgas; o dentista (ou o
dono) marca dias em que não atende; o /slots pula o dentista nessas datas.
Endpoints /dentista/:id/folgas (GET/POST/DELETE) + UI na aba Dentistas.

B — Salas alugadas: o dentista com sala_reserva aprovada passa a ter horários
oferecidos pela Secretária no quarto (disponibilidade = janela da reserva,
casada pelo usuario_id). /slots ganha passada de salas (slot com sala_id +
local); /book grava agendamentos.sala_id (coluna aditiva). Geração passa a usar
CANDIDATE_CAP e ordena por horário (clínica e salas competem).

Testado em dev: folga pula os dias do dentista; reserva de sala gera slots com o
nome da sala.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 02:12:50 +02:00
VPS 4 Builder 77dec56dd0 feat(secretaria): horários de funcionamento + feriados na agenda/Secretária
A Secretária passa a saber quando a clínica está aberta e a NÃO oferecer
horário/agendar em dia fechado (domingo, fora do horário, feriado).

- feriados.js: feriados nacionais BR (fixos + móveis via Páscoa) + resolver
  com exceções da clínica.
- agenda-schema.js: tabelas clinicas_horarios (horário do dono) e feriados
  (exceções/fechamentos), idempotentes.
- agenda-config.js: CRUD com ownership (horário/feriado da clínica = dono;
  horário do dentista = ele mesmo ou o dono), em /api/nw/agenda-config.
- agenda-bridge.js: GET /status (aberto hoje?, motivo, próximo aberto, grade,
  feriados) + /slots agora = clínica ∩ dentista − feriado.
- HorariosConfig.tsx + botão no AgendaSettingsModal: telas de configuração
  (abas Clínica e Dentistas).

Testado em dev: a Secretária ofereceu "amanhã" ao pedirem "hoje" num domingo,
com os horários configurados da clínica.

Obs.: index.js também traz trabalho acumulado do satélite (proxy de mídia via
Wasabi/storage) que estava no working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 01:23:27 +02:00
VPS 4 Builder 03711e6280 feat(secretaria): agente/cérebro por número na aba Números
Adiciona o seletor "Agente / Cérebro deste número" no NumbersPanel: cada
número de WhatsApp passa a apontar para um agente (sec_numbers.agent_id),
tornando a Secretária, os nós do cérebro e a agenda separados POR SESSÃO.
Mostra o cérebro vinculado em cada card; tipos SecNumber.agent_id e
CalendarSlot.instance_id + param instance_id no calendar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:48:29 +02:00
VPS 4 Builder 09edeb9558 feat(newwhats): prévia via thumbnail Wasabi + guard de mídia irrecuperável
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m42s
- MessageBubble: carrega <url>.thumb.webp na prévia (fallback ao full via onError)
- mediaUnavailable proativo via flag media_recoverable (evita 400 em mídia legada)
- useNewInboxBridge: mapeia mediaRecoverable -> media_recoverable

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:05:42 +02:00
VPS 4 Builder 8b1801bd9c feat(agenda): layout em 2 colunas no desktop + sidebar recolhível
- AgendaView: controles migrados do header horizontal para uma coluna lateral
  esquerda (AGENDAR, ícones de ação em faixa horizontal de tamanho igual,
  presença, legenda de dentistas); calendário em flex-1 ocupando a altura toda
- Mobile revisto: cabeçalho compacto (título + AGENDAR) e calendário flex-1
- Sidebar do sistema recolhível no desktop (App: estado persistido + botão
  flutuante para reexibir); toggle fica ao lado do título "AGENDA CLÍNICA"
- Toolbar do FullCalendar compactada (título/botões/cabeçalhos menores)
- Título da agenda responsivo (não gera scroll horizontal na coluna estreita)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 02:32:54 +02:00
VPS 4 Builder c2a1f8f701 fix(newwhats): resolve conversa por 9º dígito e nome do paciente no slot da agenda
- WhatsChatDrawer: busca o chat pelos últimos 8 dígitos (o WhatsApp grava o jid BR
  sem o 9º dígito, ex. 556799591687), evitando falso "sem conversa"
- AgendaView: slot usa pacienteNome || pacientenome (backend retorna minúsculo),
  corrigindo o card que não exibia o nome do paciente

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:29:02 +02:00
VPS 4 Builder 21bebd3256 feat(newwhats): ownership de sessões, auto-provisionamento, assinatura e drawer na agenda
- Drawer de WhatsApp focado (WhatsChatDrawer) aberto do slot do agendamento, com
  dropdown de números do paciente + grupo familiar e fluxo de iniciar conversa
- Auto-provisionamento de conta no motor: eager no cadastro/convite + self-heal no
  proxy (404 "conta não encontrada" → provisiona e refaz o request)
- Ownership por sessão (wa_session_authz): dono do workspace (owner_id ou vínculo
  donoclinica/donoconsultorio) tem poder total; delega re-scan, excluir sessão,
  apagar conversa/mensagem, acesso ao Inbox e à Secretária — por conta
- Enforcement no proxy: guardSessionAction (scan/rescan/delete sessão),
  guardAreaAccess (inbox/secretaria), guardInboxDestructive (apagar conversa/msg)
- Endpoints /api/nw/authz (dono gerencia) e /api/nw/access (acesso agregado)
- Assinatura do operador nas mensagens (*Nome*, desambiguação de homônimos → *Rui C.*)
- UI: painel "Gerenciar acesso" com 5 toggles; menu e botões de apagar condicionados
- Proxy de mídia /api/nw/media + re-download sob demanda

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:26:31 +02:00
VPS 4 Builder 372431c0a1 style(newwhats): painel de agentes +20% no desktop e tela cheia no mobile
Painel do meio (selecionar agente) passa de 280px para 336px (md:) e, no mobile,
ocupa a tela (flex-1) com o painel principal oculto (hidden md:flex).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:20:03 +02:00
VPS 4 Builder ecf52d961a feat(newwhats): envia x-nw-clinica do workspace ativo (modelo de canal)
nwClient inclui o header x-nw-clinica (id do SCOREODONTO_ACTIVE_WORKSPACE) e o
proxy REST o encaminha ao motor — assim as ações manuais escopam a ponte de
agenda para a clínica do workspace ativo (por-requisição).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:19:44 +02:00
VPS 4 Builder 3e1001b7c7 style(newwhats): /wa-sessions com sidebar do scoreodonto + tema branco
- wa-sessions sai do isStandaloneView → renderiza com a sidebar (área de
  administração); wa-inbox/wa-secretaria seguem em tela cheia.
- SessionsView convertida do dark para o tema branco/profissional (bg-gray-50/
  white, text-gray-*, badges claros, botão Conectar em brand); backdrops de
  modal e botões coloridos preservados.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 03:55:16 +02:00
VPS 4 Builder 821ae591d4 style(newwhats): tema branco/profissional na Secretária (/wa-secretaria)
Converte a SecretariaView do dark para o tema claro do scoreodonto
(bg-gray-50/white, text-gray-*, border-gray-*), preservando botões brand,
backdrops de modal e acentos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:42:34 +02:00
VPS 4 Builder 9babc69172 feat(newwhats): ponte de agenda real (slots/book) para a Secretária
Expõe /api/nw/agenda/* (server-to-server, segredo compartilhado x-nw-agenda-secret,
preso ao clinica_id) para o motor operar na agenda REAL do scoreodonto:
- GET /slots — horários livres (dentistas_horarios ou fallback comercial seg–sex
  08–18/30min) menos os agendamentos existentes e o passado.
- POST /book — cria agendamento real com anti-overbooking (mesma regra do server)
  e find-or-create de paciente pelo telefone do WhatsApp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:42:33 +02:00
VPS 4 Builder 092635be5a fix(realtime): guarda cleanup de WebSocket em CONNECTING (StrictMode)
Fechar o socket ainda em CONNECTING (double-mount do StrictMode em dev) gerava
"WebSocket is closed before the connection is established". closeSocket() adia o
close para o onopen e neutraliza handlers; reconexão por queda inesperada intacta.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:54:24 +02:00
VPS 4 Builder 3042ddca38 feat(newwhats): frontend do plugin (Inbox/Sessions/Secretária) em tela cheia
- /wa-inbox, /wa-sessions e /wa-secretaria rodam sem a sidebar do scoreodonto
  (isStandaloneView); cada tela tem seu "Voltar" (Secretária ganhou botão na ThinNav).
- SessionsView: banner "Você já possui acesso ao WhatsApp" quando há sessão ativa.
- avatares: usa a URL do CDN (contactAvatarUrl/instance.avatar) direto; Avatar
  exibe sem depender de version; self-heal no onError re-busca a foto atual.
- deps: framer-motion, immer. Dev override com HMR (docker-compose.dev.yml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:54:24 +02:00
VPS 4 Builder 6593993b3d feat(newwhats): backend do satélite — proxy REST + túnel WS com chave mestra
- proxy REST resolve a conta SEMPRE por x-nw-email (email do JWT verificado);
  remove o fallback "sem email" (a chave mestra sem email dá 400 no motor).
- túnel WS (/api/nw/v1/stream) injeta x-nw-email no handshake e valida o JWT
  por ?token=; server.js roteia o upgrade (/api/ws do app + /stream do plugin).
- testMotorKey usa email-sonda (404 = chave mestra válida).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:52:18 +02:00
VPS 4 Builder 872acd800a fix(sidebar): itens WHATSAPP/SECRETÁRIA usam isPluginActive (ALWAYS_ON)
getActivePlugins() não inclui ALWAYS_ON_PLUGINS; o gate dos itens checava
activePluginIds.includes('newwhats') (sempre falso). Troca para isPluginActive
('newwhats'), que considera ALWAYS_ON — agora os menus aparecem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:14:59 +02:00
VPS 4 Builder 13af039555 feat(newwhats): pareamento por TOKEN — cole 1 token e auto-configura
- Backend POST /api/nw/config/token (superadmin): decodifica nwpair_<base64url({u,k,s})>,
  VALIDA no motor (/api/ext/v1/sessions) e salva URL+chave+webhook. Token inválido/
  sem conexão é rejeitado antes de salvar.
- Frontend: campo 'Token de pareamento' como caminho principal (textarea + Aplicar);
  estado da conexão; clínica no dropdown (local); campos manuais em 'configuração
  manual' (avançado).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:05:13 +02:00
VPS 4 Builder 9874eb4345 docs(newwhats): ponte de config concluída (DB-backed + UI dropdown + testar)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:57:30 +02:00
VPS 4 Builder abbd9374b4 feat(newwhats): config DB-backed + UI dedicada (dropdown de clínicas, testar conexão)
Backend:
- config.js: config lida da tabela settings (id=newwhats_config) com fallback ENV;
  loadConfigFromDb/saveConfigToDb + cache.
- index.js: endpoints superadmin GET/PUT /api/nw/config (secrets nunca em claro;
  branco mantém valor), GET /api/nw/clinicas (dropdown), GET /api/nw/status (testar
  conexão). server.js passa { superadminGuard }.
Frontend:
- PluginsView: NewwhatsConfigModal dedicado (carrega/salva no backend, dropdown de
  clínicas em vez de ID cru, botão Testar conexão). Substitui o form genérico p/ newwhats.

Resolve UX ruim: nada de digitar ID de clínica; config passa a funcionar de fato.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:55:54 +02:00
VPS 4 Builder 3751d84da8 feat(nginx): rota /nw/* -> backend (Secretária do motor consulta a clínica)
O motor NewWhats chama /nw/* no satélite (dados da clínica). nginx só roteava
/api/* ao backend; adiciona location /nw/ -> scoreodonto-backend:8018.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:15:03 +02:00
VPS 4 Builder 776c25c3de docs(newwhats): frontend (plugin/views) + pendências (proxy auth, ponte de config)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:59:08 +02:00
VPS 4 Builder b031117925 feat(frontend): plugin NewWhats — Inbox + Secretária (gating por papel)
- PluginsView: registra o plugin 'newwhats' (config só no catálogo, exclusivo do
  superadmin via view 'plugins' — já gateada em isViewAllowed).
- pluginRegistry: 'newwhats' em ALWAYS_ON (ativação por plugin é local/localStorage
  e não propaga; ALWAYS_ON garante Inbox/Secretária para TODOS os usuários).
- App.tsx: views wa-inbox/wa-secretaria (ViewKey, rotas, render); gating
  isPluginActive('newwhats') libera a qualquer usuário.
- Sidebar: itens WHATSAPP + SECRETÁRIA IA quando o plugin está ativo.
- Novas views consomem o proxy /api/nw/v1/* (motor ext-api). Estágio 1: funcional
  (lista/thread/envio; ask da secretária), visual aproximado do motor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:57:46 +02:00
VPS 4 Builder b21fb0f159 feat(newwhats): satélite WhatsApp/Secretária IA adaptado ao odonto
Clona o satélite do mercado adaptado ao scoreodonto (ESM, sem PluginManager):
- backend/newwhats/: config (ENV), auth (x-nw-key), routes /nw/* (clinica,
  especialidades, dentistas, procedimentos, planos — multi-tenant por clinica_id),
  sync-knowledge (base de conhecimento odonto → motor), webhook-receiver (HMAC,
  enxuto), proxy REST /api/nw/v1/* → motor /api/ext/v1/*, manifest, INTEGRACAO-MOTOR.md.
- server.js: registerNewwhats(app, pool) antes do listen.
- Removidos (não úteis): media-transcribe (motor já transcreve), smart-router,
  personas, follow-up-detector, context-builder.
- Config por ENV: NEWWHATS_URL/INTEGRATION_KEY/WEBHOOK_SECRET/CLINICA_ID.
- ativo::int=1 normaliza boolean vs smallint entre tabelas.
- Validado: buildKnowledgeText gera 1195 chars de dados reais; registro ok no express 5.2.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:38:36 +02:00
VPS 4 Builder bfba577080 feat(agenda): isolamento por setor (opt-in) — fecha vazamento horizontal intra-clínica
build-and-promote / build (push) Successful in 1m5s
build-and-promote / promote (push) Has been skipped
A agenda só isolava entre clínicas; dentro da mesma clínica, qualquer membro
via TUDO (todos os setores/equipes). Agora o setor passa a valer:

- agendamentos.setor_id + dentistas.setor_id (migration) + índice
  (clinica_id, setor_id, start_time).
- POST carimba o setor: override em dentistas.setor_id, senão o setor do
  VÍNCULO do próprio dentista (Gestão de Equipe, UI existente), senão o setor
  de quem agenda. NULL = "geral".
- GET filtra (resolverSetorUsuario): dono/admin e membro SEM setor veem tudo
  (compat — clínica que não usa setores não muda); membro COM setor vê só o
  seu setor + os gerais (NULL). Salas não filtram.

Equipe de protético/biomédico = setor próprio, herda a mesma regra. O
anti-overbooking continua GLOBAL e acima do isolamento (isola a visão, mantém
a integridade). Validado em dev: dono/sem-setor veem 3/3; setor A vê só A+geral;
setor B vê só B+geral.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:51:30 +02:00
VPS 4 Builder facf7b48c2 fix(agenda): blindagem multi-tenant — overbooking sem conta, race no PUT, privacidade e índices
build-and-promote / build (push) Successful in 1m2s
build-and-promote / promote (push) Has been skipped
Auditoria da agenda (clínicas/setores, profissional multi-local):

- anti-overbooking: agrupa registros do mesmo profissional por EMAIL quando
  usuario_id é NULL (dentista sem conta que atende fixo numa clínica e avulso
  em outras) — antes só agrupava por usuario_id e deixava passar duplo-agendamento.
- PUT (reagendar): checagem de conflito + UPDATE + auditoria agora numa única
  transação (FOR UPDATE efetivo) — fecha a race de dois reagendamentos no mesmo slot.
- privacidade entre tenants: resposta 409 deixa de expor id/clinica_id do
  compromisso alheio (helper slotOcupadoPublico) — só o horário.
- performance: índices (clinica_id,start_time) e (dentistaid,start_time) +
  janela ?from&to opcional no GET (retrocompatível).
- cadastro de dentista: e-mail obrigatório no backend (todo dentista precisa de
  conta/identidade global; protético interno sem conta usa outro fluxo).

Validado em dev.scoreodonto.com (pgdev). Produção intocada.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:45:01 +02:00
VPS 4 Builder d83e0c82b2 feat(ui): dropdowns viram modal com busca (SearchSelect) na Avaliação/GTO e Nova OS
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s
Mesmo padrão do seletor de protético, generalizado para os demais dropdowns:

- Componente SearchSelect (modal genérico: trigger + busca + lista filtrável por rótulo/hint).
- LancarGTO: Especialidade/Área, Produto/Prótese, Procedimento, Dentista e Convênio/Credenciada
  — todos os <select> convertidos (0 selects restantes).
- Nova OS (ProteseView): Produto e Dentista convertidos.
- Cotação (RFQ): input de busca na lista de laboratórios a convidar (continua multi-seleção).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:29:49 +02:00
VPS 4 Builder e6d8b532e3 feat(protese): seletor de protético em modal (busca, favorito/padrão, vincular por e-mail)
O dropdown só listava protéticos internos (vinculados) ou do diretório público — um protético com
conta fora do diretório (ex.: claudemir) não aparecia para a clínica. Reformulado:

- Componente ProteticoPicker (modal): busca por nome/e-mail; estrela de FAVORITO; marcar PADRÃO
  (pré-selecionado na próxima OS/GTO, só um por clínica); e "Adicionar por e-mail", que VINCULA um
  protético existente como interno — resolve o caso de não estar no diretório, sem expô-lo publicamente.
- Backend: tabela protese_protetico_pref (favorito/padrão por clínica); GET /protese/laboratorios
  passa a retornar favorito/padrao e ordena padrão→favorito→interno→nota→nome; POST .../:id/pref e
  POST /protese/laboratorios/vincular.
- Picker aplicado no LancarGTO (avaliação/GTO) e na NovaOSModal; ProteticoInternoInline (protético
  SEM conta) mantido ao lado.

Validado em DEV na CONSULTT CLINIC: claudemir ausente → vinculado por e-mail → aparece interno e,
como favorito+padrão, no topo da lista.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:18:32 +02:00
VPS 4 Builder 8353a9d897 feat(protese): identificação por Nome+Sobrenome com desambiguação de homônimos
O protético identifica o trabalho pelo nome do paciente (etiqueta, sem CPF — ele não atende
paciente). Quando dois trabalhos têm o mesmo nome+sobrenome, desambigua sem expor dado sensível:

- util pacienteLabel.ts (rotulosPaciente / homonimoAtivo): comparação normalizada
  (case/acento/espaço-insensitive). Em colisão: 1) diferencia pela ORIGEM (dentista, senão
  clínica) → "João Silva · Dr. Carlos"; 2) se mesma origem, nº sequencial estável por data.
- Bancada (ProteseView) e Painel do lab (LabHomeView/Próximas entregas): nome exibido já
  desambiguado em toda a fila — vale para OS nascidas na clínica ou no lab.
- Modal "Dar entrada": campo único trocado por Nome + Sobrenome(s); aviso em tempo real quando
  há homônimo ativo ("acrescente outro sobrenome para diferenciar"), informando a origem do
  conflito.

Validado em DEV (3 homônimos "João Silva": origem distinta e mesma origem → sequencial).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:03:24 +02:00
91 changed files with 18491 additions and 236 deletions
+70
View File
@@ -0,0 +1,70 @@
# Integração NewWhats (satélite) — scoreodonto.com ↔ Motor
> **Único deste projeto** (scoreodonto.com). Clonado e **adaptado ao domínio
> odonto** a partir do satélite do `mercado.clube67.com`. Backend **ESM** — sem o
> host de plugins do mercado; é um módulo registrado no `server.js`. v1.0.0.
## ⚠️ Regra de registro bidirecional (obrigatória)
- Toda mudança que toque o contrato (rotas, payloads, auth, webhook) **deve ser
registrada no motor** e na doc central
`documentos-unicos/projetos/newwhats.clube67.com/integracao-satelites.md`.
- Toda mudança no motor que afete o contrato **deve ser refletida aqui**.
- O satélite é um **braço inteligente do motor** — consome/alimenta, não duplica.
## Diferenças vs o satélite do mercado
- **ESM** (não CommonJS) e **sem PluginManager** — registrado via
`registerNewwhats(app, pool)` no `server.js`.
- **Config por ENV** (não tabela `plugin_configs`): `NEWWHATS_URL`,
`NEWWHATS_INTEGRATION_KEY`, `NEWWHATS_WEBHOOK_SECRET`, `NEWWHATS_CLINICA_ID`.
- **Negócio adaptado ao odonto**: `/nw/*` e `sync-knowledge` usam
clínica/especialidades/dentistas/procedimentos/planos (multi-tenant por
`clinica_id`), no lugar de clubes/sorteio/vagas.
- **Removidos** (não úteis aqui): `media-transcribe` (o motor já transcreve —
ver Fase 5), `smart-router`, `personas`, `follow-up-detector`, `context-builder`
(heurísticas específicas do mercado). Reavaliar se necessário.
## Arquivos
| Arquivo | Papel |
|---|---|
| `config.js` | lê a config da integração das ENV |
| `auth.js` | `nwAuth` — valida `x-nw-key`/Bearer nas rotas `/nw/*` |
| `routes.js` | `/nw/*` odonto: `/ping`, `/clinica`, `/especialidades`, `/dentistas`, `/procedimentos`, `/planos` |
| `sync-knowledge.js` | `buildKnowledgeText(pool, clinicaId)` + `syncKnowledge(pool)` → motor `/api/secretaria/*` |
| `webhook-receiver.js` | `POST /api/webhook/newwhats` (HMAC) — `message.new`, `session.status` (enxuto) |
| `proxy.js` | `/api/nw/v1/*` → motor `/api/ext/v1/*` (injeta `x-nw-key`) |
| `index.js` | `registerNewwhats(app, pool)` — registra tudo |
| `manifest.json` | metadados + `config_env` |
## Contrato (resumo)
- **Satélite → Motor**: proxy `/api/nw/v1/*` → ext-api `/api/ext/v1/*` (Bearer/x-nw-key);
`sync-knowledge``/api/secretaria/*`.
- **Motor → Satélite**: webhook `POST /api/webhook/newwhats` (HMAC `webhook_secret`);
Secretária consulta `/nw/*` (`integration_key`).
## Frontend (React SPA)
- Plugin `newwhats` registrado em `frontend/views/PluginsView.tsx` (catálogo
PLUGINS) — **config exclusiva do superadmin** (a view `plugins` já é gateada em
`App.tsx isViewAllowed`).
- `ALWAYS_ON` em `pluginRegistry.ts` → Inbox/Secretária aparecem para **todos** os
usuários (a ativação por plugin é localStorage e não propaga entre contas).
- Views: `frontend/views/WaInboxView.tsx` (conversas/thread/envio) e
`WaSecretariaView.tsx` (ask) — consomem `/api/nw/v1/*`. **Estágio 1** (funcional,
visual aproximado do motor; refinar para ficar idêntico).
- Sidebar: itens WHATSAPP + SECRETÁRIA IA (gated por `isPluginActive('newwhats')`).
## Pendências / TODO
- [ ] **Segurança**: gatear o proxy `/api/nw/v1/*` com a auth do scoreodonto (hoje
aberto — qualquer um que alcance a rota é proxiado ao motor).
- [x] **Ponte de config** (feito): config persiste na tabela `settings`
(`id=newwhats_config`); backend lê do DB com fallback ENV
(`config.js loadConfigFromDb`). UI dedicada (`NewwhatsConfigModal`) com
**dropdown de clínicas**, secrets que mantêm valor se em branco e **Testar
conexão** (`GET /api/nw/status`). Endpoints superadmin: `GET/PUT /api/nw/config`,
`GET /api/nw/clinicas`, `GET /api/nw/status`.
- [ ] `webhook message.new`: capturar lead / acionar fluxo (hoje só loga).
- [ ] Proxy WS (`/api/nw/v1/stream`) — só o REST foi portado (Inbox sem tempo real).
- [ ] Parear scoreodonto com o motor (criar apiKey/pair + agente) e preencher as ENV.
- [ ] Endpoint/admin para disparar `syncKnowledge` (hoje exportado, sem rota).
- [ ] Refinar Inbox/Secretária para ficarem idênticas ao motor.
> Fonte da verdade do contrato: `documentos-unicos/projetos/newwhats.clube67.com/`.
+694
View File
@@ -0,0 +1,694 @@
// Ponte de agenda: expõe /api/nw/agenda/* para o MOTOR (tools da Secretária)
// operarem na agenda REAL do scoreodonto — em vez do calendário isolado do motor.
//
// Segurança: chamada server-to-server autenticada por segredo compartilhado
// (header x-nw-agenda-secret === webhookSecret configurado). Todo acesso é
// preso ao clinica_id enviado (o motor conhece a clínica da instância).
//
// Endpoints:
// GET /slots — horários livres (jornada dos dentistas agendamentos)
// POST /book — cria agendamento real (anti-overbooking + vínculo de paciente)
import express from 'express';
import { getConfig } from './config.js';
import { cautelasParaIA } from './checklist-defs.js';
import { resolverFeriado, feriadoNacionalNome } from './feriados.js';
const SLOT_MIN = 60; // duração/espaçamento padrão do slot (1h entre atendimentos)
const HORIZON_DAYS = 7; // janela padrão de busca
const MAX_SLOTS = 20; // quantos slots RETORNAR (após ordenar por horário)
const CANDIDATE_CAP = 240; // teto de GERAÇÃO (clínica + salas competem por horário)
// Fallback quando a CLÍNICA não configurou horário de funcionamento: comercial
// padrão (segsex 0818). Uma vez configurado (clinicas_horarios), manda ele.
const DEFAULT_INI = '08:00';
const DEFAULT_FIM = '18:00';
const DIAS_NOME = ['domingo', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado'];
const pad = (n) => String(n).padStart(2, '0');
const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const fmt = (d) => `${ymd(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
const hm = (t) => String(t ?? '').slice(0, 5); // 'HH:MM:SS'|Date → 'HH:MM'
// Interseção de duas listas de janelas [ini,fim] (strings 'HH:MM'). Retorna as
// sobreposições (ex.: clínica 0818 ∩ dentista 1320 = 1318).
function interseccao(a, b) {
const out = [];
for (const [ai, af] of a) for (const [bi, bf] of b) {
const ini = ai > bi ? ai : bi;
const fim = af < bf ? af : bf;
if (ini < fim) out.push([ini, fim]);
}
return out;
}
// Carrega o horário de FUNCIONAMENTO da clínica → { byDow: Map<dow,[[ini,fim]]>, hasConfig }.
async function carregarHorariosClinica(pool, clinicaId) {
const byDow = new Map();
let hasConfig = false;
try {
const { rows } = await pool.query(
`SELECT dia_semana, hora_inicio, hora_fim FROM clinicas_horarios WHERE clinica_id = $1 AND ativo = 1`,
[clinicaId]);
hasConfig = rows.length > 0;
for (const r of rows) {
const arr = byDow.get(r.dia_semana) || [];
arr.push([hm(r.hora_inicio), hm(r.hora_fim)]);
byDow.set(r.dia_semana, arr);
}
} catch { /* tabela pode não existir ainda → cai no fallback */ }
return { byDow, hasConfig };
}
// Carrega as folgas (por data) dos dentistas da clínica → Map<dentista_id,[{ini,fim}]>.
async function carregarFolgas(pool, clinicaId) {
const byDent = new Map();
try {
const { rows } = await pool.query(
`SELECT f.dentista_id, to_char(f.data_inicio,'YYYY-MM-DD') AS ini, to_char(f.data_fim,'YYYY-MM-DD') AS fim
FROM dentistas_folgas f JOIN dentistas d ON d.id = f.dentista_id
WHERE d.clinica_id = $1`, [clinicaId]);
for (const r of rows) {
const arr = byDent.get(r.dentista_id) || [];
arr.push([r.ini, r.fim]);
byDent.set(r.dentista_id, arr);
}
} catch { /* tabela pode não existir ainda */ }
return byDent;
}
const emFolga = (folgas, dateStr) => (folgas || []).some(([i, f]) => dateStr >= i && dateStr <= f);
// Janelas de funcionamento da CLÍNICA num dia específico, já considerando feriado.
// Retorna { janelas: [[ini,fim]], fechado: bool, motivo: string|null }.
async function janelasClinicaNoDia(pool, clinicaId, dateStr, dow, clinicHours) {
const fer = await resolverFeriado(pool, clinicaId, dateStr);
if (fer && fer.fecha) return { janelas: [], fechado: true, motivo: `Feriado: ${fer.nome}` };
// Feriado com horário especial (fecha=0 + horas) sobrepõe o horário normal.
if (fer && !fer.fecha && fer.hora_inicio && fer.hora_fim) {
return { janelas: [[hm(fer.hora_inicio), hm(fer.hora_fim)]], fechado: false, motivo: `Horário especial (${fer.nome})` };
}
// Horário normal do dia da semana.
let janelas;
if (clinicHours.hasConfig) {
janelas = (clinicHours.byDow.get(dow) || []).map(([i, f]) => [i, f]);
} else {
janelas = (dow >= 1 && dow <= 5) ? [[DEFAULT_INI, DEFAULT_FIM]] : [];
}
if (!janelas.length) return { janelas: [], fechado: true, motivo: DIAS_NOME[dow] === 'domingo' || DIAS_NOME[dow] === 'sábado' ? `Fechado (${DIAS_NOME[dow]})` : 'Fechado neste dia' };
return { janelas, fechado: false, motivo: null };
}
// 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, 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];
const uid = dr[0]?.usuario_id || null;
const email = (dr[0]?.email || '').trim().toLowerCase() || null;
if (uid) {
const { rows } = await db.query('SELECT id FROM dentistas WHERE usuario_id = $1', [uid]);
if (rows.length) ids = rows.map((r) => r.id);
} else if (email) {
const { rows } = await db.query('SELECT id FROM dentistas WHERE lower(trim(email)) = $1', [email]);
if (rows.length) ids = rows.map((r) => r.id);
}
const { rows } = await db.query(
`SELECT id FROM agendamentos
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, excludeId]);
return rows[0] || null;
}
export function createAgendaBridge(pool) {
const router = express.Router();
// Auth server-to-server por segredo compartilhado.
router.use((req, res, next) => {
const secret = getConfig().webhookSecret;
if (!secret || req.headers['x-nw-agenda-secret'] !== secret) {
return res.status(401).json({ error: 'Segredo da ponte inválido' });
}
next();
});
// ── GET /slots ──────────────────────────────────────────────────────────────
// Query: clinica_id (obrig.), dentista_id?, date? (YYYY-MM-DD), days?, duration?
router.get('/slots', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null;
const duration = Math.max(10, parseInt(req.query.duration, 10) || SLOT_MIN);
const days = Math.min(30, Math.max(1, parseInt(req.query.days, 10) || HORIZON_DAYS));
const from = req.query.date ? new Date(`${req.query.date}T00:00:00`) : new Date();
const periodo = req.query.periodo ? String(req.query.periodo).toLowerCase() : null; // manha|tarde
try {
// Dentistas no escopo da clínica.
const dParams = [clinicaId];
let dWhere = 'clinica_id = $1';
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({ slots: [], motivo: 'Nenhum dentista cadastrado na clínica.' });
// Jornada dos DENTISTAS (dentistas_horarios) — opcional, por dentista.
const { rows: horarios } = await pool.query(
`SELECT dentista_id, dia_semana, hora_inicio, hora_fim
FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1`, [clinicaId]);
const hoursBy = new Map();
for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push(h); hoursBy.set(h.dentista_id, a); }
// Horário de FUNCIONAMENTO da clínica (config do dono) — a "casca" externa.
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
// 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);
const end0 = new Date(start0); end0.setDate(end0.getDate() + days);
const { rows: busy } = await pool.query(
`SELECT dentistaid, 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); }
const now = new Date();
const slots = [];
for (let i = 0; i < days && slots.length < CANDIDATE_CAP; i++) {
const day = new Date(start0); day.setDate(day.getDate() + i);
const dow = day.getDay();
// Janelas da CLÍNICA no dia (já considera feriado). Fechado → pula o dia.
const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, ymd(day), dow, clinicHours);
if (fechado || !clinicWins.length) continue;
for (const dent of dentistas) {
if (slots.length >= CANDIDATE_CAP) break;
if (emFolga(folgasBy.get(dent.id), ymd(day))) continue; // dentista de folga nesse dia
const cfg = hoursBy.get(dent.id);
// Dentista: se configurou jornada, usa a do dia; senão, atende sempre
// que a clínica está aberta. Efetivo = CLÍNICA ∩ DENTISTA.
const dentWins = (cfg && cfg.length)
? cfg.filter((h) => h.dia_semana === dow).map((h) => [hm(h.hora_inicio), hm(h.hora_fim)])
: clinicWins;
const windows = interseccao(clinicWins, dentWins);
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() + duration * 60000 <= limit.getTime() && slots.length < CANDIDATE_CAP) {
const s = new Date(t);
const e = new Date(t.getTime() + duration * 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;
slots.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e) });
}
}
}
}
// ── B (salas): slots do dentista num QUARTO alugado (sala_reservas aprovada).
// Disponibilidade = janela da reserva [inicio,fim]; casa o dentista da clínica
// pelo usuario_id. Respeita folgas e agendamentos existentes (por dentista).
if (slots.length < CANDIDATE_CAP) {
try {
const { rows: reservas } = await pool.query(
`SELECT r.sala_id, r.inicio, r.fim, s.nome AS sala_nome, d.id AS dentista_id, d.nome AS dentista_nome
FROM sala_reservas r
JOIN salas s ON s.id = r.sala_id
JOIN dentistas d ON d.usuario_id = r.profissional_usuario_id
WHERE d.clinica_id = $1
AND lower(coalesce(r.status,'')) NOT IN ('cancelada','cancelado','recusada','recusado','rejeitada','pendente','negada')
AND r.fim > $2 AND r.inicio < $3
ORDER BY r.inicio`,
[clinicaId, start0.toISOString(), end0.toISOString()]);
const step = duration * 60000;
for (const rv of reservas) {
if (slots.length >= CANDIDATE_CAP) break;
const bs = busyBy.get(rv.dentista_id) || [];
const rIni = new Date(rv.inicio), rFim = new Date(rv.fim);
let t = new Date(Math.max(rIni.getTime(), start0.getTime(), now.getTime()));
const off = (t.getTime() - rIni.getTime()) % step;
if (off !== 0) t = new Date(t.getTime() + (step - off)); // alinha ao grid da reserva
while (t.getTime() + step <= rFim.getTime() && slots.length < CANDIDATE_CAP) {
const s = new Date(t), e = new Date(t.getTime() + step); t = e;
if (s <= now) continue;
if (emFolga(folgasBy.get(rv.dentista_id), ymd(s))) continue;
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
slots.push({ dentista_id: rv.dentista_id, dentista_nome: rv.dentista_nome, start: fmt(s), end: fmt(e), sala_id: rv.sala_id, local: rv.sala_nome });
}
}
} catch { /* sala_reservas pode não existir */ }
}
let out = slots.sort((a, b) => a.start.localeCompare(b.start));
// Filtro manhã/tarde (a Secretária pergunta o período antes de oferecer).
if (periodo === 'manha' || periodo === 'manhã') out = out.filter((s) => parseInt(s.start.slice(11, 13), 10) < 12);
else if (periodo === 'tarde') out = out.filter((s) => parseInt(s.start.slice(11, 13), 10) >= 12);
res.json({ slots: out.slice(0, MAX_SLOTS) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── GET /status ──────────────────────────────────────────────────────────────
// Situação de funcionamento da clínica p/ a Secretária SABER quando responder
// "estamos fechados". Query: clinica_id (obrig.), date? (YYYY-MM-DD, def: hoje BR).
// Retorna: hoje (aberto/motivo/horários), próximo dia aberto, grade da semana e
// próximos feriados.
router.get('/status', async (req, res) => {
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).
const hojeBR = req.query.date
? String(req.query.date)
: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Sao_Paulo' }); // 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); };
const statusDoDia = async (str) => {
const dow = dateAt(str).getDay();
const r = await janelasClinicaNoDia(pool, clinicaId, str, dow, clinicHours);
return {
data: str, dia_semana: dow, dia_nome: DIAS_NOME[dow],
aberto: !r.fechado && r.janelas.length > 0, motivo: r.motivo,
horarios: r.janelas.map(([i, f]) => ({ inicio: i, fim: f })),
};
};
// Grade dos próximos 7 dias (inclui hoje).
const semana = [];
for (let k = 0; k < 7; k++) semana.push(await statusDoDia(addYmd(hojeBR, k)));
const hoje = semana[0];
// Próximo dia aberto: primeiro aberto na semana; se nenhum, varre +14.
let proximo = semana.find((s) => s.aberto) || null;
if (!proximo) for (let k = 7; k <= 21; k++) { const s = await statusDoDia(addYmd(hojeBR, k)); if (s.aberto) { proximo = s; break; } }
// Próximos feriados (45 dias) — clínica (1 query) + nacionais (em memória).
const feriados = [];
let excMap = new Map();
try {
const { rows } = await pool.query(
`SELECT data, nome, fecha, recorrente FROM feriados WHERE clinica_id = $1`, [clinicaId]);
for (const r of rows) {
const ds = (r.data instanceof Date) ? ymd(r.data) : String(r.data).slice(0, 10);
excMap.set(ds, r);
if (r.recorrente === 1) excMap.set('R' + ds.slice(5), r);
}
} catch { /* tabela pode não existir */ }
for (let k = 0; k < 45; k++) {
const str = addYmd(hojeBR, k);
const exc = excMap.get(str) || excMap.get('R' + str.slice(5));
if (exc) { if (exc.fecha !== 0) feriados.push({ data: str, nome: exc.nome, origem: 'clinica' }); continue; }
const nac = feriadoNacionalNome(str);
if (nac) feriados.push({ data: str, nome: nac, origem: 'nacional' });
}
res.json({ hoje, proximo_aberto: proximo, semana, feriados_proximos: feriados.slice(0, 8) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── POST /pedido ──────────────────────────────────────────────────────────────
// A Secretária IA registra um pedido de horário FORA da janela livre (zona cinza:
// mais cedo/mais tarde) para a SECRETÁRIA HUMANA confirmar. A IA não agenda esses.
// Body: { clinica_id, data (YYYY-MM-DD), hora (HH:MM), hora_fim?, dentista_nome?,
// nome_cliente, telefone?, procedimento? }
router.post('/pedido', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.data || !b.hora) return res.status(400).json({ error: 'clinica_id, data e hora obrigatórios' });
try {
// Dia totalmente fechado (feriado/fechamento) → não adianta criar pedido.
const fer = await resolverFeriado(pool, b.clinica_id, b.data);
if (fer && fer.fecha) return res.json({ ok: false, motivo: `Fechado nesse dia (${fer.nome}).` });
// Resolve o dentista pelo nome (opcional).
let dentistaId = null;
if (b.dentista_nome) {
const { rows } = await pool.query(
`SELECT id FROM dentistas WHERE clinica_id = $1 AND lower(nome) LIKE '%'||lower($2)||'%' LIMIT 1`,
[b.clinica_id, b.dentista_nome]);
dentistaId = rows[0]?.id || null;
}
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await pool.query(
`INSERT INTO agenda_pedidos (id, clinica_id, dentista_id, data, hora_inicio, hora_fim, paciente_nome, paciente_telefone, procedimento, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pendente')`,
[id, b.clinica_id, dentistaId, b.data, hm(b.hora), b.hora_fim ? hm(b.hora_fim) : null,
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null, b.procedimento || null]);
res.json({ ok: true, pedido_id: id });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /duvida ──────────────────────────────────────────────────────────────
// Encaminha uma DÚVIDA/caso incerto à secretária humana (sem agendar). Cria um
// agenda_pedidos tipo='duvida' com o resumo; entra na mesma fila de pendentes.
router.post('/duvida', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.resumo) return res.status(400).json({ error: 'clinica_id e resumo obrigatórios' });
try {
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await pool.query(
`INSERT INTO agenda_pedidos (id, clinica_id, tipo, resumo, paciente_nome, paciente_telefone, status)
VALUES ($1,$2,'duvida',$3,$4,$5,'pendente')`,
[id, b.clinica_id, String(b.resumo).slice(0, 2000),
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null]);
res.json({ ok: true, pedido_id: id });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /contexto ─────────────────────────────────────────────────────────────
// Contexto clínico p/ a Secretária: dentistas + especialidades + "situações"
// (regras da clínica). Query: clinica_id.
router.get('/contexto', 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: dentistas } = await pool.query(
`SELECT nome, especialidade, especialidades FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0 ORDER BY nome`, [clinicaId]);
let situacoes = [];
try {
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 */ }
// Cautelas do checklist do dono (declaração × dados reais) → a IA não chuta.
let cautelas = [];
try {
const respostas = {};
const { rows } = await pool.query(`SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1`, [clinicaId]);
for (const r of rows) respostas[r.chave] = r.resposta == null ? null : Number(r.resposta);
const nPac = await pool.query(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`, [clinicaId]).then((x) => Number(x.rows[0]?.n || 0)).catch(() => 0);
cautelas = cautelasParaIA(respostas, { dentistas: dentistas.length, pacientes: nPac });
} catch { /* checklist novo/indisponível */ }
res.json({
dentistas: dentistas.map((d) => ({
nome: d.nome,
especialidades: Array.isArray(d.especialidades) ? d.especialidades : (d.especialidade ? [d.especialidade] : []),
})),
situacoes,
cautelas,
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /dentista-contato ─────────────────────────────────────────────────────
// Resolve o WhatsApp de um dentista da clínica (Parte A: a IA aciona o Dr).
// Query: clinica_id (obrig.), nome? OU dentista_id?
router.get('/dentista-contato', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
try {
const params = [clinicaId]; let where = 'clinica_id = $1';
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);
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 });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /book ────────────────────────────────────────────────────────────────
// Body: { clinica_id, dentista_id, start, end?, paciente_nome, paciente_celular, procedimento? }
router.post('/book', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.dentista_id || !b.start) {
return res.status(400).json({ error: 'clinica_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 {
await client.query('BEGIN');
const conflict = await findConflict(client, b.dentista_id, b.start, end);
if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário já ocupado.', conflict_id: conflict.id }); }
// Vincula paciente pelo telefone (escopo da clínica; casa pelos últimos 8 dígitos).
let pacienteId = null;
let pacienteCriado = false;
if (b.paciente_celular) {
const tel = String(b.paciente_celular).replace(/\D/g, '').slice(-8);
if (tel) {
const { rows } = await client.query(
`SELECT id FROM pacientes
WHERE clinica_id = $1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2 LIMIT 1`,
[b.clinica_id, tel]);
pacienteId = rows[0]?.id || null;
}
}
// Não encontrado → cria um paciente mínimo (lead do WhatsApp vira paciente).
if (!pacienteId && b.paciente_nome) {
const pid = `pac_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
await client.query(
'INSERT INTO pacientes (id, nome, telefone, clinica_id) VALUES ($1,$2,$3,$4)',
[pid, b.paciente_nome, b.paciente_celular || null, b.clinica_id]);
pacienteId = pid;
pacienteCriado = true;
}
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 id = `ag_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
const { rows: ins } = await client.query(
`INSERT INTO agendamentos
(id, clinica_id, pacientenome, pacientecelular, pacienteid, dentistaid, procedimento,
start_time, end_time, status, setor_id, created_by_nome, created_at, updated_at, sala_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'agendado',$10,'Secretária IA',NOW(),NOW(),$11)
RETURNING id, start_time, end_time, dentistaid`,
[id, b.clinica_id, b.paciente_nome || 'Cliente WhatsApp', b.paciente_celular || null,
pacienteId, b.dentista_id, b.procedimento || 'Consulta', b.start, end, setorId, b.sala_id || null]);
await client.query('COMMIT');
res.json({ ok: true, agendamento: ins[0], paciente_vinculado: !!pacienteId, paciente_criado: pacienteCriado });
} catch (e) {
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
res.status(500).json({ error: e.message });
} finally {
client.release();
}
});
// ── 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) {
if (!dob) return null;
const s = String(dob).trim();
let d = null, m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) d = new Date(+m[1], +m[2] - 1, +m[3]);
else { m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/); if (m) d = new Date(+m[3], +m[2] - 1, +m[1]); }
if (!d || isNaN(d.getTime())) return null;
const n = new Date(); let a = n.getFullYear() - d.getFullYear();
const mo = n.getMonth() - d.getMonth();
if (mo < 0 || (mo === 0 && n.getDate() < d.getDate())) a--;
return a >= 0 && a < 130 ? a : null;
}
// GET /pacientes/lookup?clinica_id=&phone= — quem está cadastrado NESTE número (a família).
router.get('/pacientes/lookup', 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' });
try {
const { rows } = await pool.query(
`SELECT id, nome, cpf, telefone, datanascimento, convenio, grupofamiliarid, grupofamiliarrelacao
FROM pacientes
WHERE clinica_id = $1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2
ORDER BY (grupofamiliarrelacao = 'titular' OR grupofamiliarrelacao IS NULL) DESC, nome`,
[clinicaId, tel]);
res.json({
encontrados: rows.length,
pacientes: rows.map((p) => ({
id: p.id, nome: p.nome, cpf: p.cpf || null,
data_nascimento: p.datanascimento || null, idade: idadeDe(p.datanascimento),
convenio: p.convenio || null, relacao: p.grupofamiliarrelacao || null, grupo_id: p.grupofamiliarid || null,
})),
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /pacientes — cadastra/atualiza. Dedup por CPF; idade por nascimento; grupo familiar
// (dependente vinculado ao titular via grupofamiliarid = id do titular).
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' });
const cpf = soDigitos(b.cpf);
const telefone = b.telefone ? soDigitos(b.telefone) : null;
const idade = idadeDe(b.data_nascimento);
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1) Dedup forte por CPF (identificador confiável — evita duplicar quem já existe).
let existente = null;
if (cpf) {
const { rows } = await client.query(
`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(cpf,''),'\\D','','g')=$2 LIMIT 1`, [b.clinica_id, cpf]);
existente = rows[0] || null;
}
// 2) Resolve o RESPONSÁVEL (para dependentes): por id, cpf ou telefone (o titular do número).
let responsavel = null;
if (b.responsavel_id) {
const { rows } = await client.query('SELECT * FROM pacientes WHERE id=$1 AND clinica_id=$2', [b.responsavel_id, b.clinica_id]); responsavel = rows[0] || null;
} else if (b.responsavel_cpf) {
const { rows } = await client.query(`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(cpf,''),'\\D','','g')=$2 LIMIT 1`, [b.clinica_id, soDigitos(b.responsavel_cpf)]); responsavel = rows[0] || null;
} else if (b.responsavel_telefone) {
const { rows } = await client.query(
`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2
AND (grupofamiliarrelacao='titular' OR grupofamiliarrelacao IS NULL) ORDER BY nome LIMIT 1`,
[b.clinica_id, soDigitos(b.responsavel_telefone).slice(-8)]); responsavel = rows[0] || null;
}
// Dependente = tem responsável OU menor de 18 (padrão) sem marcar eh_titular.
const ehDependente = !!responsavel || (idade != null && idade < 18 && !b.eh_titular);
let relacao = b.relacao || (ehDependente ? (idade != null && idade < 18 ? 'filho(a)' : 'dependente') : (b.eh_titular ? 'titular' : null));
// Telefone do dependente sem número próprio → herda o do responsável (compartilhado).
let telFinal = telefone;
if (ehDependente && !telFinal && responsavel && responsavel.telefone) telFinal = soDigitos(responsavel.telefone);
// 3) Grupo familiar: titular vira cabeça (grupofamiliarid = id do titular) + grupos_familiares.
let grupoId = null;
if (ehDependente && responsavel) {
grupoId = responsavel.grupofamiliarid || responsavel.id;
if (!responsavel.grupofamiliarid) {
await client.query(`UPDATE pacientes SET grupofamiliarid=$1, grupofamiliarrelacao=COALESCE(grupofamiliarrelacao,'titular') WHERE id=$1`, [responsavel.id]);
}
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.
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`,
[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)`,
[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 } });
} catch (e) {
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
res.status(500).json({ error: e.message });
} finally { client.release(); }
});
return router;
}
+404
View File
@@ -0,0 +1,404 @@
// Config de horários/feriados — API para o FRONTEND do scoreodonto (dono/dentista).
// Auth: JWT do scoreodonto (Authorization: Bearer). Ownership:
// - Horário da CLÍNICA + feriados → só o DONO (clinicas.owner_id / vínculo dono).
// - Horário do DENTISTA → o próprio dentista (dentistas.usuario_id) OU o dono.
//
// Montado em /api/nw/agenda-config pelo registerNewwhats.
import express from 'express';
import jwt from 'jsonwebtoken';
import { feriadosNacionais } from './feriados.js';
import { CHECKLIST_QUESTOES, pendenciasDeConfig } from './checklist-defs.js';
function authFromReq(req) {
try {
const tk = (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim();
if (!tk) return null;
const p = jwt.verify(tk, process.env.JWT_SECRET);
return { userId: p.userId || null, email: (p.email || '').toLowerCase() || null };
} catch { return null; }
}
// É dono do workspace (clínica)? owner_id direto OU vínculo dono/admin.
async function isDono(pool, clinicaId, userId) {
if (!clinicaId || !userId) return false;
try {
const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
if (cl[0]?.owner_id && cl[0].owner_id === userId) return true;
const { rows: v } = await pool.query(
`SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2
AND role IN ('donoclinica','donoconsultorio','admin') LIMIT 1`, [clinicaId, userId]);
return v.length > 0;
} catch { return false; }
}
const uid = (p) => `${p}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const hhmm = (v) => { const m = String(v ?? '').match(/^(\d{1,2}):(\d{2})/); return m ? `${m[1].padStart(2, '0')}:${m[2]}` : null; };
export function createAgendaConfig(pool) {
const router = express.Router();
router.use((req, res, next) => {
const auth = authFromReq(req);
if (!auth) return res.status(401).json({ error: 'Sessão inválida.' });
req.nwUser = auth;
next();
});
// ── Horário de FUNCIONAMENTO da clínica (dono) ────────────────────────────────
router.get('/clinica/:clinicaId/horarios', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, dia_semana, to_char(hora_inicio,'HH24:MI') AS hora_inicio,
to_char(hora_fim,'HH24:MI') AS hora_fim, ativo
FROM clinicas_horarios WHERE clinica_id = $1 ORDER BY dia_semana, hora_inicio`,
[req.params.clinicaId]);
res.json({ horarios: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Substitui TODA a grade da clínica (mais simples e previsível que patch por dia).
// Body: { horarios: [{ dia_semana, hora_inicio, hora_fim }] }
router.put('/clinica/:clinicaId/horarios', 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 o horário da clínica.' });
const linhas = Array.isArray(req.body?.horarios) ? req.body.horarios : [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM clinicas_horarios WHERE clinica_id = $1', [clinicaId]);
for (const h of linhas) {
const dow = Number(h.dia_semana); const ini = hhmm(h.hora_inicio); const fim = hhmm(h.hora_fim);
if (!(dow >= 0 && dow <= 6) || !ini || !fim || ini >= fim) continue;
await client.query(
`INSERT INTO clinicas_horarios (id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo)
VALUES ($1,$2,$3,$4,$5,1)`, [uid('ch'), clinicaId, dow, ini, fim]);
}
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(); }
});
// ── Horário do DENTISTA (o próprio dentista ou o dono) ────────────────────────
router.get('/dentista/:dentistaId/horarios', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, dia_semana, to_char(hora_inicio,'HH24:MI') AS hora_inicio,
to_char(hora_fim,'HH24:MI') AS hora_fim,
to_char(hora_inicio_flex,'HH24:MI') AS hora_inicio_flex,
to_char(hora_fim_flex,'HH24:MI') AS hora_fim_flex, ativo
FROM dentistas_horarios WHERE dentista_id = $1 ORDER BY dia_semana, hora_inicio`,
[req.params.dentistaId]);
res.json({ horarios: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.put('/dentista/:dentistaId/horarios', async (req, res) => {
const dentistaId = req.params.dentistaId;
let dent;
try { const { rows } = await pool.query('SELECT clinica_id, usuario_id FROM dentistas WHERE id = $1', [dentistaId]); dent = rows[0]; }
catch (e) { return res.status(500).json({ error: e.message }); }
if (!dent) return res.status(404).json({ error: 'Dentista não encontrado.' });
const ehProprio = dent.usuario_id && dent.usuario_id === req.nwUser.userId;
const ehDono = await isDono(pool, dent.clinica_id, req.nwUser.userId);
if (!ehProprio && !ehDono)
return res.status(403).json({ error: 'Só o próprio dentista (ou o dono) pode configurar estes horários.' });
const linhas = Array.isArray(req.body?.horarios) ? req.body.horarios : [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM dentistas_horarios WHERE dentista_id = $1', [dentistaId]);
for (const h of linhas) {
const dow = Number(h.dia_semana); const ini = hhmm(h.hora_inicio); const fim = hhmm(h.hora_fim);
if (!(dow >= 0 && dow <= 6) || !ini || !fim || ini >= fim) continue;
// Janela estendida (zona cinza): flex_inicio ≤ inicio e flex_fim ≥ fim.
let fIni = hhmm(h.hora_inicio_flex), fFim = hhmm(h.hora_fim_flex);
if (fIni && fIni >= ini) fIni = null; // só vale se for MAIS cedo
if (fFim && fFim <= fim) fFim = null; // só vale se for MAIS tarde
await client.query(
`INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, hora_inicio_flex, hora_fim_flex, ativo)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,1)`, [uid('dh'), dentistaId, dent.clinica_id, dow, ini, fim, fIni, fFim]);
}
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(); }
});
// ── Folgas/férias do DENTISTA por data (o próprio dentista ou o dono) ─────────
async function guardDentista(pool, dentistaId, user) {
const { rows } = await pool.query('SELECT clinica_id, usuario_id FROM dentistas WHERE id = $1', [dentistaId]);
const dent = rows[0];
if (!dent) return { ok: false, code: 404, msg: 'Dentista não encontrado.' };
const ehProprio = dent.usuario_id && dent.usuario_id === user.userId;
const ehDono = await isDono(pool, dent.clinica_id, user.userId);
if (!ehProprio && !ehDono) return { ok: false, code: 403, msg: 'Só o próprio dentista (ou o dono) pode alterar.' };
return { ok: true, dent };
}
router.get('/dentista/:dentistaId/folgas', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, to_char(data_inicio,'YYYY-MM-DD') AS data_inicio, to_char(data_fim,'YYYY-MM-DD') AS data_fim, motivo
FROM dentistas_folgas WHERE dentista_id = $1 AND data_fim >= CURRENT_DATE ORDER BY data_inicio`,
[req.params.dentistaId]);
res.json({ folgas: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Body: { data_inicio (YYYY-MM-DD), data_fim? (def = inicio), motivo? }
router.post('/dentista/:dentistaId/folgas', async (req, res) => {
const g = await guardDentista(pool, req.params.dentistaId, req.nwUser).catch((e) => ({ ok: false, code: 500, msg: e.message }));
if (!g.ok) return res.status(g.code).json({ error: g.msg });
const b = req.body || {};
if (!b.data_inicio) return res.status(400).json({ error: 'data_inicio obrigatória' });
const fim = b.data_fim && b.data_fim >= b.data_inicio ? b.data_fim : b.data_inicio;
try {
const { rows } = await pool.query(
`INSERT INTO dentistas_folgas (id, dentista_id, clinica_id, data_inicio, data_fim, motivo)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING id, to_char(data_inicio,'YYYY-MM-DD') AS data_inicio, to_char(data_fim,'YYYY-MM-DD') AS data_fim, motivo`,
[uid('fol'), req.params.dentistaId, g.dent.clinica_id, b.data_inicio, fim, b.motivo || null]);
res.status(201).json(rows[0]);
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/dentista/:dentistaId/folgas/:id', async (req, res) => {
const g = await guardDentista(pool, req.params.dentistaId, req.nwUser).catch((e) => ({ ok: false, code: 500, msg: e.message }));
if (!g.ok) return res.status(g.code).json({ error: g.msg });
try { await pool.query('DELETE FROM dentistas_folgas WHERE id = $1 AND dentista_id = $2', [req.params.id, req.params.dentistaId]); res.json({ ok: true }); }
catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Feriados/fechamentos (dono) ───────────────────────────────────────────────
// Lista os NACIONAIS (computados) + as exceções da clínica de um ano.
router.get('/clinica/:clinicaId/feriados', async (req, res) => {
const clinicaId = req.params.clinicaId;
const ano = Number(req.query.ano) || new Date().getFullYear();
try {
const nac = Object.entries(feriadosNacionais(ano)).map(([data, nome]) => ({ data, nome, origem: 'nacional' }));
const { rows: exc } = await pool.query(
`SELECT id, to_char(data,'YYYY-MM-DD') AS data, nome, fecha,
to_char(hora_inicio,'HH24:MI') AS hora_inicio, to_char(hora_fim,'HH24:MI') AS hora_fim, recorrente
FROM feriados WHERE clinica_id = $1 AND (extract(year from data) = $2 OR recorrente = 1) ORDER BY data`,
[clinicaId, ano]);
res.json({ nacionais: nac, excecoes: exc.map((e) => ({ ...e, origem: 'clinica' })) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Cria uma exceção da clínica (fechamento/ponte/horário especial). Dono.
// Body: { data (YYYY-MM-DD), nome, fecha?(bool), hora_inicio?, hora_fim?, recorrente?(bool) }
router.post('/clinica/:clinicaId/feriados', 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 feriados/fechamentos.' });
const b = req.body || {};
if (!b.data || !b.nome) return res.status(400).json({ error: 'data e nome obrigatórios' });
const fecha = b.fecha === false ? 0 : 1;
const ini = fecha ? null : hhmm(b.hora_inicio);
const fim = fecha ? null : hhmm(b.hora_fim);
try {
const { rows } = await pool.query(
`INSERT INTO feriados (id, clinica_id, data, nome, fecha, hora_inicio, hora_fim, recorrente)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING id, to_char(data,'YYYY-MM-DD') AS data, nome, fecha, recorrente`,
[uid('fer'), clinicaId, b.data, b.nome, fecha, ini, fim, b.recorrente ? 1 : 0]);
res.status(201).json(rows[0]);
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/clinica/:clinicaId/feriados/:id', async (req, res) => {
if (!(await isDono(pool, req.params.clinicaId, req.nwUser.userId)))
return res.status(403).json({ error: 'Apenas o dono pode remover feriados/fechamentos.' });
try {
await pool.query('DELETE FROM feriados WHERE id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Situações / regras da clínica (dono) — a "área de situações" da Secretária ─
router.get('/clinica/:clinicaId/situacoes', async (req, res) => {
try {
const { rows } = await pool.query(`SELECT id, texto, ativo, ordem FROM agenda_situacoes WHERE clinica_id = $1 ORDER BY ordem, created_at`, [req.params.clinicaId]);
res.json({ situacoes: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Substitui toda a lista. Body: { situacoes: [texto] }
router.put('/clinica/:clinicaId/situacoes', 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 as situações.' });
const linhas = Array.isArray(req.body?.situacoes) ? req.body.situacoes : [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM agenda_situacoes WHERE clinica_id = $1', [clinicaId]);
let i = 0;
for (const s of linhas) {
const t = String(s || '').trim(); if (!t) continue;
await client.query(`INSERT INTO agenda_situacoes (id, clinica_id, texto, ativo, ordem) VALUES ($1,$2,$3,1,$4)`, [uid('sit'), clinicaId, t, 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) {
const q = async (sql) => { try { const { rows } = await pool.query(sql, [clinicaId]); return Number(rows[0]?.n || 0); } catch { return 0; } };
const dentistas = await q(`SELECT COUNT(*) n FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0`);
const pacientes = await q(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`);
return { dentistas, pacientes };
}
async function respostasChecklist(clinicaId) {
const map = {};
try { const { rows } = await pool.query('SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1', [clinicaId]); for (const r of rows) map[r.chave] = r.resposta == null ? null : Number(r.resposta); } catch { /* tabela nova */ }
return map;
}
router.get('/clinica/:clinicaId/checklist', 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 acessa o checklist.' });
try {
const map = await respostasChecklist(clinicaId);
res.json({ questoes: CHECKLIST_QUESTOES.map((q) => ({ ...q, resposta: map[q.chave] ?? null })) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Salva respostas. Body: { respostas: { chave: 1|0|null } }
router.put('/clinica/:clinicaId/checklist', 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 o checklist.' });
const respostas = req.body?.respostas || {};
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const q of CHECKLIST_QUESTOES) {
if (!(q.chave in respostas)) continue;
const v = respostas[q.chave];
const r = v === 1 || v === true ? 1 : v === 0 || v === false ? 0 : null;
await client.query(
`INSERT INTO agenda_checklist (clinica_id, chave, resposta, updated_at) VALUES ($1,$2,$3,now())
ON CONFLICT (clinica_id, chave) DO UPDATE SET resposta = EXCLUDED.resposta, updated_at = now()`,
[clinicaId, q.chave, r]);
}
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(); }
});
// Área de pendências do DONO — engloba TUDO: pendências de config (checklist ×
// banco) + a fila operacional (pedidos de horário + dúvidas aguardando humano).
router.get('/clinica/:clinicaId/pendencias', 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 acessa as pendências.' });
try {
const counts = await contagens(clinicaId);
const respostas = await respostasChecklist(clinicaId);
const config = pendenciasDeConfig(respostas, counts);
let horarios = 0, duvidas = 0;
try {
const { rows } = await pool.query(
`SELECT COALESCE(tipo,'horario') tipo, COUNT(*) n FROM agenda_pedidos WHERE clinica_id = $1 AND status = 'pendente' GROUP BY 1`, [clinicaId]);
for (const r of rows) { if (r.tipo === 'duvida') duvidas = Number(r.n); else horarios = Number(r.n); }
} catch { /* tabela nova */ }
res.json({
contagens: counts,
config,
operacionais: { horarios, duvidas, total: horarios + duvidas },
total: config.length + horarios + duvidas,
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Pedidos pendentes (zona cinza) — a secretária humana confirma/recusa ──────
async function ehMembro(clinicaId, userId) {
if (await isDono(pool, clinicaId, userId)) return true;
try { const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2 LIMIT 1', [clinicaId, userId]); return rows.length > 0; }
catch { return false; }
}
router.get('/clinica/:clinicaId/pedidos', async (req, res) => {
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
const status = req.query.status ? String(req.query.status) : 'pendente';
try {
const { rows } = await pool.query(
`SELECT p.id, COALESCE(p.tipo,'horario') AS tipo, p.resumo,
to_char(p.data,'YYYY-MM-DD') AS data, to_char(p.hora_inicio,'HH24:MI') AS hora_inicio,
to_char(p.hora_fim,'HH24:MI') AS hora_fim, p.paciente_nome, p.paciente_telefone, p.procedimento,
p.dentista_id, d.nome AS dentista_nome, p.status, p.created_at
FROM agenda_pedidos p LEFT JOIN dentistas d ON d.id = p.dentista_id
WHERE p.clinica_id = $1 AND p.status = $2 ORDER BY p.created_at DESC LIMIT 100`,
[req.params.clinicaId, status]);
res.json({ pedidos: rows, total: rows.length });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Confirma → cria o agendamento real e marca o pedido como confirmado.
// Body: { dentista_id? (se o pedido não tinha), duracao_min? }
router.post('/clinica/:clinicaId/pedidos/:id/confirmar', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await ehMembro(clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query('SELECT * FROM agenda_pedidos WHERE id = $1 AND clinica_id = $2 FOR UPDATE', [req.params.id, clinicaId]);
const ped = rows[0];
if (!ped) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Pedido não encontrado.' }); }
if (ped.status !== 'pendente') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Pedido já resolvido.' }); }
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`;
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]);
const agId = `ag_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await client.query(
`INSERT INTO agendamentos (id, clinica_id, pacientenome, pacientecelular, dentistaid, procedimento,
start_time, end_time, status, setor_id, sala_id, created_by_nome, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'agendado',$9,$10,'Secretária (confirmado)',NOW(),NOW())`,
[agId, clinicaId, ped.paciente_nome || 'Cliente WhatsApp', ped.paciente_telefone || null, dentistaId,
ped.procedimento || 'Consulta', start, end, dd[0]?.setor_id || null, ped.sala_id || null]);
await client.query(`UPDATE agenda_pedidos SET status='confirmado', agendamento_id=$1, resolved_at=NOW(), resolved_by=$2 WHERE id=$3`,
[agId, req.nwUser.userId, req.params.id]);
await client.query('COMMIT');
res.json({ ok: true, agendamento_id: agId });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// Resolve uma DÚVIDA (tipo='duvida') sem criar agendamento — a humana já
// decidiu/atendeu (ou vai agendar manualmente uma avaliação).
router.post('/clinica/:clinicaId/pedidos/:id/resolver', async (req, res) => {
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
try {
await pool.query(`UPDATE agenda_pedidos SET status='resolvido', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/clinica/:clinicaId/pedidos/:id/recusar', async (req, res) => {
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
try {
await pool.query(`UPDATE agenda_pedidos SET status='recusado', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Lista dentistas da clínica (p/ a UI do dono escolher quem configurar / e o
// dentista achar seu registro). Retorna também se o usuário logado é o dentista.
router.get('/clinica/:clinicaId/dentistas', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, nome, usuario_id, ativo FROM dentistas WHERE clinica_id = $1 ORDER BY ordem NULLS LAST, nome`,
[req.params.clinicaId]);
res.json({ dentistas: rows.map((d) => ({ ...d, eh_voce: d.usuario_id === req.nwUser.userId })) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
return router;
}
+118
View File
@@ -0,0 +1,118 @@
// Schema (idempotente) das tabelas de horário/feriado usadas pela Secretária.
// Chamado uma vez no registerNewwhats. Espelha os tipos de `dentistas_horarios`.
//
// clinicas_horarios — horário de FUNCIONAMENTO da clínica (config do DONO).
// feriados — EXCEÇÕES da clínica (fechamento/ponte/horário especial);
// os feriados NACIONAIS são computados (ver feriados.js).
export async function ensureAgendaSchema(pool) {
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS clinicas_horarios (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
dia_semana integer NOT NULL, -- 0=domingo … 6=sábado
hora_inicio time without time zone NOT NULL,
hora_fim time without time zone NOT NULL,
ativo smallint DEFAULT 1
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_clinicas_horarios_clinica ON clinicas_horarios (clinica_id)`);
await pool.query(`
CREATE TABLE IF NOT EXISTS feriados (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
data date NOT NULL,
nome varchar NOT NULL,
fecha smallint DEFAULT 1, -- 1=fechado o dia; 0=abre em horário especial
hora_inicio time without time zone, -- horário especial (quando fecha=0)
hora_fim time without time zone,
recorrente smallint DEFAULT 0, -- 1=repete todo ano (mesmo dia/mês)
created_at timestamptz DEFAULT now()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_feriados_clinica_data ON feriados (clinica_id, data)`);
// ── dentistas_folgas — folga/férias/indisponibilidade por DATA do dentista.
// O dentista (ou o dono) marca dias em que NÃO atende; o /slots pula.
await pool.query(`
CREATE TABLE IF NOT EXISTS dentistas_folgas (
id varchar PRIMARY KEY,
dentista_id varchar NOT NULL,
clinica_id varchar,
data_inicio date NOT NULL,
data_fim date NOT NULL, -- inclusive; = data_inicio p/ 1 dia
motivo varchar,
created_at timestamptz DEFAULT now()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_dentistas_folgas_dent ON dentistas_folgas (dentista_id, data_inicio, data_fim)`);
// ── B (salas): agendamentos.sala_id — permite agendar num quarto alugado.
// Aditivo/nullable: NÃO afeta o fluxo existente (clínica) quando nulo.
await pool.query(`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS sala_id varchar`);
// ── Janela ESTENDIDA do dentista (zona cinza): a IA agenda livre em
// [hora_inicio, hora_fim]; entre a estendida e a livre exige confirmação
// humana. Nulo = sem zona cinza (só a janela livre).
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_inicio_flex time without time zone`);
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_fim_flex time without time zone`);
// ── agenda_pedidos — pedidos da ZONA CINZA aguardando a secretária humana.
// A IA cria (status 'pendente') e diz "vou confirmar"; a humana confirma
// (vira agendamento) ou recusa.
await pool.query(`
CREATE TABLE IF NOT EXISTS agenda_pedidos (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
dentista_id varchar,
sala_id varchar,
data date NOT NULL,
hora_inicio time without time zone NOT NULL,
hora_fim time without time zone,
paciente_nome varchar,
paciente_telefone varchar,
procedimento varchar,
origem varchar DEFAULT 'secretaria_ia',
status varchar DEFAULT 'pendente', -- pendente | confirmado | recusado
agendamento_id varchar, -- preenchido ao confirmar
created_at timestamptz DEFAULT now(),
resolved_at timestamptz,
resolved_by varchar
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_pedidos_clinica_status ON agenda_pedidos (clinica_id, status, created_at)`);
// Pedido também serve para DÚVIDAS (sem horário): tipo + resumo; data/hora nullable.
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS tipo varchar DEFAULT 'horario'`); // horario | duvida
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS resumo text`);
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN data DROP NOT NULL`).catch(() => {});
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN hora_inicio DROP NOT NULL`).catch(() => {});
// ── agenda_situacoes — "área de situações": regras da clínica que a Secretária
// IA deve seguir (ex.: "plano X não cobre canal posterior — ofereça avaliação").
await pool.query(`
CREATE TABLE IF NOT EXISTS agenda_situacoes (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
texto text NOT NULL,
ativo smallint DEFAULT 1,
ordem integer DEFAULT 0,
created_at timestamptz DEFAULT now()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_situacoes_clinica ON agenda_situacoes (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.
await pool.query(`
CREATE TABLE IF NOT EXISTS agenda_checklist (
clinica_id varchar NOT NULL,
chave varchar NOT NULL,
resposta smallint,
updated_at timestamptz DEFAULT now(),
PRIMARY KEY (clinica_id, chave)
)`);
return true;
} catch (e) {
// Não derruba o boot do satélite se o banco estiver indisponível no momento.
console.error('[newwhats] ensureAgendaSchema falhou:', e.message);
return false;
}
}
+25
View File
@@ -0,0 +1,25 @@
// Middleware de auth das rotas /nw/* — valida a integration_key enviada pelo
// motor NewWhats. Aceita header x-nw-key ou Authorization: Bearer <key>.
import { timingSafeEqual } from 'crypto'
import { getConfig } from './config.js'
export function nwAuth(req, res, next) {
const key = req.headers['x-nw-key']
|| (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim()
if (!key) {
return res.status(401).json({ error: 'Chave de integração ausente. Use o header x-nw-key.' })
}
const stored = getConfig().integrationKey
if (!stored) {
return res.status(503).json({ error: 'Integração NewWhats não configurada (NEWWHATS_INTEGRATION_KEY).' })
}
const a = Buffer.from(stored)
const b = Buffer.from(key)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(403).json({ error: 'Chave de integração inválida.' })
}
next()
}
+99
View File
@@ -0,0 +1,99 @@
// Checklist do DONO do workspace: perguntas Sim/Não sobre a clínica que a
// Secretária IA usa para saber quando PODE agendar sozinha e quando deve ter
// cautela. As respostas são conferidas contra o banco (dentistas/pacientes):
// quando a declaração do dono não bate com os dados, geramos uma PENDÊNCIA
// para ele configurar/cadastrar — e a IA fica cautelosa (encaminha ao humano).
//
// Fonte única: config (UI), consistência (pendências) e injeção no cérebro
// (cautelas) consomem esta mesma definição para nunca divergirem.
export const CHECKLIST_QUESTOES = [
{
chave: 'multi_dentista',
pergunta: 'A clínica tem mais de um dentista?',
ajuda: 'Se sim, a Secretária não pode assumir um único dentista nem "clínico geral" por padrão — precisa saber quem atende cada paciente.',
},
{
chave: 'pacientes_cadastrados',
pergunta: 'Os pacientes já estão cadastrados no sistema (/pacientes)?',
ajuda: 'Mesmo que sim, o cadastro pode estar INCOMPLETO (nem todos os familiares). A Secretária confirma a identidade antes de agendar e encaminha ao humano quando não reconhece quem fala.',
},
{
chave: 'telefone_compartilhado',
pergunta: 'Um mesmo telefone pode ser de vários pacientes (família)?',
ajuda: 'Ex.: o número está no cadastro do filho, mas quem manda mensagem é a mãe. Se sim, a Secretária SEMPRE confirma com quem está falando e para quem é o atendimento — nunca assume pelo número.',
},
{
chave: 'dentista_fixo',
pergunta: 'Cada paciente tem um dentista de referência (fixo)?',
ajuda: 'Se sim, um paciente de retorno deve voltar para o MESMO dentista — a IA não deve agendá-lo no clínico geral.',
},
];
const sim = (r, k) => r[k] === 1;
const nao = (r, k) => r[k] === 0;
// Pendências de CONFIG do dono: a declaração não bate com o banco.
// counts = { dentistas, pacientes }.
export function pendenciasDeConfig(respostas, counts) {
const r = respostas || {};
const dentistas = Number(counts?.dentistas || 0);
const pacientes = Number(counts?.pacientes || 0);
const P = [];
if (sim(r, 'multi_dentista') && dentistas <= 1)
P.push({ chave: 'multi_dentista', severidade: 'alta', titulo: 'Cadastre os demais dentistas',
detalhe: `Você indicou que a clínica tem mais de um dentista, mas há apenas ${dentistas} cadastrado. A Secretária não consegue direcionar cada paciente ao dentista certo — cadastre os outros dentistas.` });
if (nao(r, 'multi_dentista') && dentistas > 1)
P.push({ chave: 'multi_dentista', severidade: 'media', titulo: 'Confirme quantos dentistas atendem',
detalhe: `Você indicou um único dentista, mas há ${dentistas} cadastrados no sistema. Ajuste a resposta ou os cadastros.` });
if (sim(r, 'pacientes_cadastrados') && pacientes === 0)
P.push({ chave: 'pacientes_cadastrados', severidade: 'alta', titulo: 'Cadastre os pacientes',
detalhe: 'Você indicou que os pacientes estão cadastrados, mas não há nenhum paciente no sistema. A Secretária não reconhece quem já é paciente.' });
if (r['pacientes_cadastrados'] == null || nao(r, 'pacientes_cadastrados'))
P.push({ chave: 'pacientes_cadastrados', severidade: 'media', titulo: 'Complete o cadastro de pacientes',
detalhe: 'Enquanto os pacientes não estiverem cadastrados, a Secretária vai encaminhar todo retorno para a secretária humana confirmar (não agenda sozinha).' });
if (sim(r, 'dentista_fixo') && dentistas <= 1 && sim(r, 'multi_dentista'))
P.push({ chave: 'dentista_fixo', severidade: 'media', titulo: 'Cadastre os dentistas de referência',
detalhe: 'Você indicou que cada paciente tem um dentista fixo, mas o sistema ainda não tem os dentistas todos cadastrados para fazer esse vínculo.' });
if (sim(r, 'telefone_compartilhado'))
P.push({ chave: 'telefone_compartilhado', severidade: 'media', titulo: 'Complete o cadastro de cada familiar',
detalhe: 'Como um mesmo telefone pode ser de vários pacientes (ex.: cadastrado só no filho, mas quem fala é a mãe), a Secretária vai SEMPRE confirmar quem está falando e não assume pelo número. Mantenha cada familiar cadastrado e vinculado ao número para ela acertar mais e encaminhar menos ao humano.' });
return P;
}
// Cautelas para injetar no cérebro da IA (texto direto no system prompt).
export function cautelasParaIA(respostas, counts) {
const r = respostas || {};
const dentistas = Number(counts?.dentistas || 0);
const pacientes = Number(counts?.pacientes || 0);
const C = [];
const multi = sim(r, 'multi_dentista') || dentistas > 1;
const temPacientes = pacientes > 0 || sim(r, 'pacientes_cadastrados');
const telShared = sim(r, 'telefone_compartilhado') || (temPacientes && r['telefone_compartilhado'] == null);
if (multi)
C.push('A clínica tem MAIS DE UM dentista: NUNCA assuma um dentista nem "clínico geral" por padrão para quem já é paciente. Descubra/pergunte qual dentista atende. Sem certeza → encaminhe ao humano.');
if (sim(r, 'multi_dentista') && dentistas <= 1)
C.push('ATENÇÃO: o sistema ainda não tem todos os dentistas cadastrados. Para pacientes de retorno ou que pedem um dentista específico, encaminhe ao humano — NÃO agende no clínico geral.');
if (sim(r, 'dentista_fixo'))
C.push('Cada paciente tem um dentista fixo: um retorno deve voltar ao MESMO dentista. Se você não sabe qual é (falta cadastro), encaminhe ao humano.');
// Identidade de quem fala — o cadastro é incompleto e o telefone é ambíguo.
if (temPacientes)
C.push('IDENTIDADE: o cadastro pode estar INCOMPLETO. Ao achar um paciente pelo telefone, NÃO assuma que é quem está falando — confirme o NOME de quem fala e PARA QUEM é o atendimento antes de qualquer coisa.');
if (telShared)
C.push('TELEFONE COMPARTILHADO: um mesmo número pode ser de vários da família (ex.: está no cadastro do filho, mas quem fala é a mãe; ou perguntam pelo tratamento do marido). SEMPRE pergunte com quem você fala e de quem é o atendimento; se a pessoa/atendimento não bater com o cadastro do número, encaminhe ao humano — não agende no paciente errado.');
if (pacientes === 0 || nao(r, 'pacientes_cadastrados'))
C.push('O cadastro de pacientes está incompleto: você NÃO consegue confirmar histórico nem o dentista de quem diz já ser paciente. Nesses casos, encaminhe ao humano em vez de agendar.');
// Profissional citado que não existe na lista de dentistas (sempre possível).
C.push('PROFISSIONAL NÃO CADASTRADO: se o cliente citar um dentista/profissional que NÃO está na lista de dentistas acima (ex.: prótese com um Dr. de fora), NÃO invente nem agende — encaminhe ao humano com encaminhar_humano e diga NO RESUMO que o profissional citado não está cadastrado, para o dono cadastrá-lo.');
return C;
}
+64
View File
@@ -0,0 +1,64 @@
// Config da integração com o motor NewWhats.
// Fonte: tabela `settings` (id='newwhats_config') — editável pelo super-admin na UI.
// Fallback: variáveis de ambiente (NEWWHATS_*), úteis para bootstrap/deploy.
//
// NEWWHATS_URL / motorUrl URL do motor
// NEWWHATS_INTEGRATION_KEY / integrationKey x-nw-key
// NEWWHATS_WEBHOOK_SECRET / webhookSecret HMAC do webhook
// NEWWHATS_CLINICA_ID / clinicaId clínica que este satélite atende
let cache = null; // { motorUrl, integrationKey, webhookSecret, clinicaId } | null
function fromEnv() {
return {
motorUrl: (process.env.NEWWHATS_URL || '').replace(/\/$/, ''),
integrationKey: process.env.NEWWHATS_INTEGRATION_KEY || '',
webhookSecret: process.env.NEWWHATS_WEBHOOK_SECRET || '',
clinicaId: process.env.NEWWHATS_CLINICA_ID || '',
};
}
// DB (se houver valor) tem prioridade; ENV preenche o que faltar.
export function getConfig() {
const env = fromEnv();
if (!cache) return env;
return {
motorUrl: (cache.motorUrl || env.motorUrl || '').replace(/\/$/, ''),
integrationKey: cache.integrationKey || env.integrationKey || '',
webhookSecret: cache.webhookSecret || env.webhookSecret || '',
clinicaId: cache.clinicaId || env.clinicaId || '',
};
}
export function isConfigured() {
const c = getConfig();
return Boolean(c.motorUrl && c.integrationKey);
}
// Carrega a config do banco para o cache (chamado no boot e após salvar).
export async function loadConfigFromDb(pool) {
try {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'newwhats_config'");
cache = rows[0]?.data || {};
} catch {
if (!cache) cache = {};
}
return getConfig();
}
// Persiste no banco e atualiza o cache.
export async function saveConfigToDb(pool, cfg) {
const data = {
motorUrl: (cfg.motorUrl || '').replace(/\/$/, ''),
integrationKey: cfg.integrationKey || '',
webhookSecret: cfg.webhookSecret || '',
clinicaId: cfg.clinicaId || '',
};
await pool.query(
`INSERT INTO settings (id, category, data) VALUES ('newwhats_config', 'integration', $1)
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data`,
[JSON.stringify(data)]
);
cache = data;
return getConfig();
}
+92
View File
@@ -0,0 +1,92 @@
// Feriados nacionais brasileiros (fixos + móveis) + resolução com exceções da
// clínica (tabela `feriados`). Usado pela agenda-bridge para a Secretária saber
// quando é feriado e o /slots pular esses dias.
//
// Fixos: datas nacionais. Móveis: derivados da Páscoa (Computus de Meeus).
// Consciência Negra (20/11) é nacional desde 2024 (Lei 14.759/2023).
const pad = (n) => String(n).padStart(2, '0');
const ymd = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;
// Domingo de Páscoa (algoritmo de Meeus/Jones/Butcher) — retorna {mes, dia}.
function pascoa(ano) {
const a = ano % 19;
const b = Math.floor(ano / 100);
const c = ano % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const mes = Math.floor((h + l - 7 * m + 114) / 31);
const dia = ((h + l - 7 * m + 114) % 31) + 1;
return { mes, dia };
}
// Soma dias a uma data (UTC, evita fuso) → 'YYYY-MM-DD'.
function addDias(y, m, d, delta) {
const base = new Date(Date.UTC(y, m - 1, d));
base.setUTCDate(base.getUTCDate() + delta);
return ymd(base.getUTCFullYear(), base.getUTCMonth() + 1, base.getUTCDate());
}
// Mapa { 'YYYY-MM-DD': 'Nome do feriado' } dos feriados NACIONAIS do ano.
export function feriadosNacionais(ano) {
const p = pascoa(ano);
const py = ano, pm = p.mes, pd = p.dia;
const map = {
[ymd(ano, 1, 1)]: 'Confraternização Universal',
[ymd(ano, 4, 21)]: 'Tiradentes',
[ymd(ano, 5, 1)]: 'Dia do Trabalho',
[ymd(ano, 9, 7)]: 'Independência do Brasil',
[ymd(ano, 10, 12)]: 'Nossa Senhora Aparecida',
[ymd(ano, 11, 2)]: 'Finados',
[ymd(ano, 11, 15)]: 'Proclamação da República',
[ymd(ano, 11, 20)]: 'Consciência Negra',
[ymd(ano, 12, 25)]: 'Natal',
// Móveis (relativos ao Domingo de Páscoa)
[addDias(py, pm, pd, -48)]: 'Carnaval (segunda-feira)',
[addDias(py, pm, pd, -47)]: 'Carnaval (terça-feira)',
[addDias(py, pm, pd, -2)]: 'Sexta-feira Santa',
[addDias(py, pm, pd, 60)]: 'Corpus Christi',
};
return map;
}
// Nome do feriado nacional numa data 'YYYY-MM-DD', ou null.
export function feriadoNacionalNome(dateStr) {
const ano = Number(String(dateStr).slice(0, 4));
if (!ano) return null;
return feriadosNacionais(ano)[dateStr] ?? null;
}
// Resolve o feriado de uma data para uma clínica: nacional OU exceção da clínica
// (tabela `feriados`, que também pode ANULAR um dia — ver `fecha`). Retorna
// { fecha: bool, nome, hora_inicio?, hora_fim? } ou null (dia normal).
//
// Regras:
// - Exceção da clínica tem prioridade (permite abrir num feriado nacional, ou
// fechar num dia comum, ou definir horário especial).
// - Sem exceção: cai no feriado nacional (fecha o dia inteiro).
export async function resolverFeriado(pool, clinicaId, dateStr) {
try {
const mmdd = String(dateStr).slice(5); // 'MM-DD'
const { rows } = await pool.query(
`SELECT nome, fecha, hora_inicio, hora_fim FROM feriados
WHERE clinica_id = $1
AND (data = $2 OR (recorrente = 1 AND to_char(data,'MM-DD') = $3))
ORDER BY (data = $2) DESC LIMIT 1`,
[clinicaId, dateStr, mmdd]);
if (rows.length) {
const r = rows[0];
return { fecha: r.fecha !== false, nome: r.nome || 'Fechamento', hora_inicio: r.hora_inicio || null, hora_fim: r.hora_fim || null, origem: 'clinica' };
}
} catch { /* tabela pode não existir ainda */ }
const nac = feriadoNacionalNome(dateStr);
if (nac) return { fecha: true, nome: nac, hora_inicio: null, hora_fim: null, origem: 'nacional' };
return null;
}
+182
View File
@@ -0,0 +1,182 @@
// Integração NewWhats (satélite) para o scoreodonto — ESM, adaptada do mercado.
// Registra rotas no app Express existente.
//
// Uso no server.js:
// import { registerNewwhats } from './newwhats/index.js'
// registerNewwhats(app, pool, { superadminGuard })
import { createRoutes } from './routes.js'
import { createWebhookReceiver } from './webhook-receiver.js'
import { createRestProxy, createAccountsRoute, provisionNewwhatsAccount } from './proxy.js'
import { getConfig, isConfigured, loadConfigFromDb, saveConfigToDb } from './config.js'
import { Readable } from 'node:stream'
export { syncKnowledge, buildKnowledgeText } from './sync-knowledge.js'
export { createWsTunnel } from './proxy.js'
// Provisiona (eager) a conta do email no motor NewWhats, best-effort e idempotente.
// Chamado no cadastro de usuário/clínica do scoreodonto: "assim que a conta nasce
// aqui, já nasce lá — pronta esperando quando a pessoa for mexer com WhatsApp".
export async function provisionNewwhatsAccountEager(pool, email) {
try {
const cfg = getConfig()
if (!cfg.motorUrl || !cfg.integrationKey || !email) return false
return await provisionNewwhatsAccount(pool, cfg, email)
} catch { return false }
}
import { createAgendaBridge } from './agenda-bridge.js'
import { createAgendaConfig } from './agenda-config.js'
import { ensureAgendaSchema } from './agenda-schema.js'
// Valida uma chave contra o motor. Envia um email-sonda (a chave mestra exige
// x-nw-email). 200 = ok com dados; 404 = chave válida mas email-sonda inexistente
// (= chave OK); 401 = chave inválida.
async function testMotorKey(motorUrl, key) {
try {
const r = await fetch(`${motorUrl}/api/ext/v1/sessions`, {
headers: { 'x-nw-key': key, 'x-nw-email': 'probe@nw.local' },
});
if (r.ok || r.status === 404) return { ok: true, detail: 'Conexão OK' };
return { ok: false, detail: `Motor respondeu ${r.status}` };
} catch (e) {
return { ok: false, detail: `Motor inacessível: ${e.message}` };
}
}
export function registerNewwhats(app, pool, opts = {}) {
// passthrough middleware caso o guard não seja fornecido (dev)
const superadminGuard = opts.superadminGuard || ((_req, _res, next) => next());
// ── Ponte de agenda (motor → agenda real do scoreodonto) ───────────────────
// Auth própria por segredo compartilhado (x-nw-agenda-secret). Server-to-server.
app.use('/api/nw/agenda', createAgendaBridge(pool));
// ── Schema de horários/feriados (idempotente) + config (dono/dentista) ─────
// clinicas_horarios + feriados; usados pela agenda-bridge (Secretária) e pelas
// telas de configuração do scoreodonto.
ensureAgendaSchema(pool).catch((e) => console.error('[newwhats] schema agenda:', e?.message));
app.use('/api/nw/agenda-config', createAgendaConfig(pool));
// ── Proxy de MÍDIA: /api/nw/media/* → motor ────────────────────────────────
// A mídia/avatar vivem no motor (Wasabi ou local). O Wasabi é servido por
// /api/storage/view/* (plugin UnifiedStorageProvider, que cobre Wasabi + fallback
// local); a mídia local ANTIGA fica no /media estático. Tentamos o storage do
// plugin primeiro e caímos no /media se der 404. Same-origin no <img>/<video>.
app.use('/api/nw/media', async (req, res) => {
if (req.method !== 'GET') { res.status(405).end(); return; }
const cfg = getConfig();
if (!cfg.motorUrl) { res.status(503).end(); return; }
const rest = req.path.replace(/^\//, '');
const base = cfg.motorUrl.replace(/\/$/, '');
const headers = {};
if (req.headers.range) headers.range = req.headers.range;
const candidates = [`${base}/api/storage/view/${rest}`, `${base}/media/${rest}`];
try {
let r = null;
for (const url of candidates) {
r = await fetch(url, { headers });
if (r.status !== 404) break; // achou (ou erro que não é 404) → usa esse
}
res.status(r.status);
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'cache-control', 'content-disposition']) {
const v = r.headers.get(h);
if (v) res.setHeader(h, v);
}
// Mídia é imutável (path único por arquivo) → cache longo no browser.
if (!res.getHeader('cache-control')) res.setHeader('cache-control', 'public, max-age=604800, immutable');
if (r.body) Readable.fromWeb(r.body).pipe(res);
else res.end();
} catch (e) {
res.status(502).json({ error: `Mídia indisponível: ${e.message}` });
}
});
// Carrega a config do banco para o cache (não bloqueia o boot).
loadConfigFromDb(pool).then((c) =>
console.log(`[newwhats] config carregada — ${isConfigured() ? 'configurada' : 'AGUARDANDO config'} (clinica=${c.clinicaId || '—'})`)
).catch(() => {});
// ── Config (super-admin) ───────────────────────────────────────────────────
// GET: nunca devolve os segredos em claro — só se estão definidos.
app.get('/api/nw/config', superadminGuard, (_req, res) => {
const c = getConfig();
res.json({
motorUrl: c.motorUrl,
clinicaId: c.clinicaId,
hasIntegrationKey: Boolean(c.integrationKey),
hasWebhookSecret: Boolean(c.webhookSecret),
configured: isConfigured(),
});
});
// PUT: secrets em branco MANTÊM o valor atual (não precisa redigitar).
app.put('/api/nw/config', superadminGuard, async (req, res) => {
try {
const cur = getConfig();
const b = req.body || {};
const next = {
motorUrl: (b.motorUrl ?? cur.motorUrl) || '',
clinicaId: (b.clinicaId ?? cur.clinicaId) || '',
integrationKey: b.integrationKey ? String(b.integrationKey) : cur.integrationKey,
webhookSecret: b.webhookSecret ? String(b.webhookSecret) : cur.webhookSecret,
};
await saveConfigToDb(pool, next);
res.json({ ok: true, configured: isConfigured() });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Aplica um TOKEN de pareamento — auto-configura URL + chave + segredo do webhook.
// Token = "nwpair_" + base64url(JSON({ u: motorUrl, k: integrationKey, s: webhookSecret })).
// A clínica continua sendo escolhida localmente (o motor não conhece os IDs daqui).
app.post('/api/nw/config/token', superadminGuard, async (req, res) => {
try {
let raw = String((req.body || {}).token || '').trim();
raw = raw.replace(/^nwpair[_:]\s*/i, '');
let p;
try { p = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); }
catch { return res.status(400).json({ error: 'Token inválido (não foi possível decodificar).' }); }
const motorUrl = (p.u || p.url || '').replace(/\/$/, '');
const integrationKey = p.k || p.key || p.integrationKey || '';
const webhookSecret = p.s || p.secret || p.webhookSecret || '';
if (!motorUrl || !integrationKey) {
return res.status(400).json({ error: 'Token incompleto (faltam URL ou chave).' });
}
// Valida a chave contra o motor ANTES de salvar.
const { ok: motorOk, detail } = await testMotorKey(motorUrl, integrationKey);
if (!motorOk) return res.status(400).json({ error: `Token não validou no motor: ${detail}` });
const cur = getConfig();
await saveConfigToDb(pool, { motorUrl, integrationKey, webhookSecret: webhookSecret || cur.webhookSecret, clinicaId: cur.clinicaId });
res.json({ ok: true, configured: isConfigured(), motorUrl, hasWebhookSecret: Boolean(webhookSecret || cur.webhookSecret), detail });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Lista de clínicas para o dropdown.
app.get('/api/nw/clinicas', superadminGuard, async (_req, res) => {
try {
const { rows } = await pool.query('SELECT id, nome_fantasia FROM clinicas ORDER BY nome_fantasia');
res.json(rows);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Testa a conexão com o motor (usa a config atual).
app.get('/api/nw/status', superadminGuard, async (_req, res) => {
const c = getConfig();
if (!c.motorUrl || !c.integrationKey) return res.json({ configured: false, motorOk: false, detail: 'URL ou chave ausente' });
const { ok, detail } = await testMotorKey(c.motorUrl, c.integrationKey);
res.json({ configured: true, motorOk: ok, detail });
});
// ── Operação ────────────────────────────────────────────────────────────────
app.use('/nw', createRoutes(pool)); // Secretária do motor consulta a clínica
app.use(createWebhookReceiver(pool)); // webhook motor → satélite
app.get('/api/nw/accounts', createAccountsRoute(pool)); // seletor multi-conta da inbox (próprias + liberadas) — ANTES do catch-all
app.use('/api/nw/v1', createRestProxy(pool)); // proxy do inbox → motor (express 5: use, não wildcard) + enforcement de ownership
console.log('[newwhats] integração registrada (/nw, /api/webhook/newwhats, /api/nw/v1/*, /api/nw/config)');
}
+21
View File
@@ -0,0 +1,21 @@
{
"id": "newwhats",
"name": "NewWhats — Integração WhatsApp (Odonto)",
"version": "1.0.0",
"project": "scoreodonto.com",
"description": "Satélite do motor NewWhats adaptado ao domínio odontológico: expõe /nw/* (clínica, especialidades, dentistas, procedimentos, planos) para a Secretária IA, recebe webhooks do motor e faz proxy do inbox. Config por variáveis de ambiente (ESM, sem tabela plugin_configs).",
"runtime": "esm-module",
"entry": "index.js#registerNewwhats",
"features": [
"Rotas /nw/* (dados da clínica para a Secretária IA)",
"sync-knowledge (base de conhecimento odonto → motor)",
"Webhook receiver (message.new, session.status) com HMAC",
"Proxy REST /api/nw/v1/* → motor /api/ext/v1/*"
],
"config_env": [
{ "key": "NEWWHATS_URL", "required": true, "help": "URL do motor NewWhats." },
{ "key": "NEWWHATS_INTEGRATION_KEY", "required": true, "help": "x-nw-key: valida /nw/* e é injetada no proxy. Igual à apiKey criada no motor." },
{ "key": "NEWWHATS_WEBHOOK_SECRET", "required": false, "help": "Segredo HMAC para validar x-nw-signature nos webhooks. Recomendado." },
{ "key": "NEWWHATS_CLINICA_ID", "required": true, "help": "Qual clínica este satélite atende (multi-tenant)." }
]
}
+466
View File
@@ -0,0 +1,466 @@
// Proxy REST: /api/nw/v1/* (frontend) → motor /api/ext/v1/*.
//
// Segurança: valida o JWT do scoreodonto (só usuário logado acessa).
// Resolução de conta: SEMPRE via x-nw-email (chave mestra resolve o tenant pelo
// email). Todo usuário da clínica tem conta própria no motor — se o motor não
// conhecer o email, é erro (404 "conta não encontrada"), não há fallback.
// O email vem SEMPRE do token verificado — nunca do cliente.
import net from 'node:net';
import tls from 'node:tls';
import jwt from 'jsonwebtoken';
import { getConfig } from './config.js';
function authFromReq(req) {
try {
const tk = (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim();
if (!tk) return null;
const p = jwt.verify(tk, process.env.JWT_SECRET);
const email = (p.email || '').toLowerCase() || null;
if (!email) return null;
return { email, userId: p.userId || null };
} catch { return null; }
}
// Resolve o dono do workspace (clínica OU sala). Retorna o userId dono ou null.
async function resolveOwnerId(pool, clinicaId) {
if (!pool || !clinicaId) return null;
try {
const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
if (cl.length) {
if (cl[0].owner_id) return cl[0].owner_id;
// Clínicas antigas sem owner_id: dono = vínculo donoclinica/donoconsultorio.
const { rows: v } = await pool.query(
"SELECT usuario_id FROM vinculos WHERE clinica_id = $1 AND role IN ('donoclinica','donoconsultorio') ORDER BY id LIMIT 1",
[clinicaId]);
return v[0]?.usuario_id || null;
}
const { rows: sl } = await pool.query('SELECT owner_usuario_id FROM salas WHERE id = $1', [clinicaId]);
return sl[0]?.owner_usuario_id || null;
} 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
// GET /sessions/:id/qr → re-scan (parear QR) → dono OU can_rescan
// POST /sessions/:id/connect → re-scan (reconectar) → dono OU can_rescan
// DELETE /sessions/:id → excluir → dono OU can_delete
// 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)?)?/);
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
if (!isCreate && !isRescan && !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.' };
const ownerId = await resolveOwnerId(pool, clinicaId);
if (ownerId && ownerId === userId) return { ok: true }; // dono: poder total
// Scan inicial é exclusivo do dono — não delegável.
if (isCreate) return { ok: false, status: 403, error: 'Apenas o dono pode conectar um novo WhatsApp.' };
// Re-scan / exclusão: exigem delegação por sessão.
if (!instanceId) return { ok: false, status: 403, error: 'Sessão não identificada.' };
let authz = null;
try {
const { rows } = await pool.query(
'SELECT can_rescan, can_delete FROM wa_session_authz WHERE clinica_id = $1 AND instance_id = $2 AND usuario_id = $3',
[clinicaId, instanceId, userId]);
authz = rows[0] || null;
} catch { /* nega por padrão */ }
if (isRescan && 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.',
};
}
// Auto-provisiona (idempotente) a conta do email no motor, via chave mestra.
// Usado como self-heal quando o motor responde "Conta não encontrada".
export async function provisionNewwhatsAccount(pool, cfg, email) {
if (!cfg.motorUrl || !cfg.integrationKey || !email) return false;
let name = email;
try {
if (pool) {
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE lower(email) = $1', [email.toLowerCase()]);
if (rows[0]?.nome) name = rows[0].nome;
}
} catch { /* usa email como nome */ }
try {
const r = await fetch(`${cfg.motorUrl}/api/ext/v1/satellites/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integrationKey },
body: JSON.stringify({ email, name }),
});
return r.ok;
} catch { return false; }
}
// 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.
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]);
const full = String(rows[0]?.nome || '').trim();
if (!full) return null;
const parts = full.split(/\s+/);
const first = parts[0];
let ambiguous = false;
if (clinicaId) {
const { rows: m } = await pool.query(
`SELECT u.nome FROM vinculos v JOIN usuarios u ON u.id = v.usuario_id
WHERE v.clinica_id = $1 AND u.id <> $2`, [clinicaId, userId]);
ambiguous = m.some(x => String(x.nome || '').trim().split(/\s+/)[0].toLowerCase() === first.toLowerCase());
}
if (ambiguous && parts.length > 1 && parts[1][0]) {
return `${first} ${parts[1][0].toUpperCase()}.`;
}
return first;
} catch { return null; }
}
// 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);
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);
if (ownerId && ownerId === userId) return { ok: true };
const col = isSecretaria ? 'can_secretaria' : 'can_inbox';
try {
const { rows } = await pool.query(
`SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND ${col} = true LIMIT 1`,
[clinicaId, userId]);
if (rows.length) return { ok: true };
} catch { /* nega por padrão */ }
return { ok: false, status: 403, error: isSecretaria ? 'Sem acesso à Secretária. Peça autorização ao dono.' : 'Sem acesso ao Inbox. Peça autorização ao dono.' };
}
// Gate de ações DESTRUTIVAS no inbox (apagar conversa / apagar mensagem).
// 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 && !isClearMsgs && !isDeleteMsg) return { ok: true };
if (!pool || !clinicaId) return { ok: true };
const ownerId = await resolveOwnerId(pool, clinicaId);
if (ownerId && ownerId === userId) return { ok: true };
try {
const { rows } = await pool.query(
'SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND can_delete_msg = true LIMIT 1',
[clinicaId, userId]);
if (rows.length) return { ok: true };
} catch { /* nega por padrão */ }
return { ok: false, status: 403, error: 'Sem permissão para apagar conversas/mensagens. Peça autorização ao dono.' };
}
export function createRestProxy(pool) {
return async function restProxy(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 tail = req.originalUrl.replace(/^\/api\/nw\/v1/, '');
const clinicaId = req.headers['x-nw-clinica'] ? String(req.headers['x-nw-clinica']) : null;
// Enforcement de ownership/permissões antes de repassar ao motor.
const guard = await guardSessionAction(pool, req, tail, userId);
if (!guard.ok) return res.status(guard.status).json({ error: guard.error });
// Gate de acesso às áreas Inbox / Secretária.
const area = await guardAreaAccess(pool, tail, userId, clinicaId);
if (!area.ok) return res.status(area.status).json({ error: area.error });
// 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). 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}`;
}
// 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: {
'x-nw-key': cfg.integrationKey,
'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 (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);
}
try {
let r = await fetch(url, opts);
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) && targetEmail) {
const ok = await provisionNewwhatsAccount(pool, cfg, targetEmail);
if (ok) { r = await fetch(url, opts); text = await r.text(); }
}
res.status(r.status);
try { res.json(JSON.parse(text)); } catch { res.send(text); }
} catch (e) {
res.status(502).json({ error: `Motor indisponível: ${e.message}` });
}
};
}
// ── 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
// sobre WebSocket. Aqui abrimos um túnel TCP bruto socket↔socket e injetamos a
// x-nw-key no handshake com o motor — a chave nunca chega ao browser.
//
// Auth do usuário: o browser NÃO envia header Authorization no upgrade WS, então
// o JWT do scoreodonto vem por ?token= (mesmo esquema do /api/ws do app). Só
// usuário logado abre o túnel; o token fica local — não é repassado ao motor.
//
// 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.
//
// 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=); 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');
let target;
try { target = new URL(cfg.motorUrl); }
catch { return fail('502 Bad Gateway'); }
const isHttps = target.protocol === 'https:';
const host = target.hostname;
const port = Number(target.port) || (isHttps ? 443 : 80);
const onConnect = () => {
// Reescreve o handshake: path EXATO + Host do motor + x-nw-key.
// Preserva os headers do WS (sec-websocket-*, upgrade, connection).
const headers = Object.entries(req.headers)
.filter(([k]) => !['host', 'x-nw-key', 'x-nw-email'].includes(k.toLowerCase()))
.map(([k, v]) => `${k}: ${v}`)
.join('\r\n');
proxySocket.write(
`${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: ${targetEmail}\r\n` +
`${headers}\r\n\r\n`
);
if (head && head.length) proxySocket.write(head);
proxySocket.pipe(clientSocket);
clientSocket.pipe(proxySocket);
};
const proxySocket = isHttps
? tls.connect({ host, port, servername: host }, onConnect)
: net.connect(port, host, onConnect);
proxySocket.on('error', (err) => {
console.error('[newwhats] túnel WS erro:', err.message);
clientSocket.destroy();
});
clientSocket.on('error', () => proxySocket.destroy());
clientSocket.on('close', () => proxySocket.destroy());
proxySocket.on('close', () => clientSocket.destroy());
};
}
+98
View File
@@ -0,0 +1,98 @@
// Rotas /nw/* — a Secretária IA do motor consulta os dados desta clínica.
// Adaptado do satélite mercado para o domínio ODONTO (scoreodonto).
// Todas as rotas exigem a integration_key (nwAuth).
import { Router } from 'express'
import { nwAuth } from './auth.js'
import { getConfig } from './config.js'
const money = (v) =>
v == null ? null : `R$ ${Number(v).toFixed(2).replace('.', ',')}`
// Resolve a clínica-alvo: query ?clinica_id= tem prioridade; senão a do config.
function resolveClinicaId(req) {
return String(req.query.clinica_id || getConfig().clinicaId || '').trim()
}
export function createRoutes(pool) {
const router = Router()
router.use(nwAuth)
const needClinica = (req, res) => {
const id = resolveClinicaId(req)
if (!id) res.status(400).json({ error: 'clinica_id ausente (configure NEWWHATS_CLINICA_ID ou passe ?clinica_id=).' })
return id
}
router.get('/ping', (_req, res) =>
res.json({ ok: true, service: 'scoreodonto', ts: new Date().toISOString() }))
// Dados da clínica
router.get('/clinica', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows } = await pool.query(
`SELECT nome_fantasia, telefone, cidade, estado, logradouro, numero, bairro, cep
FROM clinicas WHERE id = $1`, [id])
res.json(rows[0] || null)
} catch (e) { res.status(500).json({ error: e.message }) }
})
// Especialidades atendidas
router.get('/especialidades', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows } = await pool.query(
`SELECT nome, descricao, area FROM especialidades
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
res.json(rows)
} catch (e) { res.status(500).json({ error: e.message }) }
})
// Dentistas + horários de atendimento
router.get('/dentistas', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows: dentistas } = await pool.query(
`SELECT id, nome, especialidade FROM dentistas
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
for (const d of dentistas) {
const { rows: hr } = await pool.query(
`SELECT dia_semana, hora_inicio, hora_fim FROM dentistas_horarios
WHERE dentista_id = $1 AND ativo::int = 1 ORDER BY dia_semana, hora_inicio`, [d.id])
d.horarios = hr
delete d.id
}
res.json(dentistas)
} catch (e) { res.status(500).json({ error: e.message }) }
})
// Procedimentos + valor particular + valores por plano
router.get('/procedimentos', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows: procs } = await pool.query(
`SELECT id, nome, descricao, categoria, valorparticular FROM procedimentos
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
for (const p of procs) {
const { rows: vp } = await pool.query(
`SELECT planonome, valor FROM procedimentos_valores_planos WHERE procedimentoid = $1`, [p.id])
p.valor_particular = money(p.valorparticular)
p.valores_planos = vp.map((x) => ({ plano: x.planonome, valor: money(x.valor) }))
delete p.id; delete p.valorparticular
}
res.json(procs)
} catch (e) { res.status(500).json({ error: e.message }) }
})
// Planos / convênios aceitos
router.get('/planos', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows } = await pool.query(
`SELECT nome, tipo, descontopadrao FROM planos WHERE clinica_id = $1 ORDER BY nome`, [id])
res.json(rows)
} catch (e) { res.status(500).json({ error: e.message }) }
})
return router
}
+135
View File
@@ -0,0 +1,135 @@
// sync-knowledge.js (ODONTO) — monta o nó de conhecimento da Secretária IA com
// os dados reais da clínica e sincroniza com o motor NewWhats.
// Adaptado do satélite mercado. Fluxo:
// 1. Lê dados do banco (clínica, especialidades, dentistas, procedimentos, planos)
// 2. Monta texto estruturado do knowledge node
// 3. PUT/POST no motor (/api/secretaria/*) — rotas internas, sem auth de sessão
import { getConfig } from './config.js'
const money = (v) => v == null ? null : `R$ ${Number(v).toFixed(2).replace('.', ',')}`
const DIAS = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']
async function motorFetch(motorUrl, method, path, body) {
const res = await fetch(`${motorUrl}/api/secretaria${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) throw new Error(`Motor ${method} /api/secretaria${path}${res.status}: ${(await res.text()).slice(0, 200)}`)
return res.json()
}
export async function buildKnowledgeText(pool, clinicaId) {
const lines = []
const q = (sql, params) => pool.query(sql, params).then((r) => r.rows)
const [clinica] = await q(
`SELECT nome_fantasia, telefone, cidade, estado, logradouro, numero, bairro
FROM clinicas WHERE id = $1`, [clinicaId])
const nome = clinica?.nome_fantasia || 'Nossa clínica odontológica'
lines.push(`=== ${nome.toUpperCase()} ===`)
lines.push(`Clínica odontológica. Atendimento via WhatsApp.`)
if (clinica?.telefone) lines.push(`Telefone: ${clinica.telefone}`)
if (clinica?.logradouro) {
const end = [clinica.logradouro, clinica.numero, clinica.bairro, clinica.cidade, clinica.estado].filter(Boolean).join(', ')
lines.push(`Endereço: ${end}`)
}
lines.push('')
// Especialidades
const esp = await q(
`SELECT nome, descricao FROM especialidades WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
if (esp.length) {
lines.push(`=== ESPECIALIDADES ATENDIDAS ===`)
for (const e of esp) lines.push(`${e.nome}${e.descricao ? `${e.descricao}` : ''}`)
lines.push('')
}
// Dentistas + horários
const dentistas = await q(
`SELECT id, nome, especialidade FROM dentistas WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
if (dentistas.length) {
lines.push(`=== DENTISTAS ===`)
for (const d of dentistas) {
lines.push(`${d.nome}${d.especialidade ? ` (${d.especialidade})` : ''}`)
const hr = await q(
`SELECT dia_semana, hora_inicio, hora_fim FROM dentistas_horarios
WHERE dentista_id = $1 AND ativo::int = 1 ORDER BY dia_semana, hora_inicio`, [d.id])
if (hr.length) {
const fmt = hr.map((h) => `${DIAS[h.dia_semana] ?? h.dia_semana} ${String(h.hora_inicio).slice(0, 5)}-${String(h.hora_fim).slice(0, 5)}`)
lines.push(` Atende: ${fmt.join(' | ')}`)
}
}
lines.push('')
}
// Procedimentos + valores
const procs = await q(
`SELECT id, nome, categoria, valorparticular FROM procedimentos
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
if (procs.length) {
lines.push(`=== PROCEDIMENTOS E VALORES ===`)
for (const p of procs) {
const part = p.valorparticular != null ? ` — particular: ${money(p.valorparticular)}` : ''
lines.push(`${p.nome}${p.categoria ? ` [${p.categoria}]` : ''}${part}`)
const vp = await q(
`SELECT planonome, valor FROM procedimentos_valores_planos WHERE procedimentoid = $1`, [p.id])
const comValor = vp.filter((x) => x.valor != null)
if (comValor.length) lines.push(` Por plano: ${comValor.map((x) => `${x.planonome} ${money(x.valor)}`).join(', ')}`)
}
lines.push('')
}
// Planos / convênios
const planos = await q(
`SELECT nome, tipo, descontopadrao FROM planos WHERE clinica_id = $1 ORDER BY nome`, [clinicaId])
if (planos.length) {
lines.push(`=== PLANOS / CONVÊNIOS ACEITOS ===`)
for (const pl of planos) {
const desc = pl.descontopadrao ? ` — desconto padrão ${pl.descontopadrao}%` : ''
lines.push(`${pl.nome}${pl.tipo ? ` (${pl.tipo})` : ''}${desc}`)
}
lines.push('')
}
lines.push(`=== ATENDIMENTO ===`)
lines.push(`Para agendar, remarcar ou tirar dúvidas, fale pelo WhatsApp.`)
lines.push(`Dúvidas sobre valores, convênios ou disponibilidade: informe que vai encaminhar para a equipe.`)
return lines.join('\n')
}
export async function syncKnowledge(pool) {
const cfg = getConfig()
if (!cfg.motorUrl) throw new Error('NEWWHATS_URL não configurado')
if (!cfg.clinicaId) throw new Error('NEWWHATS_CLINICA_ID não configurado')
const knowledgeText = await buildKnowledgeText(pool, cfg.clinicaId)
const agents = await motorFetch(cfg.motorUrl, 'GET', '/agents')
if (!agents.length) throw new Error('Nenhum agente no motor. Crie um agente na Secretária IA.')
const agent = agents.find((a) => a.active) || agents[0]
const nodes = await motorFetch(cfg.motorUrl, 'GET', `/agents/${agent.id}/nodes`)
const kn = nodes.find((n) => n.type === 'knowledge')
let nodeId
const title = 'Base de Conhecimento — Clínica Odontológica'
if (kn) {
await motorFetch(cfg.motorUrl, 'PUT', `/nodes/${kn.id}`, {
title, content: knowledgeText, active: true, sort_order: kn.sort_order ?? 3,
})
nodeId = kn.id
} else {
const created = await motorFetch(cfg.motorUrl, 'POST', `/agents/${agent.id}/nodes`, {
type: 'knowledge', title, content: knowledgeText, sort_order: 3,
})
nodeId = created.id
}
return {
ok: true, agent_id: agent.id, agent: agent.name, node_id: nodeId,
chars: knowledgeText.length, preview: knowledgeText, synced_at: new Date().toISOString(),
}
}
+52
View File
@@ -0,0 +1,52 @@
// Receptor de webhooks do motor NewWhats (motor → satélite).
// POST /api/webhook/newwhats
// Header x-nw-signature: sha256=HMAC-SHA256(webhook_secret, rawBody)
// Eventos: message.new (nova mensagem WA), session.status (conexão da instância)
//
// Versão ENXUTA p/ scoreodonto: valida HMAC, registra o evento e deixa ganchos
// (TODO) para captura de lead / auto-resposta. NÃO porta o auto-responder do
// mercado (a Secretária do motor já responde via /secretaria/ask).
import { Router } from 'express'
import { createHmac, timingSafeEqual } from 'crypto'
import { getConfig } from './config.js'
function verifySignature(secret, rawBody, sigHeader) {
if (!sigHeader) return false
const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'))
const received = Buffer.from(String(sigHeader))
return expected.length === received.length && timingSafeEqual(expected, received)
}
export function createWebhookReceiver(_pool) {
const router = Router()
router.post('/api/webhook/newwhats', async (req, res) => {
try {
const secret = getConfig().webhookSecret
if (secret) {
const rawBody = req.rawBody ?? Buffer.from(JSON.stringify(req.body))
if (!verifySignature(secret, rawBody, req.headers['x-nw-signature'])) {
return res.status(401).json({ error: 'Assinatura inválida' })
}
} else {
console.warn('[nw-webhook] NEWWHATS_WEBHOOK_SECRET ausente — assinatura ignorada')
}
const { event, data } = req.body || {}
if (event === 'message.new') {
// TODO(odonto): registrar lead / acionar fluxo. Hoje só loga.
console.log(`[nw-webhook] message.new de ${data?.author ?? data?.pushName ?? '?'}: ${String(data?.body ?? '').slice(0, 60)}`)
} else if (event === 'session.status') {
console.log(`[nw-webhook] session.status → ${data?.instanceId}: ${data?.status}`)
} else {
console.log(`[nw-webhook] evento não tratado: ${event}`)
}
res.json({ ok: true })
} catch (e) {
console.error('[nw-webhook] erro:', e.message)
res.status(500).json({ error: e.message })
}
})
return router
}
+468 -57
View File
@@ -11,6 +11,8 @@ import fs from 'fs';
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';
const { Pool } = pg;
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
@@ -46,7 +48,7 @@ const pool = new Pool({
max: 10,
idleTimeoutMillis: 30000
});
pool.on('connect', () => console.log('[PG] Connected to PostgreSQL'));
// pool.on('connect', () => console.log('[PG] Connected to PostgreSQL'));
pool.on('error', (err) => console.error('[PG] Pool error:', err.message));
// Helper: transaction wrapper
@@ -123,6 +125,8 @@ async function ensureUsuarioPorEmail(email, nome, role) {
'INSERT INTO usuarios (id, nome, email, senha, role, must_change_password) VALUES ($1,$2,$3,$4,$5,true)',
[id, (nome || e).toUpperCase(), e, await hashSenha(temp), role]
);
// Espelha a conta no motor NewWhats (eager, best-effort) — pronta p/ WhatsApp.
provisionNewwhatsAccountEager(pool, e).catch(() => {});
return { id, nome: (nome || e).toUpperCase(), created: true, tempPassword: temp };
}
@@ -162,6 +166,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 || '*',
@@ -653,6 +660,8 @@ app.post('/api/register', async (req, res) => {
const token = jwt.sign({ userId, email: email.toLowerCase().trim() }, JWT_SECRET, { expiresIn: '12h' });
console.log(`[REGISTER] New user: ${email}, role: ${userRole}, workspaces: ${workspaces.length}`);
// Espelha a conta no motor NewWhats (eager, best-effort) — pronta p/ WhatsApp.
provisionNewwhatsAccountEager(pool, email.toLowerCase().trim()).catch(() => {});
res.json({ success: true, token, user: { id: userId, nome: nomeUpper, email: email.toLowerCase().trim(), role: userRole }, workspaces, noWorkspace: workspaces.length === 0, userRole });
} catch (err) {
console.error('[REGISTER]', err.message);
@@ -707,6 +716,153 @@ app.get('/api/clinicas/:clinicaId/membros', tenantGuard, requireCapability('gest
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// =============================================================================
// NEWWHATS — OWNERSHIP & AUTORIZAÇÕES DE SESSÃO (WhatsApp)
// =============================================================================
// O "dono" de um workspace (clinicas.owner_id / salas.owner_usuario_id) é o único
// com poder total sobre as sessões: scan inicial (criar), re-scan (QR) e exclusão.
// Ele pode delegar re-scan/exclusão a outras contas, por sessão específica.
// Estes endpoints são LOCAIS do scoreodonto (não vão ao motor). O enforcement em
// runtime das ações do motor acontece no proxy /api/nw/v1/* (backend/newwhats).
// Resolve o dono do workspace (clínica OU sala). Retorna { ownerId, ownerNome, tipo }.
async function resolveWorkspaceOwner(clinicaId) {
if (!clinicaId) return null;
let ownerId = null, tipo = 'clinica';
const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
if (cl.length) {
ownerId = cl[0].owner_id || null;
// Clínicas antigas podem ter owner_id NULL: o dono é quem tem vínculo
// donoclinica/donoconsultorio (mesma semântica de nivelNaClinica).
if (!ownerId) {
const { rows: v } = await pool.query(
"SELECT usuario_id FROM vinculos WHERE clinica_id = $1 AND role IN ('donoclinica','donoconsultorio') ORDER BY id LIMIT 1",
[clinicaId]);
ownerId = v[0]?.usuario_id || null;
}
} else {
const { rows: sl } = await pool.query('SELECT owner_usuario_id FROM salas WHERE id = $1', [clinicaId]);
if (sl[0]?.owner_usuario_id) { ownerId = sl[0].owner_usuario_id; tipo = 'sala'; }
}
if (!ownerId) return null;
const { rows: u } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [ownerId]);
return { ownerId, ownerNome: u[0]?.nome || null, tipo };
}
// GET /api/nw/authz?clinicaId=&instanceId=
// Retorna as permissões da sessão. Dono vê a lista completa de membros + delegações;
// não-dono vê apenas as próprias permissões (para a UI condicionar botões).
app.get('/api/nw/authz', tenantGuard, async (req, res) => {
try {
const clinicaId = req.clinicaId;
// instanceId é OPCIONAL: sem ele, respondemos apenas o ownership do workspace
// (usado para liberar/ocultar "Nova Instância", o scan inicial).
const instanceId = String(req.query.instanceId || '').trim();
if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' });
const owner = await resolveWorkspaceOwner(clinicaId);
const meId = req.authUser.userId;
const isOwner = !!owner && owner.ownerId === meId;
if (isOwner) {
// Membros da clínica (exceto o dono) + salas: sócios/equipe elegíveis à delegação.
const { rows: membros } = await pool.query(`
SELECT u.id, u.nome, u.email, v.role
FROM vinculos v JOIN usuarios u ON u.id = v.usuario_id
WHERE v.clinica_id = $1 AND u.id <> $2 ORDER BY u.nome
`, [clinicaId, owner.ownerId]);
const { rows: authz } = instanceId ? await pool.query(
'SELECT usuario_id, can_rescan, can_delete, can_inbox, can_secretaria, can_delete_msg FROM wa_session_authz WHERE clinica_id = $1 AND instance_id = $2',
[clinicaId, instanceId]) : { rows: [] };
const amap = new Map(authz.map(a => [a.usuario_id, a]));
const members = membros.map(m => ({
usuarioId: m.id, nome: m.nome, email: m.email, role: m.role,
can_rescan: !!amap.get(m.id)?.can_rescan,
can_delete: !!amap.get(m.id)?.can_delete,
can_inbox: !!amap.get(m.id)?.can_inbox,
can_secretaria: !!amap.get(m.id)?.can_secretaria,
can_delete_msg: !!amap.get(m.id)?.can_delete_msg,
}));
return res.json({
isOwner: true, ownerId: owner.ownerId, ownerNome: owner.ownerNome,
me: { can_rescan: true, can_delete: true, can_inbox: true, can_secretaria: true, can_delete_msg: true },
members,
});
}
const { rows } = instanceId ? await pool.query(
'SELECT can_rescan, can_delete, can_inbox, can_secretaria, can_delete_msg FROM wa_session_authz WHERE clinica_id = $1 AND instance_id = $2 AND usuario_id = $3',
[clinicaId, instanceId, meId]) : { rows: [] };
return res.json({
isOwner: false, ownerId: owner?.ownerId || null, ownerNome: owner?.ownerNome || null,
me: { can_rescan: !!rows[0]?.can_rescan, can_delete: !!rows[0]?.can_delete, can_inbox: !!rows[0]?.can_inbox, can_secretaria: !!rows[0]?.can_secretaria, can_delete_msg: !!rows[0]?.can_delete_msg },
members: [],
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
// PUT /api/nw/authz — dono concede/revoga re-scan/exclusão de uma sessão a uma conta.
// Body: { clinicaId, instanceId, usuarioId, can_rescan, can_delete }
app.put('/api/nw/authz', tenantGuard, async (req, res) => {
try {
const clinicaId = req.clinicaId;
const { instanceId, usuarioId, can_rescan = false, can_delete = false, can_inbox = false, can_secretaria = false, can_delete_msg = false } = req.body || {};
if (!clinicaId || !instanceId || !usuarioId) return res.status(400).json({ error: 'clinicaId, instanceId e usuarioId são obrigatórios.' });
const owner = await resolveWorkspaceOwner(clinicaId);
if (!owner || owner.ownerId !== req.authUser.userId) {
return res.status(403).json({ error: 'Apenas o dono do workspace pode alterar autorizações.' });
}
if (usuarioId === owner.ownerId) return res.status(400).json({ error: 'O dono já tem poderes totais.' });
// Confirma que o alvo é membro do workspace.
const { rows: vin } = await pool.query(
'SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [usuarioId, clinicaId]);
if (vin.length === 0) return res.status(400).json({ error: 'Conta não é membro deste workspace.' });
const rescan = !!can_rescan, del = !!can_delete, inbox = !!can_inbox, sec = !!can_secretaria, delMsg = !!can_delete_msg;
if (!rescan && !del && !inbox && !sec && !delMsg) {
await pool.query('DELETE FROM wa_session_authz WHERE clinica_id = $1 AND instance_id = $2 AND usuario_id = $3',
[clinicaId, instanceId, usuarioId]);
} else {
await pool.query(`
INSERT INTO wa_session_authz (clinica_id, instance_id, usuario_id, can_rescan, can_delete, can_inbox, can_secretaria, can_delete_msg, granted_by, granted_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
ON CONFLICT (clinica_id, instance_id, usuario_id)
DO UPDATE SET can_rescan = EXCLUDED.can_rescan, can_delete = EXCLUDED.can_delete, can_inbox = EXCLUDED.can_inbox, can_secretaria = EXCLUDED.can_secretaria, can_delete_msg = EXCLUDED.can_delete_msg, granted_by = EXCLUDED.granted_by, granted_at = NOW()
`, [clinicaId, instanceId, usuarioId, rescan, del, inbox, sec, delMsg, owner.ownerId]);
}
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/nw/access?clinicaId= — acesso AGREGADO do usuário logado às áreas do
// WhatsApp no workspace (usado pelo frontend p/ esconder menu/botões). Dono = tudo.
app.get('/api/nw/access', tenantGuard, async (req, res) => {
try {
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, 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
FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2`, [clinicaId, meId]);
res.json({
isOwner: false,
inbox: !!rows[0]?.inbox,
secretaria: !!rows[0]?.secretaria,
delete_msg: !!rows[0]?.delete_msg,
signature,
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/clinicas/:clinicaId/membros', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
const { email, role, setor_id, funcao_id, nome } = req.body;
if (!email || !role) return res.status(400).json({ success: false, message: 'E-mail e função são obrigatórios.' });
@@ -1771,7 +1927,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) {
@@ -1781,43 +1955,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
};
@@ -1981,7 +2167,7 @@ app.get('/api/dentistas', authGuard, async (req, res) => {
// Filtra o payload de dentista para só colunas válidas (descarta celular/cro/cro_uf etc.),
// converte 'ativo' (boolean) para smallint e serializa as listas (especialidades/dias).
const DENTISTA_COLS = new Set(['id', 'clinica_id', 'nome', 'email', 'telefone', 'coragenda', 'especialidade', 'especialidades', 'dias_atendimento', 'ativo', 'ordem', 'usuario_id']);
const DENTISTA_COLS = new Set(['id', 'clinica_id', 'nome', 'email', 'telefone', 'coragenda', 'especialidade', 'especialidades', 'dias_atendimento', 'ativo', 'ordem', 'usuario_id', 'setor_id']);
function sanitizeDentista(d) {
const out = {};
for (const [k, v] of Object.entries(d || {})) {
@@ -1998,6 +2184,14 @@ app.post('/api/dentistas', tenantGuard, requireCapability('dentistas'), async (r
try {
const d = req.body;
let tempPassword = null, contaCriada = false;
// E-mail OBRIGATÓRIO: todo dentista precisa de conta para ter agenda e identidade
// global — sem ela o anti-overbooking entre clínicas não reconhece a mesma pessoa.
// (O protético interno "sem conta" usa outro fluxo e NÃO passa por aqui.)
const emailLimpo = (d.email || '').trim();
if (!emailLimpo || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(emailLimpo)) {
return res.status(400).json({ success: false, message: 'E-MAIL VÁLIDO É OBRIGATÓRIO PARA CADASTRAR UM DENTISTA (ele precisa de conta para ter agenda).' });
}
d.email = emailLimpo;
// Dentista com e-mail: provisiona a conta (se não existir) e vincula à clínica,
// pra ele ter acesso à agenda. Conta "free" — só vínculo com a clínica.
if (d.email && !d.usuario_id) {
@@ -2614,7 +2808,26 @@ app.get('/api/agendamentos', tenantGuard, async (req, res) => {
try {
const clinicaId = req.clinicaId;
if (!clinicaId) return res.json([]); // sem clínica escopada → nada (isolamento entre unidades)
const { rows } = await pool.query('SELECT * FROM agendamentos WHERE clinica_id = $1', [clinicaId]);
// Janela de datas OPCIONAL (?from&to): quando enviada, limita a varredura à janela
// visível do calendário em vez de carregar a agenda histórica inteira. Compatível
// com clientes antigos (sem from/to → comportamento anterior).
const { from, to } = req.query;
const params = [clinicaId];
let janela = '';
if (from) { params.push(from); janela += ` AND COALESCE(end_time, start_time) >= $${params.length}`; }
if (to) { params.push(to); janela += ` AND start_time <= $${params.length}`; }
// Isolamento por setor (opt-in). Dono/admin e membro SEM setor → vê tudo
// (compat). Membro COM setor → só o próprio setor + os "gerais" (setor_id
// NULL). Não se aplica a salas (req.isSala não tem vínculo/setor → vê tudo).
if (!req.isSala) {
const { ehDono, setorId } = await resolverSetorUsuario(pool, req.authUser?.userId, clinicaId);
if (!ehDono && setorId) {
params.push(setorId);
janela += ` AND (setor_id = $${params.length} OR setor_id IS NULL)`;
}
}
const { rows } = await pool.query(
`SELECT * FROM agendamentos WHERE clinica_id = $1${janela} ORDER BY start_time`, params);
res.json(rows.map(r => ({
...r,
dentistaId: r.dentistaid,
@@ -2634,11 +2847,19 @@ app.get('/api/agendamentos', tenantGuard, async (req, res) => {
async function findOverbookingConflict(db, dentistaId, start, end, ignoreId) {
if (!dentistaId || !start) return null;
let dentistaIds = [dentistaId];
const { rows: dr } = await db.query('SELECT usuario_id FROM dentistas WHERE id = $1', [dentistaId]);
const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]);
const profUid = dr[0]?.usuario_id || null;
const profEmail = (dr[0]?.email || '').trim().toLowerCase() || null;
if (profUid) {
const { rows: ids } = await db.query('SELECT id FROM dentistas WHERE usuario_id = $1', [profUid]);
if (ids.length) dentistaIds = ids.map(r => r.id);
} else if (profEmail) {
// Profissional SEM conta (usuario_id NULL): agrupa os registros da MESMA pessoa
// pelo email — a mesma chave usada no backfill de identidade (Fase 2). Garante o
// anti-overbooking para o dentista que atende fixo numa clínica e avulso em outras,
// mesmo sem login. Sem email não há como vincular → cai no próprio dentistaId.
const { rows: ids } = await db.query('SELECT id FROM dentistas WHERE lower(trim(email)) = $1', [profEmail]);
if (ids.length) dentistaIds = ids.map(r => r.id);
}
const fim = end || start;
const params = [dentistaIds, start, fim];
@@ -2656,6 +2877,14 @@ async function findOverbookingConflict(db, dentistaId, start, end, ignoreId) {
return rows[0] || null;
}
// Privacidade entre tenants: NUNCA devolver id/clinica_id do compromisso em conflito
// (revelaria a clínica concorrente e o agendamento alheio a quem está agendando aqui).
// Só o intervalo de tempo, que é o necessário para "escolha outro horário".
function slotOcupadoPublico(conflito) {
if (!conflito) return null;
return { start: conflito.start_time, end: conflito.end_time };
}
// POST with concurrency lock: prevents double-booking the same dentist+time slot
app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req, res) => {
try {
@@ -2675,6 +2904,23 @@ app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req
const conflito = await findOverbookingConflict(client, dentistaId, start, end);
if (conflito) throw { code: 'CONF_OVERLAP', conflito };
// Setor do agendamento (isolamento opt-in): ancora no setor do DENTISTA —
// primeiro um override explícito (dentistas.setor_id), senão o setor do
// vínculo do próprio profissional na clínica (definido em Gestão de Equipe,
// UI já existente). Sem isso, herda o setor de quem está agendando (recepção
// do setor). NULL = geral, visível a todos.
let setorAg = null;
const { rows: ds } = await client.query(
`SELECT d.setor_id AS d_setor, v.setor_id AS v_setor
FROM dentistas d
LEFT JOIN vinculos v ON v.usuario_id = d.usuario_id AND v.clinica_id = $2
WHERE d.id = $1`, [dentistaId, rest.clinica_id]);
setorAg = ds[0]?.d_setor || ds[0]?.v_setor || null;
if (!setorAg) {
const { rows: vs } = await client.query('SELECT setor_id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [actor, rest.clinica_id]);
setorAg = vs[0]?.setor_id || null;
}
// Map keys from camelCase/old attributes to correct lowercase PG columns if necessary
const payload = {
id,
@@ -2688,6 +2934,7 @@ app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req
observacoes: rest.observacoes,
status: rest.status || 'agendado',
clinica_id: rest.clinica_id,
setor_id: setorAg,
created_by: actor,
created_by_nome: actorNm,
version: 1
@@ -2727,7 +2974,7 @@ app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req
success: false,
conflito: true,
podeRegistrarInteresse: true, // gancho p/ Fase 4 (interesse/fila)
ocupado: err.conflito, // { id, clinica_id, start_time, end_time }
ocupado: slotOcupadoPublico(err.conflito), // só o horário — sem vazar a clínica alheia
message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO (EM QUALQUER CLÍNICA). REGISTRE INTERESSE OU ESCOLHA OUTRO HORÁRIO.'
});
}
@@ -2763,13 +3010,6 @@ app.put('/api/agendamentos/:id', authGuard, requireCapabilityRow('agenda', 'agen
const mudouTempoOuDent = (start && String(start) !== String(before.start_time))
|| (end && String(end) !== String(before.end_time))
|| (rest.dentistaId && rest.dentistaId !== before.dentistaid);
if (mudouTempoOuDent) {
const novoDent = rest.dentistaId || before.dentistaid;
const conflito = await findOverbookingConflict(pool, novoDent, start || before.start_time, end || before.end_time, req.params.id);
if (conflito) {
return res.status(409).json({ success: false, conflito: true, podeRegistrarInteresse: true, ocupado: conflito, message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO (EM QUALQUER CLÍNICA). ESCOLHA OUTRO HORÁRIO.' });
}
}
const updateData = {};
if (start) updateData.start_time = start;
@@ -2785,17 +3025,43 @@ app.put('/api/agendamentos/:id', authGuard, requireCapabilityRow('agenda', 'agen
updateData.updated_at = new Date().toISOString();
updateData.version = Number(before.version || 1) + 1;
const updateQuery = buildUpdate('agendamentos', updateData, 'id', req.params.id);
if (updateQuery) await pool.query(updateQuery.text, updateQuery.values);
// remarcou (mudou horário) vs editou (outros campos)
const movido = (start && String(start) !== String(before.start_time)) || (end && String(end) !== String(before.end_time));
await registrarAudit(pool, {
clinicaId: before.clinica_id, entidade: 'agendamento', entidadeId: req.params.id,
acao: movido ? 'reagendou' : 'editou', actorId: actor, actorNome: actorNm,
detalhes: movido
? { de: { start: before.start_time, end: before.end_time }, para: { start: start || before.start_time, end: end || before.end_time } }
: { campos: Object.keys(updateData).filter(k => !['updated_by', 'updated_by_nome', 'updated_at', 'version'].includes(k)) }
// Tudo numa única transação: a checagem de overbooking (FOR UPDATE) e o UPDATE
// ficam atômicos — sem isso, dois reagendamentos simultâneos para o mesmo slot
// passariam os dois pela verificação (o lock só vale dentro da transação).
await withTransaction(async (client) => {
if (mudouTempoOuDent) {
const novoDent = rest.dentistaId || before.dentistaid;
const conflito = await findOverbookingConflict(client, novoDent, start || before.start_time, end || before.end_time, req.params.id);
if (conflito) throw { code: 'CONF_OVERLAP', conflito };
}
// Trocou o dentista → o setor segue o novo profissional (mesma derivação do
// POST: override do dentista > setor do vínculo dele > setor de quem edita).
// Sem isso, o agendamento ficaria no setor do dentista ANTERIOR.
if (rest.dentistaId && rest.dentistaId !== before.dentistaid) {
const { rows: dd } = await client.query(
`SELECT d.setor_id AS d_setor, v.setor_id AS v_setor
FROM dentistas d
LEFT JOIN vinculos v ON v.usuario_id = d.usuario_id AND v.clinica_id = $2
WHERE d.id = $1`, [rest.dentistaId, before.clinica_id]);
let novoSetor = dd[0]?.d_setor || dd[0]?.v_setor || null;
if (!novoSetor) {
const { rows: av } = await client.query('SELECT setor_id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [actor, before.clinica_id]);
novoSetor = av[0]?.setor_id || null;
}
updateData.setor_id = novoSetor;
}
const updateQuery = buildUpdate('agendamentos', updateData, 'id', req.params.id);
if (updateQuery) await client.query(updateQuery.text, updateQuery.values);
await registrarAudit(client, {
clinicaId: before.clinica_id, entidade: 'agendamento', entidadeId: req.params.id,
acao: movido ? 'reagendou' : 'editou', actorId: actor, actorNome: actorNm,
detalhes: movido
? { de: { start: before.start_time, end: before.end_time }, para: { start: start || before.start_time, end: end || before.end_time } }
: { campos: Object.keys(updateData).filter(k => !['updated_by', 'updated_by_nome', 'updated_at', 'version'].includes(k)) }
});
});
schedulePush('agendamentos');
wsBroadcast(before.clinica_id, 'agenda', { acao: 'editou', id: req.params.id }); // pós-commit
@@ -2810,6 +3076,13 @@ app.put('/api/agendamentos/:id', authGuard, requireCapabilityRow('agenda', 'agen
});
res.json({ success: true, version: updateData.version });
} catch (err) {
if (err.code === 'CONF_OVERLAP') {
return res.status(409).json({
success: false, conflito: true, podeRegistrarInteresse: true,
ocupado: slotOcupadoPublico(err.conflito), // só o horário — sem vazar a clínica alheia
message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO (EM QUALQUER CLÍNICA). ESCOLHA OUTRO HORÁRIO.'
});
}
if (err.code === '23505') {
return res.status(409).json({
success: false,
@@ -2839,6 +3112,20 @@ async function userHasVinculo(userId, clinicaId) {
return rows.length > 0;
}
// Contexto de setor do usuário NA clínica (para isolamento opt-in da agenda):
// { ehDono, setorId }. ehDono (dono/admin/superadmin) → vê tudo. setorId NULL
// (membro sem setor) → vê tudo (compat: clínica que não usa setores). setorId
// preenchido → vê só o próprio setor + os "gerais" (setor_id NULL).
const DONO_ROLES = ['donoclinica', 'donoconsultorio', 'admin'];
async function resolverSetorUsuario(db, userId, clinicaId) {
if (!userId || !clinicaId) return { ehDono: false, setorId: null };
const { rows: u } = await db.query('SELECT role FROM usuarios WHERE id = $1', [userId]);
if (u[0]?.role === 'superadmin') return { ehDono: true, setorId: null };
const { rows } = await db.query('SELECT role, setor_id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [userId, clinicaId]);
if (!rows.length) return { ehDono: false, setorId: null };
return { ehDono: DONO_ROLES.includes(rows[0].role), setorId: rows[0].setor_id || null };
}
// Carrega um tratamento orto e valida o vínculo do usuário com a clínica dele (403 se não).
async function assertOrtoScoped(req, res) {
const { rows } = await pool.query('SELECT clinica_id FROM ortho_tratamento WHERE id = $1', [req.params.id]);
@@ -3839,23 +4126,66 @@ app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
try {
const clinicaId = req.clinicaId;
const { rows } = await pool.query(
`SELECT u.id, u.nome, u.email,
BOOL_OR(v.clinica_id = $1) AS interno
`SELECT u.id, u.nome, u.email, u.celular,
BOOL_OR(v.clinica_id = $1) AS interno,
BOOL_OR(pf.favorito) AS favorito,
BOOL_OR(pf.padrao) AS padrao
FROM usuarios u
LEFT JOIN vinculos v ON v.usuario_id = u.id
LEFT JOIN protese_protetico_pref pf ON pf.protetico_id = u.id AND pf.clinica_id = $1
WHERE u.role = 'protetico'
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true)
GROUP BY u.id, u.nome, u.email
ORDER BY interno DESC, u.nome`, [clinicaId]);
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true OR pf.clinica_id = $1)
GROUP BY u.id, u.nome, u.email, u.celular
ORDER BY u.nome`, [clinicaId]);
// Nota ScoreOdonto por protético (ranking no marketplace) — mesma fórmula da tela do lab.
const notas = await notasScorePorProtetico(rows.map(r => r.id));
const lista = rows.map(r => ({ ...r, interno: !!r.interno, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }));
// Ranking: internos primeiro, depois maior nota, depois nome.
lista.sort((a, b) => (b.interno ? 1 : 0) - (a.interno ? 1 : 0) || (b.nota ?? -1) - (a.nota ?? -1) || a.nome.localeCompare(b.nome));
const lista = rows.map(r => ({ ...r, interno: !!r.interno, favorito: !!r.favorito, padrao: !!r.padrao, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }));
// Ranking: padrão → favorito → interno → maior nota nome.
lista.sort((a, b) => (b.padrao ? 1 : 0) - (a.padrao ? 1 : 0) || (b.favorito ? 1 : 0) - (a.favorito ? 1 : 0) || (b.interno ? 1 : 0) - (a.interno ? 1 : 0) || (b.nota ?? -1) - (a.nota ?? -1) || a.nome.localeCompare(b.nome));
res.json(lista);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Marcar protético como FAVORITO (★) e/ou PADRÃO (pré-selecionado) na clínica ativa.
app.post('/api/protese/laboratorios/:proteticoId/pref', tenantGuard, async (req, res) => {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try {
const protId = req.params.proteticoId;
const { rows: pr } = await pool.query("SELECT 1 FROM usuarios WHERE id=$1 AND role='protetico'", [protId]);
if (!pr.length) return res.status(404).json({ error: 'Protético não encontrado.' });
const favorito = req.body?.favorito;
const padrao = req.body?.padrao;
// Só pode haver UM padrão por clínica.
if (padrao === true) await pool.query('UPDATE protese_protetico_pref SET padrao=false WHERE clinica_id=$1', [req.clinicaId]);
// COALESCE(EXCLUDED...) preserva o outro flag quando só um é enviado.
await pool.query(
`INSERT INTO protese_protetico_pref (clinica_id, protetico_id, favorito, padrao)
VALUES ($1,$2,$3,$4)
ON CONFLICT (clinica_id, protetico_id) DO UPDATE
SET favorito = COALESCE(EXCLUDED.favorito, protese_protetico_pref.favorito),
padrao = COALESCE(EXCLUDED.padrao, protese_protetico_pref.padrao)`,
[req.clinicaId, protId, favorito ?? null, padrao ?? null]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Vincular um protético EXISTENTE (com conta) como interno da clínica — resolve "não aparece no
// dropdown" sem precisar do diretório público. Busca por e-mail exato.
app.post('/api/protese/laboratorios/vincular', tenantGuard, async (req, res) => {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try {
const email = (req.body?.email || '').toLowerCase().trim();
if (!email) return res.status(400).json({ error: 'E-mail obrigatório.' });
const { rows } = await pool.query("SELECT id, nome, email FROM usuarios WHERE lower(email)=$1 AND role='protetico'", [email]);
if (!rows.length) return res.status(404).json({ error: 'Nenhum protético com este e-mail. Confira ou cadastre um protético interno.' });
const p = rows[0];
await pool.query(
"INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1,$2,$3,'protetico') ON CONFLICT (usuario_id, clinica_id) DO NOTHING",
[`v_${p.id}_${req.clinicaId}`, p.id, req.clinicaId]);
res.json({ success: true, protetico: { id: p.id, nome: p.nome, email: p.email } });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Protético INTERNO (sem conta): a clínica registra um protético que não usa a plataforma.
// Cria um usuário "mudo" (e-mail placeholder, senha-fantasma) + vínculo com a clínica (interno,
// fora do diretório). A partir daí já recebe OS e o link /p/TOKEN.
@@ -6195,6 +6525,10 @@ async function runMigrations() {
// Slot único só vale para status ATIVOS — assim cancelar/faltar libera o slot p/ reagendar.
`ALTER TABLE agendamentos DROP CONSTRAINT IF EXISTS unique_dentista_slot`,
`CREATE UNIQUE INDEX IF NOT EXISTS unique_dentista_slot_active ON agendamentos (dentistaid, start_time) WHERE lower(status) NOT IN ('cancelado','falta','remarcar')`,
// Leitura da agenda por clínica + janela de datas (GET /api/agendamentos?from&to).
`CREATE INDEX IF NOT EXISTS idx_agendamentos_clinica_start ON agendamentos (clinica_id, start_time)`,
// Anti-overbooking entre clínicas p/ dentista sem conta: busca por dentistaid + janela.
`CREATE INDEX IF NOT EXISTS idx_agendamentos_dentista_start ON agendamentos (dentistaid, start_time)`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS pacienteid TEXT`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS pacientecelular TEXT`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS created_by TEXT`,
@@ -6209,6 +6543,13 @@ async function runMigrations() {
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS google_event_id TEXT`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS cor TEXT`,
// ── Isolamento por setor (opt-in) ──────────────────────────────────────
// O agendamento carimba o setor a que pertence (do dentista, ou de quem o
// criou). NULL = "geral" (visível a todos → retrocompat). dentistas.setor_id
// é o reforço opcional para ancorar a agenda do profissional num setor.
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS setor_id TEXT`,
`ALTER TABLE dentistas ADD COLUMN IF NOT EXISTS setor_id TEXT`,
`CREATE INDEX IF NOT EXISTS idx_agendamentos_clinica_setor_start ON agendamentos (clinica_id, setor_id, start_time)`,
`CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
clinica_id TEXT,
@@ -6858,6 +7199,15 @@ async function runMigrations() {
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_lab ON protese_coleta (protetico_id, data_hora)`,
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_os ON protese_coleta (os_id)`,
// Preferência de protético por clínica: favorito (★) e padrão (pré-selecionado na nova OS/GTO).
`CREATE TABLE IF NOT EXISTS protese_protetico_pref (
clinica_id TEXT NOT NULL,
protetico_id TEXT NOT NULL,
favorito BOOLEAN DEFAULT false,
padrao BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (clinica_id, protetico_id)
)`,
`CREATE TABLE IF NOT EXISTS protese_produtos (
id TEXT PRIMARY KEY,
protetico_id TEXT NOT NULL, -- dono (usuário protético)
@@ -6870,6 +7220,26 @@ async function runMigrations() {
deleted_at TIMESTAMPTZ
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_produtos_prot ON protese_produtos (protetico_id, ativo)`,
// Autorizações delegadas de sessão de WhatsApp (NewWhats), por sessão específica.
// Dono do workspace (clinicas.owner_id / salas.owner_usuario_id) tem poder total
// implícito e NÃO aparece aqui; esta tabela guarda apenas delegações a outras contas.
`CREATE TABLE IF NOT EXISTS wa_session_authz (
clinica_id TEXT NOT NULL, -- workspace (clínica ou sala) dono da sessão
instance_id TEXT NOT NULL, -- id da instância/sessão no motor
usuario_id TEXT NOT NULL, -- conta autorizada
can_rescan BOOLEAN NOT NULL DEFAULT false, -- pode re-parear (QR) esta sessão
can_delete BOOLEAN NOT NULL DEFAULT false, -- pode excluir esta sessão
granted_by TEXT, -- quem concedeu (dono)
granted_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (clinica_id, instance_id, usuario_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_wa_authz_lookup ON wa_session_authz (clinica_id, instance_id, usuario_id)`,
// Acesso a áreas do WhatsApp (Inbox / Secretária) delegado pelo dono, por conta.
// Armazenado na mesma tabela por conveniência (o painel do dono gerencia tudo junto).
`ALTER TABLE wa_session_authz ADD COLUMN IF NOT EXISTS can_inbox BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE wa_session_authz ADD COLUMN IF NOT EXISTS can_secretaria BOOLEAN NOT NULL DEFAULT false`,
// Apagar conversa/mensagem no inbox (ação destrutiva) — só dono e quem ele liberar.
`ALTER TABLE wa_session_authz ADD COLUMN IF NOT EXISTS can_delete_msg BOOLEAN NOT NULL DEFAULT false`,
];
for (const sql of migrations) {
try { await pool.query(sql); } catch (e) { console.error('[MIGRATION]', e.message); }
@@ -9068,6 +9438,27 @@ 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 r = await fetch(`${cfg.motorUrl}/api/ext/v1/secretaria/session-power/log?limit=${limit}`, {
headers: { 'x-nw-key': cfg.integrationKey, 'x-nw-email': email },
});
if (!r.ok) return res.status(502).json({ error: 'Motor indisponível', status: r.status });
const log = await r.json();
res.json({ configurado: true, log: Array.isArray(log) ? log : [] });
} 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 = {
@@ -9769,6 +10160,9 @@ app.post('/api/admin/repasses/:id/confirmar', adminGuard, async (req, res) => {
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// ── Integração NewWhats (satélite WhatsApp/Secretária IA) — odonto ──────────
registerNewwhats(app, pool, { superadminGuard });
const httpServer = app.listen(PORT, '0.0.0.0', async () => {
console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`);
console.log(`[VERSION] ${BUILD_INFO.name} ${BUILD_INFO.version} · commit ${BUILD_INFO.commit} · build ${BUILD_INFO.build || 'n/a'} · ${BUILD_INFO.environment}`);
@@ -9785,7 +10179,24 @@ const httpServer = app.listen(PORT, '0.0.0.0', async () => {
// ── WebSocket server (tempo real) em /api/ws ────────────────────────────────
// Auth via query string (?token=&clinicaId=), pois EventSource/WebSocket do
// navegador não enviam header Authorization. Valida JWT + vínculo com a clínica.
const wss = new WebSocketServer({ server: httpServer, path: '/api/ws' });
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(pool);
httpServer.on('upgrade', (req, socket, head) => {
let pathname;
try { pathname = new URL(req.url, 'http://localhost').pathname; }
catch { socket.destroy(); return; }
if (pathname === '/api/ws') {
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
} else if (pathname === '/api/nw/v1/stream') {
nwWsTunnel(req, socket, head);
} else {
socket.destroy();
}
});
wss.on('connection', async (ws, req) => {
try {
const u = new URL(req.url, 'http://localhost');
+24 -10
View File
@@ -1,11 +1,18 @@
# ─── Dev override — NO docker compose build required ─────────────────────────
# ─── Dev override — modo desenvolvimento com HMR ─────────────────────────────
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# Uso:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
#
# What changes in dev:
# - Backend: Runs node server.js with direct hot mount of backend/
# - Frontend: Runs vite dev server on port 3013 with Hot Module Replacement (HMR)
# O que muda em relação à produção:
# - Backend: monta ./backend ao vivo (node server.js), preservando o
# node_modules da imagem (node:20-slim) — código reflete sem rebuild.
# - Frontend: builda o stage `builder` (node:20-alpine, que TEM vite +
# node_modules no arch musl correto) e roda o vite dev server na 3013 com
# Hot Module Replacement. O nginx (default.conf) já faz proxy de / → :3013
# com headers de Upgrade, então o WS de HMR passa.
#
# ⚠️ Este box serve APENAS dev.scoreodonto.com. A produção (scoreodonto.com)
# roda em outro servidor — flipar esta stack para dev NÃO afeta produção.
# ─────────────────────────────────────────────────────────────────────────────
services:
@@ -15,13 +22,20 @@ services:
- NODE_ENV=development
volumes:
- "/home/deploy/scoreodonto-sessions:/app/sessions"
- "./backend:/app"
- "/home/deploy/scoreodonto-media:/app/media"
- "./backend:/app" # source ao vivo (server.js + newwhats/ do túnel)
- "/app/node_modules" # preserva node_modules da imagem (arch correto)
- "./plugins:/app/plugins"
scoreodonto-frontend:
build:
context: "."
dockerfile: frontend/Dockerfile
target: builder # node:20-alpine com vite + node_modules (musl)
command: ["npx", "vite", "--port", "3013", "--host", "0.0.0.0"]
environment:
- PORT=3013
- NODE_ENV=development
- DEV_PUBLIC_HOST=dev.scoreodonto.com # libera host + roteia HMR por wss:443 (vite.config)
volumes:
- "./frontend:/app"
- /app/node_modules
- "./frontend:/app/frontend" # source ao vivo (HMR)
- "/app/frontend/node_modules" # preserva node_modules da imagem (musl)
+1
View File
@@ -35,6 +35,7 @@ services:
- "./backend/.env"
environment:
- BAILEYS_ENGINE=infinite
- TZ=America/Sao_Paulo
- PORT=8018
- NODE_ENV=production
# Senha do banco vem do ambiente (.env raiz em DEV; provisionado fora do git em PROD).
+71 -5
View File
@@ -4,7 +4,7 @@ import { HybridBackend } from './services/backend.ts';
import { Sidebar } from './components/Sidebar.tsx';
import {
Menu, LayoutDashboard, Users, Calendar as CalendarIcon,
DollarSign, Activity, ClipboardList, Bell, Settings, MoreHorizontal, Eye, X
DollarSign, Activity, ClipboardList, Bell, Settings, MoreHorizontal, Eye, X, PanelLeftOpen
} from 'lucide-react';
import { ToastContainer } from './components/Toast.tsx';
@@ -12,6 +12,11 @@ import { useAppVersion } from './buildInfo.ts';
import { Dashboard } from './views/Dashboard.tsx';
import { LeadsView } from './views/LeadsView.tsx';
import { WaInboxView } from './views/WaInboxView.tsx';
import { SessionsView } from './views/newwhats/SessionsView.tsx';
import { InboxView } from './views/newwhats/InboxView.tsx';
import { SecretariaView } from './views/newwhats/SecretariaView.tsx';
import { WaSecretariaView } from './views/WaSecretariaView.tsx';
import { PublicContactForm } from './views/PublicContactForm.tsx';
import { PatientsView } from './views/PatientsView.tsx';
import { AgendaView } from './views/AgendaView.tsx';
@@ -86,6 +91,9 @@ export type ViewKey =
| 'contratos'
| 'plugins'
| 'rx-radiografias'
| 'wa-inbox'
| 'wa-sessions'
| 'wa-secretaria'
| 'salas'
| 'minhas-salas'
| 'alugar-salas'
@@ -113,6 +121,7 @@ export type ViewKey =
| 'superadmin-logs'
| 'superadmin-financeiro'
| 'superadmin-notificacoes'
| 'superadmin-secretaria'
| 'diretorio-profissionais';
// ------- URL <-> VIEW mapping -------
@@ -144,6 +153,9 @@ const ROUTE_MAP: Record<string, ViewKey> = {
'/contratos': 'contratos',
'/plugins': 'plugins',
'/rx-radiografias': 'rx-radiografias',
'/wa-inbox': 'wa-inbox',
'/wa-sessions': 'wa-sessions',
'/wa-secretaria': 'wa-secretaria',
'/salas': 'salas',
'/minhas-salas': 'minhas-salas',
'/alugar-salas': 'alugar-salas',
@@ -170,6 +182,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',
@@ -207,6 +220,9 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
contratos: '/contratos',
plugins: '/plugins',
'rx-radiografias': '/rx-radiografias',
'wa-inbox': '/wa-inbox',
'wa-sessions': '/wa-sessions',
'wa-secretaria': '/wa-secretaria',
salas: '/salas',
'minhas-salas': '/minhas-salas',
'alugar-salas': '/alugar-salas',
@@ -234,6 +250,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',
};
@@ -301,6 +318,21 @@ 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; }
});
const toggleSidebarCollapsed = (v: boolean) => {
setSidebarCollapsed(v);
try { localStorage.setItem('scoreodonto:sidebar-collapsed', v ? '1' : '0'); } catch { /* ignore */ }
};
const [, forcePwRerender] = useState(0); // re-avalia o modal de troca obrigatória de senha
const currentRole = HybridBackend.getCurrentRole();
const activeWorkspace = HybridBackend.getActiveWorkspace();
@@ -315,6 +347,9 @@ const App: React.FC = () => {
// Locação de Salas: capability PESSOAL (add-on por conta), liberada a qualquer
// usuário com o plugin ativo na conta — independe de ter clínica/cargo.
if (view === 'salas' || view === 'minhas-salas' || view === 'alugar-salas') return isPluginActive('locacao-salas');
// NewWhats: Inbox e Secretária liberadas a qualquer usuário com o plugin ativo
// (a CONFIG do plugin é exclusiva do superadmin via view 'plugins').
if (view === 'wa-inbox' || view === 'wa-sessions' || view === 'wa-secretaria') return isPluginActive('newwhats');
// Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return ['sala-home', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
// Contexto de LABORATÓRIO: área do protético — painel do lab + bancada/conta/notificações.
@@ -483,7 +518,7 @@ const App: React.FC = () => {
case 'leads': return <LeadsView />;
case 'public': return <PublicContactForm />;
case 'pacientes': return <PatientsView />;
case 'agenda': return <AgendaView onNavigate={handleNavigate} />;
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 />;
@@ -506,6 +541,9 @@ const App: React.FC = () => {
case 'contratos': return <ContratosView />;
case 'plugins': return <PluginsView />;
case 'rx-radiografias': return <RXPlugin />;
case 'wa-inbox': return <InboxView onNavigate={handleNavigate} />;
case 'wa-sessions': return <SessionsView onNavigate={handleNavigate} />;
case 'wa-secretaria': return <SecretariaView onNavigate={handleNavigate} />;
case 'salas': return <SalasPlugin />;
case 'minhas-salas': return <SalasPlugin view="minhas" />;
case 'alugar-salas': return <SalasPlugin view="alugar" />;
@@ -533,6 +571,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':
@@ -541,7 +580,11 @@ const App: React.FC = () => {
}
};
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite'].includes(currentView);
// Views do plugin NewWhats rodam em tela cheia (sem sidebar/barra do
// scoreodonto) — cada uma tem seu próprio "Voltar" (onNavigate) para sair.
// /wa-sessions (gestão de sessões WhatsApp) roda COM a sidebar do scoreodonto
// (é área de administração). wa-inbox/wa-secretaria seguem em tela cheia.
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite', 'wa-inbox', 'wa-secretaria'].includes(currentView);
return (
<>
@@ -567,7 +610,7 @@ const App: React.FC = () => {
></div>
)}
<div className={`fixed inset-y-0 left-0 z-30 transform transition-transform duration-300 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)}
@@ -576,6 +619,18 @@ const App: React.FC = () => {
</>
)}
{/* Botão flutuante para reexibir a sidebar (desktop, quando recolhida).
Na agenda NÃO aparece — ela já tem o próprio toggle ao lado do título. */}
{!isStandaloneView && isSidebarCollapsed && currentView !== 'agenda' && (
<button
onClick={() => toggleSidebarCollapsed(false)}
title="Mostrar menu"
className="hidden md:flex fixed top-3 left-3 z-40 items-center justify-center w-9 h-9 bg-white border border-gray-200 rounded-lg text-gray-500 hover:text-gray-800 hover:border-gray-300 shadow-sm transition-colors"
>
<PanelLeftOpen size={18} />
</button>
)}
<main className={`flex-1 h-full relative flex flex-col min-w-0`}>
{HybridBackend.isPreviewMode() && (
<div className="bg-red-600 text-white px-4 py-2 flex items-center justify-between gap-3 shadow-md z-30">
@@ -608,7 +663,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>
</>
)}
+9 -1
View File
@@ -6,7 +6,7 @@ import { AvaliacaoEditor } from './AvaliacaoEditor.tsx';
import {
User, Stethoscope, Clock, CalendarRange, AlertCircle, CheckCircle2,
History, RotateCcw, CalendarClock, Ban, Users, Pencil, ClipboardCheck, ShieldAlert,
Search, UserPlus, Link2, Trash2, ClipboardList
Search, UserPlus, Link2, Trash2, ClipboardList, MessageCircle
} from 'lucide-react';
interface Props {
@@ -17,6 +17,7 @@ interface Props {
onChanged: () => void; // recarrega a agenda
onEditar: () => void; // abre o modal de edição/reagendar (mover de horário)
onAtender?: () => void;
onVerWhats?: (info: { pacienteId: string; pacienteNome: string }) => void; // abre o drawer de WhatsApp do paciente
podeAtender?: boolean;
dentistaRestrito?: boolean; // dentista linkado por clínica: modal simples, sem dados sensíveis/agendamento
}
@@ -492,6 +493,13 @@ const DetailPage: React.FC<Props> = (p) => {
{/* Navegação / ações principais */}
<div className="space-y-2">
{p.onVerWhats && pacienteId && (
<button onClick={() => p.onVerWhats?.({ pacienteId, pacienteNome: nome })}
className="w-full flex items-center justify-between px-4 py-2.5 rounded-xl bg-emerald-50 hover:bg-emerald-100 transition-colors text-xs font-black uppercase text-emerald-700">
<span className="flex items-center gap-2"><MessageCircle size={15} /> Ver WhatsApp</span>
<span className="text-emerald-300"></span>
</button>
)}
<button onClick={() => push({ title: 'HISTÓRICO', render: () => <HistoryPage appointmentId={ag.id} /> })}
className="w-full flex items-center justify-between px-4 py-2.5 rounded-xl bg-gray-50 hover:bg-gray-100 transition-colors text-xs font-black uppercase text-gray-600">
<span className="flex items-center gap-2"><History size={15} /> Histórico</span>
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { HorariosConfig } from './HorariosConfig.tsx';
import { X, ChevronLeft, ChevronRight, Save, Mail, Calendar, Settings as GearIcon, CheckCircle2 } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { Settings, Dentista } from '../types.ts';
@@ -22,6 +23,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
});
const [isSaving, setIsSaving] = useState(false);
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
const [showHorarios, setShowHorarios] = useState(false);
const toast = useToast();
// Escopo: tudo (status Google, convites, settings) é da clínica/workspace ativa.
const clinicaId = HybridBackend.getActiveWorkspace()?.id;
@@ -84,6 +86,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
const adminAccount = connectedAccounts.find(a => a.owner_id && a.owner_id.includes('@'));
return (
<>
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-md p-0">
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-[2.5rem] flex flex-col overflow-hidden border border-gray-100 animate-in fade-in zoom-in-95 duration-300">
@@ -135,6 +138,15 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
/>
<p className="text-[10px] text-gray-400 italic ml-1">* Este e-mail será usado para alertas de sistema e envio de comprovantes.</p>
</div>
<button onClick={() => setShowHorarios(true)}
className="w-full flex items-center justify-between bg-teal-50 hover:bg-teal-100 border-2 border-teal-100 rounded-2xl p-4 transition-colors group">
<div className="text-left">
<p className="text-sm font-black text-teal-800 uppercase tracking-tight">Horários de Funcionamento</p>
<p className="text-[10px] font-bold text-teal-500 uppercase tracking-widest mt-0.5">Dias/horas da clínica · feriados · horários dos dentistas</p>
</div>
<span className="text-teal-600 font-black text-lg group-hover:translate-x-1 transition-transform"></span>
</button>
</div>
</div>
@@ -275,5 +287,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
}
`}</style>
</div>
<HorariosConfig isOpen={showHorarios} onClose={() => setShowHorarios(false)} clinicaId={clinicaId} />
</>
);
};
+333
View File
@@ -0,0 +1,333 @@
// Configuração de horários de funcionamento (clínica) + feriados/fechamentos e
// 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';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
const BASE = `${API}/nw/agenda-config`;
const DIAS = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado'];
async function req(path: string, opts: RequestInit = {}) {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const res = await fetch(`${BASE}${path}`, {
...opts,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) },
});
const txt = await res.text();
const data = txt ? JSON.parse(txt) : {};
if (!res.ok) throw new Error(data.error || `Erro ${res.status}`);
return data;
}
interface Faixa { dia_semana: number; hora_inicio: string; hora_fim: string; hora_inicio_flex?: string | null; hora_fim_flex?: string | null }
interface Dentista { id: string; nome: string; usuario_id: string | null; eh_voce: boolean }
// Editor de grade semanal (7 dias, cada um com 0..n faixas). Reusado p/ clínica e dentista.
const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void; disabled?: boolean; withFlex?: boolean }> = ({ faixas, onChange, disabled, withFlex }) => {
const porDia = (d: number) => faixas.filter((f) => f.dia_semana === d);
const setDia = (d: number, novas: Faixa[]) => onChange([...faixas.filter((f) => f.dia_semana !== d), ...novas]);
// Copia as faixas do dia `d` para TODOS os outros dias.
const duplicarParaTodos = (d: number) => {
const base = porDia(d).map((f) => ({ hora_inicio: f.hora_inicio, hora_fim: f.hora_fim }));
onChange(DIAS.flatMap((_, x) => base.map((f) => ({ dia_semana: x, ...f }))));
};
return (
<div className="space-y-2">
{DIAS.map((nome, d) => {
const fs = porDia(d);
return (
<div key={d} className="flex items-start gap-3 py-2 border-b border-gray-50">
<div className="w-24 shrink-0 pt-2 text-xs font-black text-gray-700 uppercase tracking-tight">{nome}</div>
<div className="flex-1 space-y-2">
{fs.length === 0 && <span className="text-[11px] text-gray-400 italic">Fechado</span>}
{fs.map((f, i) => {
const upd = (patch: Partial<Faixa>) => { const n = [...fs]; n[i] = { ...f, ...patch }; setDia(d, n); };
return (
<div key={i} className="space-y-1">
<div className="flex items-center gap-2">
<input type="time" disabled={disabled} value={f.hora_inicio} onChange={(e) => upd({ hora_inicio: e.target.value })}
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" />
<span className="text-gray-400 text-xs font-bold">até</span>
<input type="time" disabled={disabled} value={f.hora_fim} onChange={(e) => upd({ hora_fim: e.target.value })}
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" />
{withFlex && <span className="text-[9px] font-black text-teal-500 uppercase tracking-wide">IA agenda livre</span>}
{!disabled && <button onClick={() => setDia(d, fs.filter((_, j) => j !== i))} className="text-gray-300 hover:text-red-500 p-1"><Trash2 size={15} /></button>}
</div>
{withFlex && (
<div className="flex items-center gap-2 flex-wrap pl-1 text-[11px] text-gray-500">
<span className="font-bold text-amber-600">Aceito confirmar também:</span>
<span>antes, a partir de</span>
<input type="time" disabled={disabled} value={f.hora_inicio_flex ?? ''} onChange={(e) => upd({ hora_inicio_flex: e.target.value || null })}
className="bg-amber-50 border-2 border-transparent focus:border-amber-500 rounded-lg px-2 py-1 text-xs font-bold text-gray-800 outline-none w-24" />
<span>e depois, até</span>
<input type="time" disabled={disabled} value={f.hora_fim_flex ?? ''} onChange={(e) => upd({ hora_fim_flex: e.target.value || null })}
className="bg-amber-50 border-2 border-transparent focus:border-amber-500 rounded-lg px-2 py-1 text-xs font-bold text-gray-800 outline-none w-24" />
</div>
)}
</div>
);
})}
{!disabled && (
<button onClick={() => setDia(d, [...fs, { dia_semana: d, hora_inicio: '08:00', hora_fim: '18:00' }])}
className="text-[11px] font-black text-teal-600 hover:text-teal-800 uppercase tracking-wide flex items-center gap-1">
<Plus size={13} /> {fs.length ? 'Outra faixa' : 'Abrir neste dia'}
</button>
)}
{!disabled && fs.length > 0 && (
<button onClick={() => duplicarParaTodos(d)} title="Aplicar este mesmo horário a todos os dias da semana"
className="text-[10px] font-black text-gray-400 hover:text-teal-600 uppercase tracking-wide flex items-center gap-1">
<Copy size={11} /> Copiar p/ todos os dias
</button>
)}
</div>
</div>
);
})}
</div>
);
};
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');
useEffect(() => { if (isOpen && initialTab) setAba(initialTab); }, [isOpen, initialTab]);
const [erro, setErro] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Clínica
const [clinFaixas, setClinFaixas] = useState<Faixa[]>([]);
const [feriados, setFeriados] = useState<{ excecoes: any[]; nacionais: any[] }>({ excecoes: [], nacionais: [] });
const [novo, setNovo] = useState({ data: '', nome: '', fecha: true });
// Dentistas
const [dentistas, setDentistas] = useState<Dentista[]>([]);
const [dentSel, setDentSel] = useState<string>('');
const [dentFaixas, setDentFaixas] = useState<Faixa[]>([]);
const [folgas, setFolgas] = useState<any[]>([]);
const [novaFolga, setNovaFolga] = useState({ data_inicio: '', data_fim: '', motivo: '' });
// Situações (regras que a Secretária IA segue)
const [situacoes, setSituacoes] = useState<string[]>([]);
const flash = (setter: (v: string | null) => void, msg: string) => { setter(msg); setTimeout(() => setter(null), 3000); };
const carregarClinica = useCallback(async () => {
if (!clinicaId) return;
try {
const h = await req(`/clinica/${clinicaId}/horarios`);
setClinFaixas((h.horarios || []).map((r: any) => ({ dia_semana: r.dia_semana, hora_inicio: r.hora_inicio, hora_fim: r.hora_fim })));
const f = await req(`/clinica/${clinicaId}/feriados?ano=${new Date().getFullYear()}`);
setFeriados({ excecoes: f.excecoes || [], nacionais: f.nacionais || [] });
} catch (e: any) { flash(setErro, e.message); }
}, [clinicaId]);
const carregarDentistas = useCallback(async () => {
if (!clinicaId) return;
try {
const d = await req(`/clinica/${clinicaId}/dentistas`);
let list: Dentista[] = d.dentistas || [];
if (soDentista) { const meus = list.filter((x) => x.eh_voce); if (meus.length) list = meus; } // dentista só vê o próprio
setDentistas(list);
const first = list.find((x: Dentista) => x.eh_voce) || list[0];
if (first) setDentSel(first.id);
} catch (e: any) { flash(setErro, e.message); }
}, [clinicaId, soDentista]);
const carregarSituacoes = useCallback(async () => {
if (!clinicaId) return;
try { const s = await req(`/clinica/${clinicaId}/situacoes`); setSituacoes((s.situacoes || []).map((r: any) => r.texto)); }
catch (e: any) { flash(setErro, e.message); }
}, [clinicaId]);
useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); if (!soDentista) carregarSituacoes(); } }, [isOpen, carregarClinica, carregarDentistas, carregarSituacoes, soDentista]);
const carregarFolgas = useCallback((id: string) => {
req(`/dentista/${id}/folgas`).then((f) => setFolgas(f.folgas || [])).catch(() => setFolgas([]));
}, []);
useEffect(() => {
if (!dentSel) { setDentFaixas([]); setFolgas([]); return; }
req(`/dentista/${dentSel}/horarios`).then((h) =>
setDentFaixas((h.horarios || []).map((r: any) => ({ dia_semana: r.dia_semana, hora_inicio: r.hora_inicio, hora_fim: r.hora_fim, hora_inicio_flex: r.hora_inicio_flex, hora_fim_flex: r.hora_fim_flex })))
).catch((e) => flash(setErro, e.message));
carregarFolgas(dentSel);
}, [dentSel, carregarFolgas]);
const addFolga = async () => {
if (!novaFolga.data_inicio) { flash(setErro, 'Informe a data.'); return; }
try { await req(`/dentista/${dentSel}/folgas`, { method: 'POST', body: JSON.stringify(novaFolga) }); setNovaFolga({ data_inicio: '', data_fim: '', motivo: '' }); carregarFolgas(dentSel); flash(setOk, 'Folga adicionada!'); }
catch (e: any) { flash(setErro, e.message); }
};
const delFolga = async (id: string) => {
try { await req(`/dentista/${dentSel}/folgas/${id}`, { method: 'DELETE' }); carregarFolgas(dentSel); } catch (e: any) { flash(setErro, e.message); }
};
const salvarClinica = async () => {
setSaving(true); setErro(null);
try { await req(`/clinica/${clinicaId}/horarios`, { method: 'PUT', body: JSON.stringify({ horarios: clinFaixas }) }); flash(setOk, 'Horário da clínica salvo!'); }
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
};
const salvarDentista = async () => {
setSaving(true); setErro(null);
try { await req(`/dentista/${dentSel}/horarios`, { method: 'PUT', body: JSON.stringify({ horarios: dentFaixas }) }); flash(setOk, 'Horário do dentista salvo!'); }
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
};
const addFeriado = async () => {
if (!novo.data || !novo.nome) { flash(setErro, 'Informe data e nome.'); return; }
try { await req(`/clinica/${clinicaId}/feriados`, { method: 'POST', body: JSON.stringify(novo) }); setNovo({ data: '', nome: '', fecha: true }); carregarClinica(); flash(setOk, 'Fechamento adicionado!'); }
catch (e: any) { flash(setErro, e.message); }
};
const delFeriado = async (id: string) => {
try { await req(`/clinica/${clinicaId}/feriados/${id}`, { method: 'DELETE' }); carregarClinica(); }
catch (e: any) { flash(setErro, e.message); }
};
const salvarSituacoes = async () => {
setSaving(true); setErro(null);
try { await req(`/clinica/${clinicaId}/situacoes`, { method: 'PUT', body: JSON.stringify({ situacoes: situacoes.map((s) => s.trim()).filter(Boolean) }) }); flash(setOk, 'Situações salvas!'); }
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-4xl md:h-[94vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
{/* Header */}
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-teal-600 rounded-2xl shadow-lg shadow-teal-200"><Clock size={22} className="text-white" /></div>
<div>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Horários de Funcionamento</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">A Secretária IA usa isto para atender</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
</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>
{(erro || ok) && (
<div className={`px-8 py-2 text-xs font-bold ${erro ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>{erro || ok}</div>
)}
<div className="flex-1 overflow-y-auto p-8">
{aba === 'clinica' ? (
<div className="space-y-10">
<section>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2"><Clock size={16} className="text-teal-600" /> Dias e horários que a clínica funciona</h3>
<button onClick={salvarClinica} 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>
<GradeSemanal faixas={clinFaixas} onChange={setClinFaixas} />
<p className="text-[11px] text-gray-400 italic mt-3">Sem horário configurado, assume-se segsex 08h18h. o dono edita.</p>
</section>
<section>
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2 mb-4"><CalendarOff size={16} className="text-teal-600" /> Feriados e fechamentos</h3>
<div className="flex flex-wrap items-end gap-2 mb-4 bg-gray-50 p-4 rounded-2xl">
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Data</label>
<input type="date" value={novo.data} onChange={(e) => setNovo({ ...novo, data: e.target.value })} className="bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
<div className="flex-1 min-w-[160px]"><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Motivo</label>
<input type="text" placeholder="Ex: Recesso, ponte" value={novo.nome} onChange={(e) => setNovo({ ...novo, nome: e.target.value })} className="w-full bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
<button onClick={addFeriado} 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} /> Adicionar</button>
</div>
{feriados.excecoes.length > 0 && (
<div className="space-y-1 mb-4">
{feriados.excecoes.map((f) => (
<div key={f.id} className="flex items-center justify-between bg-red-50 rounded-xl px-3 py-2">
<span className="text-xs font-bold text-red-700">{f.data.split('-').reverse().join('/')} · {f.nome}</span>
<button onClick={() => delFeriado(f.id)} className="text-red-300 hover:text-red-600"><Trash2 size={15} /></button>
</div>
))}
</div>
)}
<details className="text-xs">
<summary className="cursor-pointer font-black text-gray-400 uppercase tracking-wide">Feriados nacionais (automáticos)</summary>
<div className="mt-2 grid grid-cols-2 gap-1">
{feriados.nacionais.map((f) => <div key={f.data} className="text-[11px] text-gray-500">{f.data.split('-').reverse().join('/')} · {f.nome}</div>)}
</div>
</details>
</section>
</div>
) : aba === 'dentistas' ? (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User size={16} className="text-teal-600" />
<select value={dentSel} onChange={(e) => setDentSel(e.target.value)} className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-black text-gray-800 outline-none">
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}{d.eh_voce ? ' (você)' : ''}</option>)}
</select>
</div>
<button onClick={salvarDentista} disabled={saving || !dentSel} 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>
{dentSel ? <GradeSemanal faixas={dentFaixas} onChange={setDentFaixas} withFlex /> : <p className="text-xs text-gray-400">Nenhum dentista.</p>}
<p className="text-[11px] text-gray-400 italic">A IA agenda LIVRE na janela principal. Na janela "aceito confirmar" (mais cedo/mais tarde), ela não confirma sozinha cria um pedido para a secretária humana decidir.</p>
{dentSel && (
<section className="pt-4 border-t border-gray-100">
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2 mb-3"><CalendarOff size={16} className="text-teal-600" /> Folgas e férias (não atende nestas datas)</h3>
<div className="flex flex-wrap items-end gap-2 mb-3 bg-gray-50 p-4 rounded-2xl">
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">De</label>
<input type="date" value={novaFolga.data_inicio} onChange={(e) => setNovaFolga({ ...novaFolga, data_inicio: e.target.value })} className="bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Até (opcional)</label>
<input type="date" value={novaFolga.data_fim} onChange={(e) => setNovaFolga({ ...novaFolga, data_fim: e.target.value })} className="bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
<div className="flex-1 min-w-[140px]"><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Motivo</label>
<input type="text" placeholder="Ex: Férias, congresso" value={novaFolga.motivo} onChange={(e) => setNovaFolga({ ...novaFolga, motivo: e.target.value })} className="w-full bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
<button onClick={addFolga} 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} /> Adicionar</button>
</div>
{folgas.length > 0 ? (
<div className="space-y-1">
{folgas.map((f) => (
<div key={f.id} className="flex items-center justify-between bg-amber-50 rounded-xl px-3 py-2">
<span className="text-xs font-bold text-amber-700">
{f.data_inicio.split('-').reverse().join('/')}{f.data_fim !== f.data_inicio ? ` até ${f.data_fim.split('-').reverse().join('/')}` : ''}{f.motivo ? ` · ${f.motivo}` : ''}
</span>
<button onClick={() => delFolga(f.id)} className="text-amber-300 hover:text-amber-600"><Trash2 size={15} /></button>
</div>
))}
</div>
) : <p className="text-[11px] text-gray-400 italic">Nenhuma folga futura cadastrada.</p>}
</section>
)}
</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" /> Situações e regras da Secretária IA</h3>
<button onClick={salvarSituacoes} 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">
Escreva regras que a Secretária IA deve seguir ao atender uma por caixa. Ex.: <span className="italic">"Pelo plano X não temos profissional para canal em dente posterior; não agende direto — ofereça uma avaliação para o dentista examinar melhor o dente."</span> Quando uma situação exigir decisão humana, a IA encaminha o caso e pede para o paciente aguardar (aparece em Pedidos pendentes).
</p>
<div className="space-y-2">
{situacoes.map((s, i) => (
<div key={i} className="flex items-start gap-2">
<textarea value={s} rows={2} onChange={(e) => setSituacoes(situacoes.map((x, j) => j === i ? e.target.value : x))}
placeholder="Descreva a situação e o que a IA deve fazer…"
className="flex-1 bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-medium text-gray-800 outline-none resize-y" />
<button onClick={() => setSituacoes(situacoes.filter((_, j) => j !== i))} className="mt-2 text-gray-300 hover:text-red-600"><Trash2 size={16} /></button>
</div>
))}
<button onClick={() => setSituacoes([...situacoes, ''])} 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} /> Nova situação</button>
</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>
</div>
</div>
);
};
+141
View File
@@ -0,0 +1,141 @@
// Fila da secretária humana: pedidos de horário da ZONA CINZA que a Secretária IA
// 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';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
const BASE = `${API}/nw/agenda-config`;
async function req(path: string, opts: RequestInit = {}) {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const res = await fetch(`${BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) } });
const t = await res.text(); const d = t ? JSON.parse(t) : {};
if (!res.ok) throw new Error(d.error || `Erro ${res.status}`);
return d;
}
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 }) => {
const [pedidos, setPedidos] = useState<any[]>([]);
const [dentistas, setDentistas] = useState<any[]>([]);
const [sel, setSel] = useState<Record<string, string>>({}); // pedido_id → dentista_id escolhido
const [busy, setBusy] = useState<string | null>(null);
const [erro, setErro] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const carregar = useCallback(async () => {
if (!clinicaId) return;
setLoading(true); setErro(null);
try {
const p = await req(`/clinica/${clinicaId}/pedidos?status=pendente`);
setPedidos(p.pedidos || []);
const d = await req(`/clinica/${clinicaId}/dentistas`);
setDentistas(d.dentistas || []);
} catch (e: any) { setErro(e.message); } finally { setLoading(false); }
}, [clinicaId]);
useEffect(() => { if (isOpen) carregar(); }, [isOpen, carregar]);
const confirmar = async (p: any) => {
const dentistaId = p.dentista_id || sel[p.id];
if (!dentistaId) { setErro('Escolha o dentista para confirmar.'); return; }
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?.(); }
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
};
const recusar = async (p: any) => {
setBusy(p.id); setErro(null);
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/recusar`, { method: 'POST' }); await carregar(); onChange?.(); }
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
};
const resolver = async (p: any) => {
setBusy(p.id); setErro(null);
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/resolver`, { method: 'POST' }); await carregar(); onChange?.(); }
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-3xl md:h-[90vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-amber-500 rounded-2xl shadow-lg shadow-amber-200"><CalendarClock size={22} className="text-white" /></div>
<div>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Pedidos aguardando confirmação</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Horários fora do padrão que a Secretária IA encaminhou</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
</div>
{erro && <div className="px-8 py-2 text-xs font-bold bg-red-50 text-red-600">{erro}</div>}
<div className="flex-1 overflow-y-auto p-6 space-y-3">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div>
) : pedidos.length === 0 ? (
<p className="text-center text-sm text-gray-400 py-16">Nenhum pedido pendente. 🎉</p>
) : pedidos.map((p) => p.tipo === 'duvida' ? (
<div key={p.id} className="border-2 border-indigo-100 rounded-2xl p-4 bg-indigo-50/40">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="space-y-1.5 max-w-[75%]">
<div className="flex items-center gap-1.5 text-[10px] font-black text-indigo-600 uppercase tracking-widest"><HelpCircle size={13} /> Dúvida p/ decidir</div>
<div className="text-sm font-bold text-gray-800 leading-snug whitespace-pre-wrap">{p.resumo}</div>
<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>
</div>
<div className="flex gap-2">
<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
</button>
<button onClick={() => recusar(p)} disabled={busy === p.id}
className="bg-gray-100 hover:bg-red-50 text-gray-500 hover:text-red-600 text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
<Ban size={13} /> Descartar
</button>
</div>
</div>
</div>
) : (
<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>
<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>}
</div>
<div className="flex flex-col items-end gap-2">
{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 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">
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Confirmar
</button>
<button onClick={() => recusar(p)} disabled={busy === p.id}
className="bg-gray-100 hover:bg-red-50 text-gray-500 hover:text-red-600 text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
<Ban size={13} /> Recusar
</button>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
};
+165
View File
@@ -0,0 +1,165 @@
// Área de PENDÊNCIAS DO DONO do workspace (só o dono vê). Engloba tudo:
// 1) Checklist da clínica — perguntas Sim/Não que o dono responde;
// 2) Pendências de configuração — quando a resposta não bate com o banco
// (ex.: marcou "mais de um dentista" mas só há 1 cadastrado);
// 3) Fila operacional — resumo dos pedidos/dúvidas aguardando a secretária humana.
// Consome /api/nw/agenda-config/clinica/:id/{checklist,pendencias}.
import React, { useState, useEffect, useCallback } from 'react';
import { X, ClipboardCheck, AlertTriangle, Check, Ban, Loader2, Save, CalendarClock, HelpCircle, ChevronRight } from 'lucide-react';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
const BASE = `${API}/nw/agenda-config`;
async function req(path: string, opts: RequestInit = {}) {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const res = await fetch(`${BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) } });
const t = await res.text(); const d = t ? JSON.parse(t) : {};
if (!res.ok) throw new Error(d.error || `Erro ${res.status}`);
return d;
}
type Questao = { chave: string; pergunta: string; ajuda?: string; resposta: number | null };
type PendConfig = { chave: string; severidade: string; titulo: string; detalhe: string };
export const PendenciasDono: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onOpenFila?: () => void }> = ({ isOpen, onClose, clinicaId, onOpenFila }) => {
const [questoes, setQuestoes] = useState<Questao[]>([]);
const [config, setConfig] = useState<PendConfig[]>([]);
const [oper, setOper] = useState<{ horarios: number; duvidas: number; total: number }>({ horarios: 0, duvidas: 0, total: 0 });
const [contagens, setContagens] = useState<{ dentistas: number; pacientes: number }>({ dentistas: 0, pacientes: 0 });
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [erro, setErro] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
const flash = (setter: (v: string | null) => void, m: string) => { setter(m); setTimeout(() => setter(null), 3000); };
const carregar = useCallback(async () => {
if (!clinicaId) return;
setLoading(true); setErro(null);
try {
const c = await req(`/clinica/${clinicaId}/checklist`);
setQuestoes(c.questoes || []);
const p = await req(`/clinica/${clinicaId}/pendencias`);
setConfig(p.config || []);
setOper(p.operacionais || { horarios: 0, duvidas: 0, total: 0 });
setContagens(p.contagens || { dentistas: 0, pacientes: 0 });
} catch (e: any) { setErro(e.message); } finally { setLoading(false); }
}, [clinicaId]);
useEffect(() => { if (isOpen) carregar(); }, [isOpen, carregar]);
const setResposta = (chave: string, resposta: number | null) =>
setQuestoes((qs) => qs.map((q) => q.chave === chave ? { ...q, resposta } : q));
const salvar = async () => {
setSaving(true); setErro(null);
try {
const respostas: Record<string, number | null> = {};
questoes.forEach((q) => { respostas[q.chave] = q.resposta; });
await req(`/clinica/${clinicaId}/checklist`, { method: 'PUT', body: JSON.stringify({ respostas }) });
flash(setOk, 'Checklist salvo!');
await carregar();
} catch (e: any) { setErro(e.message); } finally { setSaving(false); }
};
if (!isOpen) return null;
const sevColor = (s: string) => s === 'alta' ? 'border-red-200 bg-red-50' : 'border-amber-200 bg-amber-50';
const sevText = (s: string) => s === 'alta' ? 'text-red-700' : 'text-amber-700';
return (
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-3xl md:h-[92vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-indigo-600 rounded-2xl shadow-lg shadow-indigo-200"><ClipboardCheck size={22} className="text-white" /></div>
<div>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Pendências do workspace</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Config da clínica + o que a Secretária precisa que você resolva</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
</div>
{(erro || ok) && <div className={`px-8 py-2 text-xs font-bold ${erro ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>{erro || ok}</div>}
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div>
) : (
<>
{/* Fila operacional */}
<section>
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3">Aguardando a secretária humana</h3>
<button onClick={() => onOpenFila?.()} disabled={!onOpenFila}
className="w-full flex items-center justify-between gap-3 border-2 border-amber-100 bg-amber-50/50 rounded-2xl px-4 py-3.5 text-left hover:bg-amber-50 disabled:cursor-default disabled:hover:bg-amber-50/50">
<div className="flex items-center gap-3">
<CalendarClock size={20} className="text-amber-600" />
<div>
<div className="text-sm font-black text-gray-800">{oper.total} pedido(s) na fila</div>
<div className="text-[11px] text-gray-500 font-bold">{oper.horarios} horário(s) · {oper.duvidas} dúvida(s)</div>
</div>
</div>
{onOpenFila && <ChevronRight size={18} className="text-amber-400" />}
</button>
</section>
{/* Pendências de configuração */}
<section>
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3">Configuração a resolver</h3>
{config.length === 0 ? (
<p className="text-sm text-gray-400 flex items-center gap-2 py-2"><Check size={16} className="text-emerald-500" /> Nenhuma pendência de configuração. 🎉</p>
) : (
<div className="space-y-2">
{config.map((p, i) => (
<div key={i} className={`border-2 rounded-2xl p-4 ${sevColor(p.severidade)}`}>
<div className={`flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest ${sevText(p.severidade)}`}><AlertTriangle size={13} /> {p.severidade === 'alta' ? 'Importante' : 'Atenção'}</div>
<div className="text-sm font-black text-gray-800 mt-1">{p.titulo}</div>
<div className="text-[12px] text-gray-600 mt-0.5 leading-snug">{p.detalhe}</div>
</div>
))}
</div>
)}
<p className="text-[10px] text-gray-400 mt-2">No sistema: {contagens.dentistas} dentista(s) · {contagens.pacientes} paciente(s) cadastrado(s).</p>
</section>
{/* Checklist */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest">Checklist da clínica</h3>
<button onClick={salvar} disabled={saving} className="bg-indigo-600 hover:bg-indigo-700 text-white text-[11px] font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
{saving ? <Loader2 size={13} className="animate-spin" /> : <Save size={13} />} Salvar
</button>
</div>
<p className="text-[11px] text-gray-500 bg-gray-50 rounded-2xl p-3 mb-3 leading-relaxed">
Responda sobre a clínica. A Secretária usa isto para saber quando pode agendar sozinha quando algo não bate com o cadastro, ela evita agendar e encaminha ao humano, e aparece aqui em cima como pendência.
</p>
<div className="space-y-2">
{questoes.map((q) => (
<div key={q.chave} className="border border-gray-200 rounded-2xl p-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<div className="text-sm font-black text-gray-800 flex items-center gap-1.5"><HelpCircle size={14} className="text-gray-300" /> {q.pergunta}</div>
{q.ajuda && <div className="text-[11px] text-gray-400 mt-1 leading-snug">{q.ajuda}</div>}
</div>
<div className="flex gap-1.5">
<button onClick={() => setResposta(q.chave, q.resposta === 1 ? null : 1)}
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 transition-colors ${q.resposta === 1 ? 'bg-emerald-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-emerald-50'}`}>
<Check size={13} /> Sim
</button>
<button onClick={() => setResposta(q.chave, q.resposta === 0 ? null : 0)}
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 transition-colors ${q.resposta === 0 ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-red-50'}`}>
<Ban size={13} /> Não
</button>
</div>
</div>
</div>
))}
</div>
</section>
</>
)}
</div>
</div>
</div>
);
};
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Search, X, Star, Check, ShieldCheck, UserPlus, Building2 } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
// Seletor de protético em modal: busca por nome/e-mail, favorito (★), padrão (pré-selecionado)
// e "adicionar por e-mail" (vincula como interno — resolve o protético que não está no diretório).
export const ProteticoPicker: React.FC<{ value: string; onChange: (id: string, lab?: any) => void; className?: string }> = ({ value, onChange, className }) => {
const toast = useToast();
const [open, setOpen] = useState(false);
const [labs, setLabs] = useState<any[]>([]);
const [busca, setBusca] = useState('');
const [addEmail, setAddEmail] = useState('');
const [showAdd, setShowAdd] = useState(false);
const [busy, setBusy] = useState(false);
const preselected = useRef(false);
const carregar = useCallback(async (): Promise<any[]> => {
try { const r = await HybridBackend.getProteseLaboratorios(); const arr = Array.isArray(r) ? r : []; setLabs(arr); return arr; }
catch { setLabs([]); return []; }
}, []);
useEffect(() => { carregar(); }, [carregar]);
// Pré-seleciona o protético PADRÃO uma única vez, quando nada foi escolhido ainda.
useEffect(() => {
if (preselected.current || value) return;
const pad = labs.find(l => l.padrao);
if (pad) { preselected.current = true; onChange(pad.id, pad); }
}, [labs, value]); // eslint-disable-line react-hooks/exhaustive-deps
const sel = labs.find(l => l.id === value);
const q = busca.toLowerCase();
const filtrados = labs.filter(l => !q || (l.nome || '').toLowerCase().includes(q) || (l.email || '').toLowerCase().includes(q));
const toggleFav = async (l: any, e: React.MouseEvent) => {
e.stopPropagation();
try { await HybridBackend.setProteticoPref(l.id, { favorito: !l.favorito }); await carregar(); }
catch { toast.error('ERRO'); }
};
const tornarPadrao = async (l: any, e: React.MouseEvent) => {
e.stopPropagation();
try { await HybridBackend.setProteticoPref(l.id, { padrao: !l.padrao }); await carregar(); toast.success(l.padrao ? 'PADRÃO REMOVIDO.' : 'DEFINIDO COMO PADRÃO.'); }
catch { toast.error('ERRO'); }
};
const selecionar = (l: any) => { onChange(l.id, l); setOpen(false); setBusca(''); };
const adicionar = async () => {
if (!addEmail.trim()) return;
setBusy(true);
try {
const r = await HybridBackend.vincularProteticoEmail(addEmail.trim());
if (r?.error) throw new Error(r.error);
toast.success('PROTÉTICO VINCULADO.');
setAddEmail(''); setShowAdd(false);
const novos = await carregar();
const novo = novos.find((x: any) => x.id === r.protetico?.id);
if (novo) selecionar(novo);
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
return (
<>
<button type="button" onClick={() => setOpen(true)}
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm text-left flex items-center justify-between gap-2 uppercase focus:border-teal-400 outline-none transition-all'}>
<span className="truncate flex items-center gap-2">
{sel ? (<><Building2 size={16} className="text-teal-600 shrink-0" /> {sel.nome}{sel.padrao ? ' · PADRÃO' : ''}</>) : 'Escolha o laboratório / protético...'}
</span>
<Search size={16} className="text-gray-400 shrink-0" />
</button>
{open && (
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div className="bg-white rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="font-black text-gray-800 uppercase text-sm">Escolher protético</h3>
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-4 border-b border-gray-50">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar por nome ou e-mail…"
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{filtrados.length === 0 ? (
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nenhum protético encontrado.</p>
) : filtrados.map(l => (
<div key={l.id} onClick={() => selecionar(l)}
className={`flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer ${l.id === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
<button onClick={e => toggleFav(l, e)} title="Favorito" className="shrink-0">
<Star size={16} className={l.favorito ? 'text-amber-400 fill-amber-400' : 'text-gray-300'} />
</button>
<div className="min-w-0 flex-1">
<p className="font-black text-gray-800 text-sm truncate uppercase flex items-center gap-1.5">
{l.nome}{l.verificado && <ShieldCheck size={13} className="text-blue-500 shrink-0" />}
</p>
<p className="text-[10px] font-bold text-gray-400 uppercase truncate flex items-center gap-1.5">
<span className={l.interno ? 'text-teal-600' : ''}>{l.interno ? 'Interno' : 'Diretório'}</span>
{l.nota != null && <span>· {l.nota}</span>}
{l.padrao && <span className="text-violet-600">· Padrão</span>}
</p>
</div>
<button onClick={e => tornarPadrao(l, e)} title="Definir como padrão"
className={`shrink-0 text-[9px] font-black uppercase px-2 py-1 rounded-lg border ${l.padrao ? 'border-violet-300 bg-violet-50 text-violet-700' : 'border-gray-200 text-gray-400'}`}>Padrão</button>
{l.id === value && <Check size={16} className="text-teal-600 shrink-0" />}
</div>
))}
</div>
<div className="p-4 border-t border-gray-100">
{showAdd ? (
<div className="flex gap-2">
<input value={addEmail} onChange={e => setAddEmail(e.target.value)} type="email" placeholder="e-mail do protético"
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400" />
<button disabled={busy} onClick={adicionar} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50">Vincular</button>
</div>
) : (
<button onClick={() => setShowAdd(true)} className="w-full py-2.5 rounded-xl border border-dashed border-gray-300 text-gray-500 text-xs font-black uppercase flex items-center justify-center gap-2">
<UserPlus size={14} /> Não encontrou? Adicionar por e-mail
</button>
)}
</div>
</div>
</div>
)}
</>
);
};
+66
View File
@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { Search, X, Check, ChevronDown } from 'lucide-react';
export interface SelectOption { value: string; label: string; hint?: string }
// Dropdown genérico que abre um modal com busca — substitui <select> em listas longas.
export const SearchSelect: React.FC<{
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
title?: string;
className?: string;
disabled?: boolean;
}> = ({ value, onChange, options, placeholder = 'Selecione...', title, className, disabled }) => {
const [open, setOpen] = useState(false);
const [busca, setBusca] = useState('');
const sel = options.find(o => o.value === value);
const q = busca.trim().toLowerCase();
const filtrados = q ? options.filter(o => o.label.toLowerCase().includes(q) || (o.hint || '').toLowerCase().includes(q)) : options;
const pick = (v: string) => { onChange(v); setOpen(false); setBusca(''); };
return (
<>
<button type="button" disabled={disabled} onClick={() => setOpen(true)}
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase disabled:opacity-50'}>
<span className="flex items-center justify-between gap-2">
<span className={`truncate ${sel ? '' : 'text-gray-400'}`}>{sel ? sel.label : placeholder}</span>
<ChevronDown size={16} className="text-gray-400 shrink-0" />
</span>
</button>
{open && (
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div className="bg-white rounded-2xl w-full max-w-md max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="font-black text-gray-800 uppercase text-sm">{title || placeholder}</h3>
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-3 border-b border-gray-50">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar…"
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{filtrados.length === 0 ? (
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nada encontrado.</p>
) : filtrados.map(o => (
<button key={o.value} onClick={() => pick(o.value)}
className={`w-full text-left flex items-center gap-2 px-3 py-2.5 rounded-xl ${o.value === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-800 text-sm truncate uppercase">{o.label}</p>
{o.hint && <p className="text-[11px] text-gray-400 truncate">{o.hint}</p>}
</div>
{o.value === value && <Check size={16} className="text-teal-600 shrink-0" />}
</button>
))}
</div>
</div>
</div>
)}
</>
);
};
+25 -3
View File
@@ -1,12 +1,12 @@
import React from 'react';
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
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';
import { getActivePlugins } from '../views/plugins/pluginRegistry.ts';
import { getActivePlugins, isPluginActive } from '../views/plugins/pluginRegistry.ts';
interface SidebarProps {
activeTab: string;
@@ -25,6 +25,19 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}').is_tutor === true; } catch { return false; }
})();
// Acesso do usuário às áreas do WhatsApp (Inbox/Secretária). null = ainda carregando
// (fail-open: mostra até saber). Dono e autorizados veem; os demais não.
const [waAccess, setWaAccess] = useState<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean } | null>(null);
useEffect(() => {
let alive = true;
if (isPluginActive('newwhats')) {
HybridBackend.getWaAccess().then(a => { if (alive) setWaAccess(a); }).catch(() => {});
}
return () => { alive = false; };
}, [activeWorkspace?.id]);
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')) {
// Radiografias são ODONTOLÓGICAS: só papéis odonto (dentista + donos/funcionário).
@@ -37,6 +50,14 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
pluginMenuItems.push({ id: 'rx-config', label: 'DISPOSITIVOS RX', icon: Monitor, color: '#7c3aed' });
}
}
// NewWhats: Inbox + Secretária IA — para TODOS os usuários quando o plugin está
// 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' });
}
// 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
// a paciente. Funcionário, quando muito, opera a AGENDA da sala (workspace tipo='sala'), não
@@ -62,6 +83,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 },
+41
View File
@@ -1,5 +1,46 @@
@import "tailwindcss";
/* ── Tema do motor NewWhats (plugin newwhats) — tokens Tailwind v4 ──────────── */
/* Portados do tailwind.config.js do motor para as páginas inbox/sessions/ */
/* secretária renderizarem idênticas ao motor. */
@theme {
--color-brand-50: #eff6ff;
--color-brand-100: #dbeafe;
--color-brand-200: #bfdbfe;
--color-brand-300: #93c5fd;
--color-brand-400: #60a5fa;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
--color-brand-800: #1e40af;
--color-brand-900: #1e3a8a;
--color-brand-950: #172554;
--color-surface: #070b14;
--color-surface-raised: #0c1220;
--color-surface-overlay: #111827;
--radius-4xl: 2rem;
--shadow-glow-brand: 0 0 24px rgba(59, 130, 246, 0.15);
--shadow-glow-sm: 0 0 12px rgba(59, 130, 246, 0.1);
--shadow-premium: 0 8px 32px rgba(0, 0, 0, 0.4);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--animate-shimmer: shimmer 3s linear infinite;
--animate-pulse-slow: pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-float: float 6s ease-in-out infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
/* ScoreOdonto Base Styles */
body {
+239 -19
View File
@@ -15,6 +15,9 @@
"@hello-pangea/dnd": "^18.0.1",
"@tanstack/react-query": "^5.90.21",
"date-fns": "^4.1.0",
"framer-motion": "^12.42.2",
"immer": "^11.1.8",
"jspdf": "^2.5.2",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -734,15 +737,6 @@
"node": ">=18"
}
},
"node_modules/@fullcalendar/core": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
"peer": true,
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
@@ -1522,11 +1516,17 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"optional": true
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"dev": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1565,6 +1565,17 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/atob": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
"bin": {
"atob": "bin/atob.js"
},
"engines": {
"node": ">= 4.5.0"
}
},
"node_modules/autoprefixer": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
@@ -1601,6 +1612,15 @@
"postcss": "^8.1.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.29",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
@@ -1646,6 +1666,17 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/btoa": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
"bin": {
"btoa": "bin/btoa.js"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001792",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
@@ -1666,12 +1697,42 @@
}
]
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/css-box-model": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
@@ -1680,11 +1741,20 @@
"tiny-invariant": "^1.0.6"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true
"dev": true
},
"node_modules/date-fns": {
"version": "4.1.0",
@@ -1721,6 +1791,12 @@
"node": ">=8"
}
},
"node_modules/dompurify": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz",
"integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==",
"optional": true
},
"node_modules/electron-to-chromium": {
"version": "1.5.355",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.355.tgz",
@@ -1807,6 +1883,11 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -1820,6 +1901,32 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.42.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz",
"integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==",
"dependencies": {
"motion-dom": "^12.42.2",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1849,6 +1956,28 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/immer": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -1888,6 +2017,23 @@
"node": ">=6"
}
},
"node_modules/jspdf": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz",
"integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==",
"dependencies": {
"@babel/runtime": "^7.23.2",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.6",
"core-js": "^3.6.0",
"dompurify": "^2.5.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2163,6 +2309,19 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/motion-dom": {
"version": "12.42.2",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz",
"integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2193,6 +2352,12 @@
"integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
"dev": true
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2245,14 +2410,13 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true
},
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/raf-schd": {
@@ -2315,6 +2479,21 @@
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"optional": true
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
@@ -2382,6 +2561,24 @@
"node": ">=0.10.0"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -2401,6 +2598,15 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -2422,6 +2628,11 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@@ -2479,6 +2690,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
+2
View File
@@ -16,6 +16,8 @@
"@hello-pangea/dnd": "^18.0.1",
"@tanstack/react-query": "^5.90.21",
"date-fns": "^4.1.0",
"framer-motion": "^12.42.2",
"immer": "^11.1.8",
"jspdf": "^2.5.2",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
+67
View File
@@ -45,6 +45,15 @@ const apiFetch = async (url: string, options: RequestInit = {}) => {
return res;
};
// Permissões de WhatsApp por conta (sessão): reconectar, excluir, acesso Inbox/Secretária.
export interface WaPerms {
can_rescan: boolean;
can_delete: boolean;
can_inbox: boolean;
can_secretaria: boolean;
can_delete_msg: boolean; // apagar conversa/mensagem no inbox
}
export const HybridBackend = {
dbConfig: {
database: 'scoreodonto'
@@ -748,6 +757,18 @@ export const HybridBackend = {
const res = await apiFetch(`${API_URL}/protese/laboratorios?clinicaId=${encodeURIComponent(clinicaId)}`);
return await res.json();
},
// Favorito (★) / padrão do protético na clínica ativa.
setProteticoPref: async (proteticoId: string, body: { favorito?: boolean; padrao?: boolean }) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/laboratorios/${proteticoId}/pref?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return await res.json().catch(() => ({ error: 'falha' }));
},
// Vincular um protético existente (com conta) como interno, por e-mail.
vincularProteticoEmail: async (email: string) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/laboratorios/vincular?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) });
return await res.json().catch(() => ({ error: 'falha' }));
},
getProteseOS: async (opts: { status?: string; paciente_id?: string } = {}) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const qs = new URLSearchParams({ clinicaId });
@@ -1460,6 +1481,52 @@ export const HybridBackend = {
return { grupo: j.grupo || null, membros: j.membros || [] };
},
// ── 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<{
available: boolean; // false = endpoint indisponível (backend antigo/erro) → UI não deve bloquear
isOwner: boolean; ownerId: string | null; ownerNome: string | null;
me: WaPerms;
members: Array<{ usuarioId: string; nome: string; email: string; role: string } & WaPerms>;
}> => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const params = new URLSearchParams({ clinicaId, instanceId });
const openPerms: WaPerms = { can_rescan: true, can_delete: true, can_inbox: true, can_secretaria: true, can_delete_msg: true };
try {
const res = await apiFetch(`${API_URL}/nw/authz?${params}`, {});
// Endpoint ausente (backend sem a feature ainda) → indisponível, não bloqueia a UI.
// A autoridade real é o proxy do backend (403); aqui é só dica visual.
if (!res.ok) return { available: false, isOwner: false, ownerId: null, ownerNome: null, me: openPerms, members: [] };
const j = await res.json();
return { available: true, ...j };
} catch {
return { available: false, isOwner: false, ownerId: null, ownerNome: null, me: openPerms, members: [] };
}
},
// 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 }> => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const open = { isOwner: false, inbox: true, secretaria: true, delete_msg: true };
if (!clinicaId) return open;
try {
const res = await apiFetch(`${API_URL}/nw/access?clinicaId=${encodeURIComponent(clinicaId)}`, {});
if (!res.ok) return open;
return await res.json();
} catch { return open; }
},
setWaSessionAuthz: async (instanceId: string, usuarioId: string, perms: WaPerms): Promise<{ ok: boolean; error?: string }> => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/nw/authz`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clinicaId, instanceId, usuarioId, ...perms }),
});
const j = await res.json().catch(() => ({}));
return res.ok ? { ok: true } : { ok: false, error: j.error || 'Erro ao salvar autorização.' };
},
// Importa agendamentos do Google p/ dentro do sistema (admin/dono). Idempotente.
importarGoogleAgenda: async (clinicaId?: string, dias?: number): Promise<{ success: boolean; criados?: number; pulados?: number; total?: number; message?: string }> => {
const cid = clinicaId || HybridBackend.getActiveWorkspace()?.id || '';
+22 -3
View File
@@ -44,11 +44,30 @@ function connect() {
sock.onerror = () => { try { sock.close(); } catch { /* ignore */ } };
}
// Fecha um socket que NÓS abrimos. Se ainda está em CONNECTING, chamar close()
// agora dispara "WebSocket is closed before the connection is established"
// (comum no double-mount do React StrictMode em DEV) → adiamos o close para o
// onopen. Em todos os casos neutralizamos os handlers para o socket morto não
// disparar reconexão (a reconexão automática por queda inesperada continua no
// onclose original definido em connect()).
function closeSocket(sock: WebSocket | null) {
if (!sock) return;
sock.onmessage = null;
sock.onerror = null;
sock.onclose = null;
if (sock.readyState === WebSocket.CONNECTING) {
sock.onopen = () => { try { sock.close(); } catch { /* ignore */ } };
} else {
sock.onopen = null;
try { sock.close(); } catch { /* ignore */ }
}
}
function ensureConnected() {
const url = buildUrl();
if (!url) return;
// Clínica/token mudou → reconecta no canal certo.
if (ws && url !== currentUrl) { try { ws.close(); } catch { /* ignore */ } ws = null; }
if (ws && url !== currentUrl) { closeSocket(ws); ws = null; }
if (!ws || ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) connect();
}
@@ -61,13 +80,13 @@ export const Realtime = {
handlers.delete(handler);
if (handlers.size === 0) {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (ws) { try { ws.close(); } catch { /* ignore */ } ws = null; }
if (ws) { closeSocket(ws); ws = null; }
}
};
},
/** Força reconexão (ex.: ao trocar de workspace). */
reconnect() {
if (ws) { try { ws.close(); } catch { /* ignore */ } ws = null; }
if (ws) { closeSocket(ws); ws = null; }
backoff = 1000;
ensureConnected();
},
+51
View File
@@ -0,0 +1,51 @@
// Desambiguação de homônimos na bancada do protético.
// O protético identifica o trabalho pelo NOME do paciente (sem CPF — princípio de privacidade).
// Quando dois trabalhos têm o mesmo nome+sobrenome, diferenciamos sem expor dado sensível:
// 1) pela ORIGEM (dentista, senão clínica) — "João Silva · Dr. Carlos";
// 2) se ainda empatar (mesma origem), por nº sequencial estável — "João Silva · Dr. Carlos (2)".
// Etiqueta livre, nunca um cadastro/atendimento de paciente.
const DIACRITICOS = new RegExp('[' + String.fromCharCode(0x300) + '-' + String.fromCharCode(0x36f) + ']', 'g');
const norm = (s?: string) =>
(s || '').normalize('NFD').replace(DIACRITICOS, '').toLowerCase().replace(/\s+/g, ' ').trim();
const origemDe = (o: any) => (o?.dentista_nome || o?.clinica_nome || o?.cliente_nome || '').trim();
// Recebe a lista de OS e devolve um Map id → rótulo de exibição já desambiguado.
export function rotulosPaciente(lista: any[]): Map<string, string> {
const grupos = new Map<string, any[]>();
for (const o of lista || []) {
const k = norm(o?.paciente_nome);
if (!k) continue;
const g = grupos.get(k);
if (g) g.push(o); else grupos.set(k, [o]);
}
const out = new Map<string, string>();
for (const o of lista || []) {
const base = (o?.paciente_nome || '').trim() || 'Sem identificação';
const k = norm(o?.paciente_nome);
const grupo = k ? (grupos.get(k) || [o]) : [o];
if (grupo.length <= 1) { out.set(o.id, base); continue; }
const origem = origemDe(o);
const mesmaOrigem = grupo.filter(g => norm(origemDe(g)) === norm(origem));
if (origem && mesmaOrigem.length === 1) {
out.set(o.id, `${base} · ${origem}`);
} else {
// mesma origem (ou origem ausente) → sequencial estável por data de criação
const ord = [...mesmaOrigem].sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')));
const idx = ord.findIndex(g => g.id === o.id) + 1;
out.set(o.id, origem ? `${base} · ${origem} (${idx})` : `${base} (${idx})`);
}
}
return out;
}
// Há outro trabalho ATIVO com o mesmo nome+sobrenome? (usado no aviso da entrada do lab)
// Retorna a origem do homônimo encontrado, ou null.
export function homonimoAtivo(nomeCompleto: string, lista: any[]): string | null {
const k = norm(nomeCompleto);
if (!k) return null;
const hit = (lista || []).find(o =>
norm(o?.paciente_nome) === k && !['entregue', 'cancelado'].includes(o?.status));
return hit ? (origemDe(hit) || 'outro cliente') : null;
}
+19
View File
@@ -110,6 +110,7 @@ export const DentistasView: React.FC = () => {
const [isHoursModalOpen, setIsHoursModalOpen] = useState(false);
const [selectedDentist, setSelectedDentist] = useState<Dentista | null>(null);
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
const [setores, setSetores] = useState<{ id: string; nome: string }[]>([]);
const toast = useToast();
const confirm = useConfirm();
@@ -125,6 +126,13 @@ export const DentistasView: React.FC = () => {
useEffect(() => {
fetchGoogleStatus();
// Setores da clínica (para lotar o dentista num setor → isolamento da agenda).
(async () => {
try {
const r = await HybridBackend.getSetores(activeWorkspace?.id || '');
setSetores(r?.setores || []);
} catch { /* clínica sem setores → seletor não aparece */ }
})();
}, []);
useEffect(() => {
@@ -261,6 +269,17 @@ export const DentistasView: React.FC = () => {
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto">
<input id="dentista-nome" type="text" name="nome" placeholder="NOME COMPLETO" defaultValue={editingDentista?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
<input id="dentista-email" type="email" name="email" placeholder="EMAIL" defaultValue={editingDentista?.email} className="w-full border border-gray-300 rounded-lg p-2" required />
{/* Setor do dentista → isola a agenda dele para a equipe do setor.
Só aparece se a clínica usa setores. Vazio = geral (visível a todos). */}
{setores.length > 0 && (
<div>
<label className="text-xs font-bold text-gray-500 uppercase block mb-1.5">Setor (opcional)</label>
<select id="dentista-setor" name="setor_id" defaultValue={(editingDentista as any)?.setor_id || ''} className="w-full border border-gray-300 rounded-lg p-2">
<option value="">SEM SETOR (geral visível a todos)</option>
{setores.map(s => <option key={s.id} value={s.id}>{s.nome}</option>)}
</select>
</div>
)}
{/* Especialidades que o dentista atende (várias) */}
<div>
<label className="text-xs font-bold text-gray-500 uppercase block mb-1.5">Especialidades que atende *</label>
+160 -54
View File
@@ -3,9 +3,12 @@ import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download } from 'lucide-react';
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 { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx';
import { AgendaPresence } from '../components/AgendaPresence.tsx';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import { HybridBackend } from '../services/backend.ts';
@@ -14,7 +17,6 @@ import { Dentista, Agendamento, Paciente, DentistaHorario } from '../types.ts';
import { useGTOStore } from './LancarGTO.tsx';
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
// --- Reusable Debounce Hook ---
function useDebounce(value: string, delay: number) {
@@ -343,16 +345,37 @@ const AppointmentModal: React.FC<{
);
};
export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({ onNavigate }) => {
export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebarCollapsed?: boolean; onToggleSidebar?: () => void; controlsOpen?: boolean; onCloseControls?: () => void }> = ({ onNavigate, sidebarCollapsed, onToggleSidebar, controlsOpen = false, onCloseControls }) => {
const { data: agendamentos, refresh } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
const { data: dentists, refresh: refreshDentists } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend<any[]>(HybridBackend.getGoogleEvents);
const [events, setEvents] = useState<any[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [showMeusHorarios, setShowMeusHorarios] = useState(false);
const [showPedidos, setShowPedidos] = useState(false);
const [pedidosCount, setPedidosCount] = useState(0);
// Pedidos da zona cinza aguardando a secretária humana — badge + poll a cada 30 min.
useEffect(() => {
const cid = (HybridBackend.getActiveWorkspace() as any)?.id;
const role = HybridBackend.getCurrentRole();
if (!cid || !['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(role)) return;
let alive = true;
const poll = async () => {
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}` } : {} });
if (r.ok && alive) { const j = await r.json(); setPedidosCount(j.total || (j.pedidos || []).length || 0); }
} catch { /* silencioso */ }
};
poll();
const t = setInterval(poll, 30 * 60 * 1000); // 30 min
return () => { alive = false; clearInterval(t); };
}, []);
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 [interesses, setInteresses] = useState<any[]>([]);
const [showInteresses, setShowInteresses] = useState(false);
const [pendencias, setPendencias] = useState<any[]>([]);
@@ -457,14 +480,15 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
const inativo = st === 'cancelado' || st === 'remarcar';
// Prioridade: status (falta/cancelado) > cor própria do agendamento (ex.: importada do Google) > cor do dentista.
const cor = faltou ? '#ef4444' : inativo ? '#cbd5e1' : ((ag as any).cor || (dentist ? dentist.corAgenda : '#94a3b8'));
const nomePac = (ag as any).pacienteNome || (ag as any).pacientenome || '';
return {
id: ag.id,
title: `${ag.pacienteNome} - ${ag.procedimento}`,
title: `${nomePac} - ${ag.procedimento}`,
start: ag.start,
end: ag.end,
backgroundColor: cor,
borderColor: cor,
extendedProps: { ...ag, isGoogle: false, dentistaNome: dentist?.nome }
extendedProps: { ...ag, pacienteNome: nomePac, isGoogle: false, dentistaNome: dentist?.nome }
};
});
@@ -597,13 +621,107 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
});
return (
<div className="space-y-6">
<PageHeader title="AGENDA CLÍNICA">
<div className="flex gap-2 sm:gap-3 items-center flex-wrap justify-end">
<div className="flex flex-col lg:flex-row lg:gap-4 h-full">
{/* Backdrop da gaveta de controles (mobile) — fecha ao tocar fora. Como a sidebar. */}
{controlsOpen && (
<div className="fixed inset-0 bg-black/50 z-20 lg:hidden backdrop-blur-sm transition-opacity" onClick={onCloseControls} />
)}
{/* Coluna de controles desktop: lateral esquerda fixa; mobile: gaveta deslizante
(oculta por padrão p/ dar o máximo de espaço ao calendário; abre pelo do header). */}
<aside className={`
fixed inset-y-0 right-0 z-30 w-72 max-w-[85%] bg-white shadow-2xl overflow-y-auto p-4 transform transition-transform duration-300
${controlsOpen ? 'translate-x-0' : 'translate-x-full'}
lg:static lg:inset-auto lg:z-auto lg:w-56 lg:max-w-none lg:translate-x-0 lg:transform-none lg:transition-none lg:shadow-none lg:bg-transparent lg:p-0 lg:overflow-y-auto lg:pr-3 lg:border-r lg:border-gray-100
shrink-0 lg:mb-0`}>
{/* Fechar a gaveta (só mobile) */}
<button onClick={onCloseControls} title="Fechar"
className="lg:hidden absolute top-3 right-3 p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors z-10">
<X size={20} />
</button>
{/* Título + recolher menu (desktop, ao lado). No mobile o AGENDAR e as
ações ficam empilhados abaixo (dentro da gaveta). */}
<div className="flex items-center gap-2 mb-3 pr-8 lg:pr-0">
{onToggleSidebar && (
<button onClick={onToggleSidebar} title={sidebarCollapsed ? 'Mostrar menu lateral' : 'Recolher menu lateral'}
className="hidden lg:flex items-center justify-center w-8 h-8 shrink-0 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors">
{sidebarCollapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
</button>
)}
<h2 className="text-xl lg:text-base font-bold text-gray-900 uppercase flex-1 min-w-0 leading-tight break-words">AGENDA CLÍNICA</h2>
</div>
<div className="flex gap-2 sm:gap-3 items-center flex-wrap lg:flex-col lg:items-stretch">
{/* AGENDAR (full-width no topo da coluna/gaveta — mobile e desktop) */}
{(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); onCloseControls?.(); }} title="Agendar"
className="flex w-full bg-teal-600 text-white px-4 py-2.5 rounded-lg text-sm font-bold uppercase items-center justify-center gap-2 hover:bg-teal-700 shadow-sm transition-colors">
<Plus size={18} /> AGENDAR
</button>
)}
{/* Ações — mobile: empilhadas, ícone + nome, largura cheia; desktop: fileira de ícones */}
<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 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="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 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="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 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="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 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="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 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="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 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="text-sm font-bold">Interesses na agenda</span>
{interessesAguardando > 0 && (
<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 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="text-sm font-bold">Pacientes a reagendar</span>
{pendencias.length > 0 && (
<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>
)}
</div>
<div className="w-full"><AgendaPresence /></div>
{/* Legenda de dentistas (desktop, no rodapé da coluna) */}
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="dentists-legend" direction="horizontal">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="hidden lg:flex gap-2 items-center">
<div {...provided.droppableProps} ref={provided.innerRef} className="hidden lg:flex lg:flex-wrap gap-2 items-center lg:w-full lg:mt-1">
{filteredDentists?.map((d, index) => (
<Draggable key={d.id} draggableId={d.id} index={index} isDragDisabled={currentRole === 'dentista'}>
{(provided, snapshot) => (
@@ -625,50 +743,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
)}
</Droppable>
</DragDropContext>
<AgendaPresence />
{(currentRole !== 'paciente') && (
<button onClick={() => { setShowAtividade(true); loadAtividade(); }} title="Atividade da equipe hoje"
className="p-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<History size={20} />
</button>
)}
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
<button onClick={handleImportarGoogle} disabled={importando} title="Importar agendamentos do Google para o sistema"
className="p-2.5 bg-white 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">
{importando ? <Loader2 size={20} className="animate-spin" /> : <Download size={20} />}
</button>
)}
{(currentRole === 'admin' || currentRole === 'donoclinica') && (
<button onClick={() => setIsSettingsOpen(true)} className="p-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<GearIcon size={20} />
</button>
)}
{souDentista && (
<button onClick={() => { setShowInteresses(true); loadInteresses(); }} title="Pedidos de interesse na sua agenda"
className="relative p-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<Bell size={20} />
{interessesAguardando > 0 && (
<span className="absolute -top-1 -right-1 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(); }} title="Pacientes a reagendar"
className="relative p-2.5 bg-white text-gray-400 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
<CalendarClock size={20} />
{pendencias.length > 0 && (
<span className="absolute -top-1 -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>
)}
</button>
)}
{(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} title="Novo agendamento" className="bg-teal-600 text-white px-3 sm:px-4 py-2 rounded-lg text-sm font-bold uppercase flex items-center gap-2 hover:bg-teal-700 shadow-sm transition-colors">
<Plus size={18} /> <span className="hidden sm:inline">NOVO AGENDAMENTO</span>
</button>
)}
</div>
</PageHeader>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-2 sm:p-4 relative h-[calc(100dvh-220px)] sm:h-[750px] min-h-[420px]">
</aside>
<div className="flex-1 min-w-0 bg-white rounded-xl shadow-sm border border-gray-200 p-2 sm:p-4 relative min-h-[420px] lg:min-h-0">
<FullCalendar
key={isMobile ? 'mobile' : 'desktop'}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
@@ -681,6 +758,8 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
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}
@@ -720,10 +799,23 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
onChanged={() => { refresh(); refreshGoogle(); loadPendencias(); }}
onEditar={() => { setShowDetails(false); setIsModalOpen(true); }}
onAtender={handleAtender}
podeAtender={!!onNavigate && currentRole !== 'paciente'}
onVerWhats={(info) => setWhatsTarget(info)}
// "Atender" (abre Lançar GTO) é ação CLÍNICA — atende quem trata: dentista,
// biomédico, dono da clínica/consultório, dentista locatário, admin. A recepção
// (funcionario) NÃO atende; ela só remarca/desmarca/agenda.
podeAtender={!!onNavigate && currentRole !== 'paciente' && currentRole !== 'funcionario'}
dentistaRestrito={souDentista}
/>
{/* Drawer de WhatsApp do paciente — clone focado da área de mensagens */}
<WhatsChatDrawer
isOpen={!!whatsTarget}
onClose={() => setWhatsTarget(null)}
pacienteId={whatsTarget?.pacienteId}
pacienteNome={whatsTarget?.pacienteNome}
onNavigate={onNavigate}
/>
{/* 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)}>
@@ -797,6 +889,12 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: #f1f1f1; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
/* FullCalendar — toolbar/controles compactos (todas as telas) */
.fc .fc-toolbar.fc-header-toolbar { margin-bottom: .6rem; }
.fc .fc-toolbar-title { font-size: 1.05rem; font-weight: 700; }
.fc .fc-button { padding: .3rem .65rem; font-size: .8rem; box-shadow: none !important; }
.fc .fc-button-group { gap: 0; }
.fc .fc-col-header-cell-cushion { font-size: .72rem; padding: 4px 2px; }
/* FullCalendar — responsividade mobile */
@media (max-width: 639px) {
.fc .fc-toolbar.fc-header-toolbar { margin-bottom: .5rem; flex-wrap: wrap; gap: .4rem; }
@@ -806,8 +904,13 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
.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}
@@ -819,6 +922,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
initialPatient={reagendarPatient}
/>
<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}
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 && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowInteresses(false)}>
+26 -9
View File
@@ -1,8 +1,9 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox, Plus, ArrowDownToLine, X, Stethoscope, Building2, Briefcase } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { PageHeader } from '../components/PageHeader.tsx';
import { useToast } from '../contexts/ToastContext.tsx';
import { rotulosPaciente, homonimoAtivo } from '../utils/pacienteLabel.ts';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const CLI_TIPO = [
@@ -40,6 +41,7 @@ export const LabHomeView: React.FC = () => {
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setImportando(null); }
};
const rotuloPac = useMemo(() => rotulosPaciente(os), [os]);
const ativas = os.filter(o => o.status !== 'entregue' && o.status !== 'cancelado');
const atrasadas = os.filter(o => o.atrasada).length;
const ymMes = new Date().toISOString().slice(0, 7);
@@ -148,7 +150,7 @@ export const LabHomeView: React.FC = () => {
<tbody className="divide-y divide-gray-100">
{ativas.filter(o => o.prazo_entrega).sort((a, b) => (a.prazo_entrega || '').localeCompare(b.prazo_entrega || '')).slice(0, 12).map(o => (
<tr key={o.id} className={`hover:bg-gray-50/50 ${o.atrasada ? 'bg-red-50/40' : ''}`}>
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{o.paciente_nome || '—'}</td>
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{rotuloPac.get(o.id) || o.paciente_nome || '—'}</td>
<td className="px-4 py-3 text-gray-500 uppercase">{o.tipo || '—'}</td>
<td className={`px-4 py-3 whitespace-nowrap font-bold ${o.atrasada ? 'text-red-600' : 'text-gray-500'}`}>{(o.prazo_entrega || '').slice(0, 10).split('-').reverse().join('/')}</td>
<td className="px-4 py-3"><span className="px-2 py-0.5 rounded-full text-[9px] font-black uppercase bg-teal-100 text-teal-700">{STATUS_LABEL[o.status] || o.status}</span></td>
@@ -163,13 +165,13 @@ export const LabHomeView: React.FC = () => {
</>
)}
{showEntrada && <LabEntradaModal onClose={() => setShowEntrada(false)} onSaved={() => { setShowEntrada(false); carregar(); }} />}
{showEntrada && <LabEntradaModal osAtivas={os} onClose={() => setShowEntrada(false)} onSaved={() => { setShowEntrada(false); carregar(); }} />}
</div>
);
};
// ── Modal: dar entrada em OS pelo lado do laboratório ────────────────────────
const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
const LabEntradaModal: React.FC<{ osAtivas: any[]; onClose: () => void; onSaved: () => void }> = ({ osAtivas, onClose, onSaved }) => {
const toast = useToast();
const [clientes, setClientes] = useState<any[]>([]);
const [produtos, setProdutos] = useState<any[]>([]);
@@ -182,9 +184,14 @@ const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> =
const [tipo, setTipo] = useState('');
const [custo, setCusto] = useState('');
const [prazo, setPrazo] = useState('');
const [paciente, setPaciente] = useState('');
const [nome, setNome] = useState('');
const [sobrenome, setSobrenome] = useState('');
const [obs, setObs] = useState('');
// Identificação do trabalho = Nome + Sobrenome (etiqueta, sem CPF). Avisa sobre homônimos ativos.
const pacienteNome = `${nome} ${sobrenome}`.replace(/\s+/g, ' ').trim();
const homonimo = useMemo(() => pacienteNome ? homonimoAtivo(pacienteNome, osAtivas) : null, [pacienteNome, osAtivas]);
useEffect(() => {
HybridBackend.getLabClientes().then(r => setClientes(Array.isArray(r) ? r : [])).catch(() => {});
HybridBackend.getProteseProdutos().then(r => setProdutos(Array.isArray(r) ? r : [])).catch(() => {});
@@ -195,7 +202,7 @@ const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> =
if (!produtoId && !tipo.trim()) { toast.error('INFORME O PRODUTO OU TIPO.'); return; }
setBusy(true);
try {
const body: any = { paciente_nome: paciente.trim() || undefined, observacoes: obs.trim() || undefined, prazo_entrega: prazo || undefined };
const body: any = { paciente_nome: pacienteNome || undefined, observacoes: obs.trim() || undefined, prazo_entrega: prazo || undefined };
if (clienteId) body.cliente_id = clienteId; else { body.cliente_nome = cliNome.trim(); body.cliente_tipo = cliTipo; }
if (produtoId) body.produto_id = produtoId; else { body.tipo = tipo.trim(); body.custo = Number(custo) || 0; }
const r = await HybridBackend.criarLabOs(body);
@@ -248,10 +255,20 @@ const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> =
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div><label className={lbl}>Prazo</label><input value={prazo} onChange={e => setPrazo(e.target.value)} type="date" className={inp} /></div>
<div><label className={lbl}>Paciente (opcional)</label><input value={paciente} onChange={e => setPaciente(e.target.value)} placeholder="Nome" className={inp} /></div>
{/* Identificação do trabalho: Nome + Sobrenome (etiqueta, sem CPF — o protético não atende paciente). */}
<div>
<label className={lbl}>Identificação do trabalho (paciente)</label>
<div className="grid grid-cols-2 gap-3 mt-1">
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Nome" className={inp.replace('mt-1', '')} />
<input value={sobrenome} onChange={e => setSobrenome(e.target.value)} placeholder="Sobrenome(s)" className={inp.replace('mt-1', '')} />
</div>
{homonimo && (
<p className="mt-1.5 text-[10px] font-bold text-amber-600 uppercase flex items-start gap-1">
<AlertTriangle size={12} className="shrink-0 mt-px" /> existe {pacienteNome} ativo ({homonimo}). Acrescente outro sobrenome para diferenciar.
</p>
)}
</div>
<div><label className={lbl}>Prazo</label><input value={prazo} onChange={e => setPrazo(e.target.value)} type="date" className={inp} /></div>
<div><label className={lbl}>Observações</label><textarea value={obs} onChange={e => setObs(e.target.value)} rows={2} className={inp} /></div>
<button disabled={busy} onClick={salvar} className="w-full py-3 rounded-xl text-white font-black uppercase text-sm disabled:opacity-50" style={{ backgroundColor: '#2d6a4f' }}>{busy ? 'Salvando…' : 'Registrar entrada'}</button>
<p className="text-[10px] text-gray-300 font-bold uppercase text-center">O trabalho entra com o laboratório (custódia) e no seu financeiro.</p>
+32 -53
View File
@@ -13,6 +13,8 @@ import {
X
} from 'lucide-react';
import { PageHeader } from '../components/PageHeader.tsx';
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
import { SearchSelect } from '../components/SearchSelect.tsx';
import { HybridBackend } from '../services/backend.ts';
import { Paciente, Dentista, Procedimento, Especialidade } from '../types.ts';
import { useToast } from '../contexts/ToastContext.tsx';
@@ -123,7 +125,6 @@ export const LancarGTO: React.FC = () => {
const [patientSearchTerm, setPatientSearchTerm] = useState('');
// --- PRÓTESE (Fase 1): catálogo do protético entra como "procedimentos" do GTO ---
const [laboratorios, setLaboratorios] = useState<any[]>([]);
const [selectedLab, setSelectedLab] = useState<string>('');
const [labProdutos, setLabProdutos] = useState<any[]>([]);
const [selectedProdutoId, setSelectedProdutoId] = useState<string>('');
@@ -162,12 +163,6 @@ export const LancarGTO: React.FC = () => {
[labProdutos, selectedProdutoId]
);
// Carrega os laboratórios acessíveis assim que a especialidade de prótese é escolhida.
useEffect(() => {
if (!isProtese || laboratorios.length) return;
HybridBackend.getProteseLaboratorios().then(l => setLaboratorios(Array.isArray(l) ? l : [])).catch(() => {});
}, [isProtese, laboratorios.length]);
// Catálogo do laboratório selecionado.
useEffect(() => {
if (!selectedLab) { setLabProdutos([]); return; }
@@ -463,10 +458,10 @@ export const LancarGTO: React.FC = () => {
<Stethoscope size={20} strokeWidth={3} /> 02. Especialidade
</div>
<div className="space-y-3">
<select
<SearchSelect
value={selectedEsp}
onChange={(e) => {
setSelectedEsp(e.target.value);
onChange={(v) => {
setSelectedEsp(v);
setSelectedProc(null);
setSelectedDentes([]);
setSelectedArco(null);
@@ -474,22 +469,13 @@ export const LancarGTO: React.FC = () => {
setProtValor('');
setProtObs('');
}}
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase"
>
<option value="">ESCOLHA A ÁREA...</option>
{especialidades.map(es => <option key={es.id} value={es.id}>{es.nome}</option>)}
</select>
options={especialidades.map(es => ({ value: es.id, label: es.nome }))}
placeholder="ESCOLHA A ÁREA..." title="Especialidade"
/>
{selectedEsp && isProtese && (
<div className="space-y-3">
<select
value={selectedLab}
onChange={(e) => { setSelectedLab(e.target.value); setSelectedProdutoId(''); setProtValor(''); }}
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase"
>
<option value="">ESCOLHA O LABORATÓRIO / PROTÉTICO...</option>
{laboratorios.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (INTERNO)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
</select>
<ProteticoPicker value={selectedLab} onChange={(id) => { setSelectedLab(id); setSelectedProdutoId(''); setProtValor(''); }} />
{selectedLab && (labProdutos.length === 0 ? (
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
@@ -497,18 +483,17 @@ export const LancarGTO: React.FC = () => {
</div>
) : (
<>
<select
<SearchSelect
value={selectedProdutoId}
onChange={(e) => {
setSelectedProdutoId(e.target.value);
const pr = labProdutos.find(p => p.id === e.target.value);
onChange={(v) => {
setSelectedProdutoId(v);
const pr = labProdutos.find(p => p.id === v);
setProtValor(pr ? String(pr.preco ?? '') : '');
}}
options={labProdutos.map(p => ({ value: p.id, label: p.nome, hint: `R$ ${Number(p.preco || 0).toFixed(2)}` }))}
placeholder="ESCOLHA A PRÓTESE..." title="Produto do laboratório"
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
>
<option value="">ESCOLHA A PRÓTESE...</option>
{labProdutos.map(p => <option key={p.id} value={p.id}>{p.nome} R$ {Number(p.preco || 0).toFixed(2)}</option>)}
</select>
/>
{selectedProduto && (
<>
@@ -556,21 +541,20 @@ export const LancarGTO: React.FC = () => {
<p className="text-[10px] font-bold text-amber-500 uppercase mt-1">Cadastre em Procedimentos para usá-lo aqui</p>
</div>
) : (
<select
<SearchSelect
value={selectedProc?.id || ''}
onChange={(e) => {
const pr = procedimentos.find(p => p.id === e.target.value);
onChange={(v) => {
const pr = procedimentos.find(p => p.id === v);
setSelectedProc(pr || null);
setSelectedDentes([]);
setSelectedArco(null);
setArcoFace('');
setArcoObs('');
}}
options={filteredProcedimentos.map(p => ({ value: p.id, label: p.nome }))}
placeholder="BUSCAR PROCEDIMENTO" title="Procedimento"
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
>
<option value="">BUSCAR PROCEDIMENTO</option>
{filteredProcedimentos.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
</select>
/>
)}
{selectedProc && (
@@ -601,28 +585,23 @@ export const LancarGTO: React.FC = () => {
<div className="flex items-center gap-3 text-[#2d6a4f] font-black text-base uppercase tracking-wider">
<Check size={20} strokeWidth={3} className="bg-green-100 p-1 rounded-full text-green-700" /> 03. Dentista Executor
</div>
<select
<SearchSelect
value={store.dentista?.id || ''}
onChange={(e) => {
const d = dentistas.find(d => d.id === e.target.value);
store.setDentista(d || null);
}}
onChange={(v) => { const d = dentistas.find(d => d.id === v); store.setDentista(d || null); }}
options={dentistas.map(d => ({ value: d.id, label: d.nome }))}
placeholder="SELECIONE O PROFISSIONAL" title="Dentista executor"
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
>
<option value="">SELECIONE O PROFISSIONAL</option>
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
</select>
/>
<div className="pt-1">
<label className="text-[11px] font-black text-gray-400 uppercase tracking-widest mb-1.5 block">Convênio / Credenciada</label>
<select
<SearchSelect
value={store.credenciada || ''}
onChange={(e) => store.setCredenciada(e.target.value)}
onChange={(v) => store.setCredenciada(v)}
options={convenios.map(c => ({ value: c.id, label: c.nome }))}
placeholder="SELECIONE O CONVÊNIO" title="Convênio / Credenciada"
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
>
<option value="">SELECIONE O CONVÊNIO</option>
{convenios.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
</select>
/>
</div>
</div>
);
+184 -1
View File
@@ -29,6 +29,31 @@ interface PluginDef {
}
const PLUGINS: PluginDef[] = [
{
id: 'newwhats',
name: 'NewWhats — WhatsApp & Secretária IA',
tagline: 'Inbox WhatsApp + Atendente virtual',
description: 'Integração com o motor NewWhats: Inbox de WhatsApp em tempo real e Secretária IA. Inbox e Secretária ficam disponíveis para todos os usuários; esta configuração é exclusiva do super-admin.',
version: '1.0.0',
author: 'ScoreOdonto',
category: 'COMUNICAÇÃO',
color: '#16a34a',
sidebarItems: [
{ id: 'wa-inbox', label: 'WHATSAPP' },
{ id: 'wa-secretaria', label: 'SECRETÁRIA IA' },
],
features: [
'Inbox WhatsApp (conversas, mensagens, envio)',
'Secretária IA (atendente virtual do motor)',
'Base de conhecimento da clínica sincronizada com o motor',
],
configFields: [
{ key: 'NEWWHATS_URL', label: 'URL DO MOTOR', type: 'text', placeholder: 'https://newwhats.clube67.com', defaultValue: '' },
{ key: 'NEWWHATS_INTEGRATION_KEY', label: 'CHAVE DE INTEGRAÇÃO', type: 'password', placeholder: 'nw_...', defaultValue: '' },
{ key: 'NEWWHATS_WEBHOOK_SECRET', label: 'SEGREDO DO WEBHOOK', type: 'password', placeholder: '(HMAC)', defaultValue: '' },
{ key: 'NEWWHATS_CLINICA_ID', label: 'CLÍNICA (ID)', type: 'text', placeholder: 'c_...', defaultValue: '' },
],
},
{
id: 'rx-scoreodonto',
name: 'RX ScoreOdonto',
@@ -171,6 +196,161 @@ const ConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }> = ({ plu
};
// ─── Plugin Card ──────────────────────────────────────────────────────────────
// ─── Config dedicada do NewWhats (dropdown de clínicas + backend + testar) ──────
const NW_API = (import.meta as any).env?.VITE_API_URL || '/api';
const nwAuthHeaders = () => {
const t = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
};
const NewwhatsConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }> = ({ plugin, onClose }) => {
const toast = useToast();
const [motorUrl, setMotorUrl] = useState('');
const [clinicaId, setClinicaId] = useState('');
const [integrationKey, setIntegrationKey] = useState('');
const [webhookSecret, setWebhookSecret] = useState('');
const [hasKey, setHasKey] = useState(false);
const [hasSecret, setHasSecret] = useState(false);
const [clinics, setClinics] = useState<Array<{ id: string; nome_fantasia: string }>>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [test, setTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [token, setToken] = useState('');
const [applying, setApplying] = useState(false);
const [advanced, setAdvanced] = useState(false);
const loadCfg = async () => {
try {
const cfgR = await fetch(`${NW_API}/nw/config`, { headers: nwAuthHeaders() });
if (cfgR.ok) {
const c = await cfgR.json();
setMotorUrl(c.motorUrl || ''); setClinicaId(c.clinicaId || '');
setHasKey(!!c.hasIntegrationKey); setHasSecret(!!c.hasWebhookSecret);
}
} catch { /* noop */ }
};
useEffect(() => {
(async () => {
try {
const clR = await fetch(`${NW_API}/nw/clinicas`, { headers: nwAuthHeaders() });
if (clR.ok) setClinics(await clR.json());
await loadCfg();
} catch { /* noop */ }
finally { setLoading(false); }
})();
}, []);
const applyToken = async () => {
if (!token.trim()) return;
setApplying(true); setTest(null);
try {
const r = await fetch(`${NW_API}/nw/config/token`, { method: 'POST', headers: nwAuthHeaders(), body: JSON.stringify({ token: token.trim() }) });
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'Falha ao aplicar token');
await loadCfg();
setToken('');
setTest({ ok: true, msg: 'Token aplicado — ' + (j.detail || 'configurado') });
toast.success('TOKEN APLICADO! Conexão configurada.');
} catch (e: any) { setTest({ ok: false, msg: String(e.message).slice(0, 120) }); }
finally { setApplying(false); }
};
const handleSave = async () => {
setSaving(true);
try {
const body: any = { motorUrl, clinicaId };
if (integrationKey) body.integrationKey = integrationKey;
if (webhookSecret) body.webhookSecret = webhookSecret;
const r = await fetch(`${NW_API}/nw/config`, { method: 'PUT', headers: nwAuthHeaders(), body: JSON.stringify(body) });
if (!r.ok) throw new Error(await r.text());
toast.success('CONFIGURAÇÕES SALVAS!');
onClose();
} catch (e: any) { toast.error('FALHA AO SALVAR: ' + String(e.message).slice(0, 80)); }
finally { setSaving(false); }
};
const handleTest = async () => {
setTest(null);
try {
const r = await fetch(`${NW_API}/nw/status`, { headers: nwAuthHeaders() });
const s = await r.json();
setTest({ ok: !!s.motorOk, msg: s.detail || (s.motorOk ? 'OK' : 'Falha') });
} catch (e: any) { setTest({ ok: false, msg: String(e.message).slice(0, 80) }); }
};
const inputCls = "w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-green-400 focus:ring-2 focus:ring-green-100 bg-white";
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">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between" style={{ background: `linear-gradient(to right, ${plugin.color}10, transparent)` }}>
<div>
<h3 className="text-sm font-black text-gray-900 uppercase">CONFIGURAR {plugin.name}</h3>
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: plugin.color }}>CONEXÃO COM O MOTOR</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">
{loading ? <div className="text-sm text-gray-500">Carregando</div> : <>
{/* Caminho principal: colar o token de pareamento → auto-configura */}
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">TOKEN DE PAREAMENTO</label>
<textarea className={inputCls + ' font-mono text-xs h-20 resize-none'} value={token} onChange={e => setToken(e.target.value)} placeholder="Cole aqui o token do motor (nwpair_…). URL, chave e webhook são configurados automaticamente." />
<button onClick={applyToken} disabled={applying || !token.trim()} className="mt-2 w-full py-2.5 text-white rounded-xl text-xs font-black uppercase flex items-center justify-center gap-2 disabled:opacity-50" style={{ backgroundColor: plugin.color }}>
{applying ? 'APLICANDO…' : 'APLICAR TOKEN'}
</button>
</div>
{/* Estado atual da conexão */}
<div className="text-[11px] text-gray-500 bg-gray-50 rounded-xl px-3 py-2">
Motor: <b>{motorUrl || '—'}</b> · Chave: <b className={hasKey ? 'text-green-600' : ''}>{hasKey ? 'definida' : '—'}</b> · Webhook: <b className={hasSecret ? 'text-green-600' : ''}>{hasSecret ? 'definido' : '—'}</b>
</div>
{test && <div className={`text-xs font-bold ${test.ok ? 'text-green-600' : 'text-red-600'}`}>{test.ok ? '✓ ' : '✗ '}{test.msg}</div>}
{/* Clínica — local (o motor não conhece os IDs daqui) */}
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">CLÍNICA</label>
<select className={inputCls} value={clinicaId} onChange={e => setClinicaId(e.target.value)}>
<option value=""> selecione a clínica </option>
{clinics.map(c => <option key={c.id} value={c.id}>{c.nome_fantasia || c.id}</option>)}
</select>
</div>
{/* Configuração manual (avançado) */}
<button onClick={() => setAdvanced(a => !a)} className="text-[10px] font-black uppercase tracking-widest text-gray-400 hover:text-gray-600">
{advanced ? '▾ ocultar configuração manual' : '▸ configuração manual'}
</button>
{advanced && <div className="space-y-4 border-t border-gray-100 pt-4">
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">URL DO MOTOR</label>
<input className={inputCls} value={motorUrl} onChange={e => setMotorUrl(e.target.value)} placeholder="https://newwhats.clube67.com" />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">CHAVE DE INTEGRAÇÃO {hasKey && <span className="text-green-600">(definida)</span>}</label>
<input className={inputCls} type="password" value={integrationKey} onChange={e => setIntegrationKey(e.target.value)} placeholder={hasKey ? '•••• (deixe em branco p/ manter)' : 'nw_...'} />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">SEGREDO DO WEBHOOK {hasSecret && <span className="text-green-600">(definido)</span>}</label>
<input className={inputCls} type="password" value={webhookSecret} onChange={e => setWebhookSecret(e.target.value)} placeholder={hasSecret ? '•••• (deixe em branco p/ manter)' : '(HMAC)'} />
</div>
<button onClick={handleTest} className="text-xs font-black uppercase text-gray-600 border border-gray-200 rounded-xl px-4 py-2 hover:bg-gray-50">Testar conexão</button>
</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={handleSave} 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: plugin.color }}>
<Save size={14} /> {saving ? 'SALVANDO…' : 'SALVAR'}
</button>
</div>
</div>
</div>
);
};
const PluginCard: React.FC<{ plugin: PluginDef; isActive: boolean; alwaysOn?: boolean; price?: number; onToggle: () => void; onConfigure: () => void }> = ({ plugin, isActive, alwaysOn, price, onToggle, onConfigure }) => {
const [expanded, setExpanded] = useState(false);
@@ -366,7 +546,10 @@ export const PluginsView: React.FC = () => {
</div>
{/* Config modal */}
{configuringPlugin && (
{configuringPlugin && configuringPlugin.id === 'newwhats' && (
<NewwhatsConfigModal plugin={configuringPlugin} onClose={() => setConfiguringPlugin(null)} />
)}
{configuringPlugin && configuringPlugin.id !== 'newwhats' && (
<ConfigModal plugin={configuringPlugin} onClose={() => setConfiguringPlugin(null)} />
)}
</div>
+24 -18
View File
@@ -1,9 +1,12 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History, Printer, MapPin, Building2, Share2, FileText, Star } 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 { rotulosPaciente } from '../utils/pacienteLabel.ts';
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
import { SearchSelect } from '../components/SearchSelect.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('/');
@@ -62,7 +65,7 @@ const ProteticoInternoInline: React.FC<{ onCriado: (id: string) => void }> = ({
// ── Modal: Nova OS (lado clínica) ───────────────────────────────────────────
const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
const toast = useToast();
const [labs, setLabs] = useState<any[]>([]);
const [pickerKey, setPickerKey] = useState(0);
const [dentistas, setDentistas] = useState<any[]>([]);
const [busca, setBusca] = useState('');
const [pacientes, setPacientes] = useState<any[]>([]);
@@ -73,7 +76,6 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
const ws = HybridBackend.getActiveWorkspace();
useEffect(() => {
HybridBackend.getProteseLaboratorios().then(r => setLabs(Array.isArray(r) ? r : [])).catch(() => setLabs([]));
HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([]));
}, [ws?.id]);
@@ -151,20 +153,17 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
{/* Laboratório */}
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Laboratório / Protético *</label>
<select value={form.protetico_id} onChange={e => set('protetico_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
<option value="">Selecione...</option>
{labs.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (interno)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
</select>
<ProteticoInternoInline onCriado={(id) => { HybridBackend.getProteseLaboratorios().then(r => { setLabs(Array.isArray(r) ? r : []); set('protetico_id', id); }); }} />
<div className="mt-1"><ProteticoPicker key={pickerKey} value={form.protetico_id} onChange={(id) => set('protetico_id', id)} /></div>
<ProteticoInternoInline onCriado={(id) => { set('protetico_id', id); setPickerKey(k => k + 1); }} />
</div>
{/* Produto (catálogo do laboratório — preço fixo) */}
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Produto / Trabalho *</label>
{form.protetico_id && produtos.length > 0 ? (
<select value={form.produto_id} onChange={e => set('produto_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
<option value="">Selecione...</option>
{produtos.map(p => <option key={p.id} value={p.id}>{p.nome} {fmtBRL(p.preco)}</option>)}
</select>
<div className="mt-1"><SearchSelect value={form.produto_id} onChange={(v) => set('produto_id', v)}
options={produtos.map(p => ({ value: p.id, label: p.nome, hint: fmtBRL(p.preco) }))}
placeholder="Selecione..." title="Produto do laboratório"
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
) : (
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder={form.protetico_id ? 'Sem catálogo — descreva o trabalho' : 'Selecione o laboratório primeiro'} disabled={!form.protetico_id}
className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm disabled:bg-gray-50" />
@@ -176,10 +175,10 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
{/* Dentista */}
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Dentista</label>
<select value={form.dentista_id} onChange={e => set('dentista_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
<option value=""></option>
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
</select>
<div className="mt-1"><SearchSelect value={form.dentista_id} onChange={(v) => set('dentista_id', v)}
options={dentistas.map(d => ({ value: d.id, label: d.nome }))}
placeholder="—" title="Dentista"
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
</div>
{/* Material + cor + dentes */}
<div className="grid grid-cols-3 gap-3">
@@ -1010,6 +1009,7 @@ const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => v
const [dentes, setDentes] = useState('');
const [prazo, setPrazo] = useState('');
const [sel, setSel] = useState<string[]>([]);
const [buscaLab, setBuscaLab] = useState('');
const [busy, setBusy] = useState(false);
const carregar = useCallback(() => HybridBackend.getRfqs().then(r => setRfqs(Array.isArray(r) ? r : [])).catch(() => setRfqs([])), []);
@@ -1052,8 +1052,12 @@ const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => v
</div>
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Laboratórios a convidar</p>
<div className="relative mb-2">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-300" />
<input value={buscaLab} onChange={e => setBuscaLab(e.target.value)} placeholder="Buscar laboratório…" className="w-full pl-8 pr-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
</div>
<div className="space-y-1.5 max-h-56 overflow-y-auto">
{labs.map(l => (
{labs.filter(l => !buscaLab.trim() || (l.nome || '').toLowerCase().includes(buscaLab.toLowerCase()) || (l.email || '').toLowerCase().includes(buscaLab.toLowerCase())).map(l => (
<button key={l.id} onClick={() => toggleLab(l.id)} className={`w-full flex items-center gap-2 px-3 py-2 rounded-xl border text-left ${sel.includes(l.id) ? 'border-violet-400 bg-violet-50' : 'border-gray-100'}`}>
<span className={`w-4 h-4 rounded border flex items-center justify-center ${sel.includes(l.id) ? 'bg-violet-600 border-violet-600' : 'border-gray-300'}`}>{sel.includes(l.id) && <Check size={11} className="text-white" />}</span>
<span className="text-sm font-bold text-gray-700 flex-1 truncate">{l.nome}</span>
@@ -1102,6 +1106,8 @@ export const ProteseView: React.FC = () => {
const toast = useToast();
const confirm = useConfirm();
const [lista, setLista] = useState<any[]>([]);
// Rótulos desambiguados de homônimos (só faz sentido na bancada, onde o protético lê o nome sem CPF).
const rotuloPac = useMemo(() => modo === 'bancada' ? rotulosPaciente(lista) : new Map<string, string>(), [lista, modo]);
const [loading, setLoading] = useState(true);
const [filtro, setFiltro] = useState('');
const [novaOS, setNovaOS] = useState(false);
@@ -1175,7 +1181,7 @@ export const ProteseView: React.FC = () => {
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-800 text-sm truncate">{os.tipo} {os.dentes ? <span className="text-gray-400 font-medium">· {os.dentes}</span> : ''}</p>
<p className="text-xs text-gray-400 truncate">
{modo === 'bancada' ? (os.paciente_nome || 'Sem paciente') : (os.protetico_nome || 'Laboratório')}
{modo === 'bancada' ? (rotuloPac.get(os.id) || os.paciente_nome || 'Sem paciente') : (os.protetico_nome || 'Laboratório')}
{os.prazo_entrega ? ` · entrega ${dataBR(os.prazo_entrega)}` : ''}
</p>
{/* Clínica de origem: essencial quando o lab atende várias clínicas. */}
+70 -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,74 @@ 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 liga/desliga por sessão</h2>
<p className="text-xs text-gray-400 font-medium">Quem ligou/desligou a Secretária de cada número, e o motivo do desligamento. {totalOff > 0 && `${totalOff} desligamento(s).`}</p>
</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">Número</th>
<th className="text-left px-4 py-3">Quem</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 text-gray-700 font-bold whitespace-nowrap">{l.phone || l.name || l.instance_id}</td>
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{l.by || '—'}</td>
<td className="px-4 py-3 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>
);
};
+231
View File
@@ -0,0 +1,231 @@
// WaInboxView — Inbox WhatsApp do scoreodonto, consumindo o motor NewWhats via
// proxy do backend (/api/nw/v1/* → motor /api/ext/v1/*). Estágio 1: lista de
// conversas + thread + envio. Visual aproximado do motor (WhatsApp-like).
import React, { useEffect, useRef, useState } from 'react';
import { PageHeader } from '../components/PageHeader.tsx';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
async function nw(path: string, opts: RequestInit = {}) {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const res = await fetch(`${API}/nw/v1${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts.headers || {}),
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`.slice(0, 200));
return res.json();
}
// URL do túnel WS (/api/nw/v1/stream → motor). Deriva o host do API (relativo ou
// absoluto) e troca http(s)→ws(s). O JWT vai no query porque o browser não envia
// Authorization no handshake de WebSocket.
function streamUrl(token: string) {
const base = API.startsWith('http') ? API : `${window.location.origin}${API}`;
const u = new URL(`${base}/nw/v1/stream`);
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
u.searchParams.set('token', token);
return u.toString();
}
interface Chat { id: string | number; remote_jid?: string; displayName?: string; phone?: string; last_message_text?: string | null; unread_count?: number }
interface Msg { id: string | number; text?: string; direction?: 'in' | 'out'; from_me?: number; timestamp?: number; type?: string }
// Normaliza a mensagem do stream (shape do motor) para o Msg do inbox.
function normalizeWsMsg(m: any): Msg {
return {
id: m?.id ?? `ws-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
text: m?.body ?? m?.text,
from_me: (m?.fromMe === true || m?.fromMe === 1) ? 1 : 0,
direction: m?.fromMe ? 'out' : 'in',
type: m?.type,
timestamp: m?.timestamp,
};
}
// Atualiza a lista de conversas com uma mensagem nova: preview, badge de unread
// (só quando não é a conversa aberta e não é enviada por nós) e sobe pro topo.
function bumpChat(list: Chat[], chatId: any, m: Msg, countUnread: boolean): Chat[] {
const i = list.findIndex((c) => String(c.id) === String(chatId));
if (i === -1) return list; // conversa ainda não carregada — aparece no próximo refresh
const updated: Chat = { ...list[i], last_message_text: m.text || `[${m.type || 'mídia'}]` };
if (countUnread && m.from_me !== 1) updated.unread_count = (updated.unread_count || 0) + 1;
return [updated, ...list.filter((_, idx) => idx !== i)];
}
export const WaInboxView: React.FC = () => {
const [sessions, setSessions] = useState<any[]>([]);
const [session, setSession] = useState<string>('');
const [chats, setChats] = useState<Chat[]>([]);
const [active, setActive] = useState<Chat | null>(null);
const [msgs, setMsgs] = useState<Msg[]>([]);
const [text, setText] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [wsUp, setWsUp] = useState(false);
const [qr, setQr] = useState<string | null>(null);
const endRef = useRef<HTMLDivElement>(null);
// Ref com a conversa aberta para o handler do WS ler sempre o valor atual
// (evita closure velha dentro do onmessage).
const activeRef = useRef<Chat | null>(null);
useEffect(() => { activeRef.current = active; }, [active]);
// Tempo real: conecta ao túnel WS e reflete eventos do motor sem refetch.
useEffect(() => {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
if (!token) return;
let ws: WebSocket | null = null;
let retry: ReturnType<typeof setTimeout> | null = null;
let closed = false;
const connect = () => {
try { ws = new WebSocket(streamUrl(token)); } catch { return; }
ws.onopen = () => setWsUp(true);
ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
ws.onclose = () => {
setWsUp(false);
if (!closed) retry = setTimeout(connect, 3000); // reconexão simples
};
ws.onmessage = (ev) => {
let env: any;
try { env = JSON.parse(ev.data); } catch { return; }
const { event, data } = env || {};
if (event === 'message.new') {
const norm = normalizeWsMsg(data?.message);
const isActive = !!activeRef.current && String(data?.chatId) === String(activeRef.current.id);
if (isActive) {
setMsgs((p) => p.some((x) => String(x.id) === String(norm.id)) ? p : [...p, norm]);
setTimeout(() => endRef.current?.scrollIntoView(), 50);
}
setChats((prev) => bumpChat(prev, data?.chatId, norm, !isActive));
} else if (event === 'session.status') {
setSessions((prev) => prev.map((s) =>
String(s.id || s.instance) === String(data?.instanceId) ? { ...s, status: data?.status } : s));
if (data?.status === 'connected') setQr(null);
} else if (event === 'session.qr') {
setQr(data?.qrBase64 || null);
}
};
};
connect();
return () => {
closed = true;
if (retry) clearTimeout(retry);
try { ws?.close(); } catch { /* ignore */ }
};
}, []);
useEffect(() => {
nw('/sessions').then((s) => {
const list = Array.isArray(s) ? s : [];
setSessions(list);
const conn = list.find((i: any) => String(i.status).toLowerCase() === 'connected') || list[0];
if (conn) setSession(conn.id || conn.instance);
}).catch((e) => setErr(String(e.message)));
}, []);
useEffect(() => {
if (!session) return;
setLoading(true);
nw(`/inbox?session=${encodeURIComponent(session)}&limit=50`)
.then((c) => setChats(Array.isArray(c) ? c : (c.chats ?? [])))
.catch((e) => setErr(String(e.message)))
.finally(() => setLoading(false));
}, [session]);
const openChat = async (c: Chat) => {
setActive(c); setMsgs([]);
setChats((prev) => prev.map((x) => String(x.id) === String(c.id) ? { ...x, unread_count: 0 } : x));
try {
const m = await nw(`/inbox/${encodeURIComponent(String(c.id))}/messages?limit=50`);
setMsgs(Array.isArray(m) ? m : (m.messages ?? []));
setTimeout(() => endRef.current?.scrollIntoView(), 50);
} catch (e: any) { setErr(String(e.message)); }
};
const send = async () => {
if (!active || !text.trim()) return;
const body = text.trim(); setText('');
try {
await nw(`/inbox/${encodeURIComponent(String(active.id))}/send`, { method: 'POST', body: JSON.stringify({ text: body }) });
setMsgs((p) => [...p, { id: `tmp-${Date.now()}`, text: body, from_me: 1, direction: 'out', timestamp: Date.now() }]);
setTimeout(() => endRef.current?.scrollIntoView(), 50);
} catch (e: any) { setErr(String(e.message)); }
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<PageHeader title="WhatsApp" description="Inbox integrado ao motor NewWhats" />
{err && <div style={{ background: '#fee2e2', color: '#991b1b', padding: '8px 12px', fontSize: 13 }}>Erro: {err}. Verifique a configuração do plugin (super-admin) e o pareamento com o motor.</div>}
<div style={{ display: 'flex', flex: 1, minHeight: 0, border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
{/* Lista de conversas */}
<div style={{ width: 320, borderRight: '1px solid #e5e7eb', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: 8, borderBottom: '1px solid #e5e7eb' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, fontSize: 11, color: wsUp ? '#16a34a' : '#9ca3af' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: wsUp ? '#22c55e' : '#d1d5db', display: 'inline-block' }} />
{wsUp ? 'Tempo real ativo' : 'Conectando tempo real…'}
</div>
<select value={session} onChange={(e) => setSession(e.target.value)} style={{ width: '100%', padding: 6, fontSize: 13 }}>
<option value="">Selecione a sessão</option>
{sessions.map((s) => <option key={s.id || s.instance} value={s.id || s.instance}>{(s.nome || s.name || s.phone || s.id)} ({s.status})</option>)}
</select>
{qr && (
<div style={{ marginTop: 8, textAlign: 'center', background: '#fff', border: '1px solid #e5e7eb', borderRadius: 8, padding: 8 }}>
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 6 }}>Escaneie para conectar</div>
<img src={qr.startsWith('data:') ? qr : `data:image/png;base64,${qr}`} alt="QR Code" style={{ width: '100%', maxWidth: 220 }} />
</div>
)}
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{loading && <div style={{ padding: 12, color: '#6b7280', fontSize: 13 }}>Carregando</div>}
{chats.map((c) => (
<div key={c.id} onClick={() => openChat(c)}
style={{ padding: '10px 12px', cursor: 'pointer', borderBottom: '1px solid #f3f4f6', background: active?.id === c.id ? '#f0fdf4' : 'transparent' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<strong style={{ fontSize: 14 }}>{c.displayName || c.phone || c.remote_jid}</strong>
{!!c.unread_count && <span style={{ background: '#22c55e', color: '#fff', borderRadius: 10, fontSize: 11, padding: '0 6px' }}>{c.unread_count}</span>}
</div>
<div style={{ fontSize: 12, color: '#6b7280', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{c.last_message_text || ''}</div>
</div>
))}
{!loading && !chats.length && <div style={{ padding: 12, color: '#9ca3af', fontSize: 13 }}>Nenhuma conversa.</div>}
</div>
</div>
{/* Thread */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: '#efeae2' }}>
{!active ? (
<div style={{ margin: 'auto', color: '#6b7280' }}>Selecione uma conversa</div>
) : (
<>
<div style={{ padding: '10px 14px', background: '#f0f2f5', borderBottom: '1px solid #e5e7eb', fontWeight: 600 }}>
{active.displayName || active.phone || active.remote_jid}
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 14 }}>
{msgs.map((m) => {
const out = m.from_me === 1 || m.direction === 'out';
return (
<div key={m.id} style={{ display: 'flex', justifyContent: out ? 'flex-end' : 'flex-start', marginBottom: 6 }}>
<div style={{ maxWidth: '70%', padding: '6px 10px', borderRadius: 8, fontSize: 14, background: out ? '#d9fdd3' : '#fff', boxShadow: '0 1px 0.5px rgba(0,0,0,.13)' }}>
{m.text || <em style={{ color: '#6b7280' }}>[{m.type || 'mídia'}]</em>}
</div>
</div>
);
})}
<div ref={endRef} />
</div>
<div style={{ display: 'flex', gap: 8, padding: 10, background: '#f0f2f5' }}>
<input value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder="Mensagem" style={{ flex: 1, padding: '8px 12px', borderRadius: 20, border: '1px solid #d1d5db' }} />
<button onClick={send} style={{ background: '#22c55e', color: '#fff', border: 'none', borderRadius: 20, padding: '8px 16px', cursor: 'pointer' }}>Enviar</button>
</div>
</>
)}
</div>
</div>
</div>
);
};
+67
View File
@@ -0,0 +1,67 @@
// WaSecretariaView — painel da Secretária IA (motor) no scoreodonto. Estágio 1:
// chat de teste via proxy POST /api/nw/v1/secretaria/ask. A configuração do
// cérebro/agente vive no motor (acessível ao super-admin).
import React, { useRef, useState } from 'react';
import { PageHeader } from '../components/PageHeader.tsx';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
async function ask(chatId: string, message: string) {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const res = await fetch(`${API}/nw/v1/secretaria/ask`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ chatId, message, contactName: 'Teste (painel)' }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`.slice(0, 200));
return res.json();
}
export const WaSecretariaView: React.FC = () => {
const [turns, setTurns] = useState<Array<{ q: string; a: string }>>([]);
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const chatId = useRef(`painel-${Date.now()}`);
const endRef = useRef<HTMLDivElement>(null);
const send = async () => {
if (!text.trim() || busy) return;
const q = text.trim(); setText(''); setBusy(true); setErr(null);
try {
const r = await ask(chatId.current, q);
setTurns((p) => [...p, { q, a: r.reply ?? JSON.stringify(r) }]);
setTimeout(() => endRef.current?.scrollIntoView(), 50);
} catch (e: any) { setErr(String(e.message)); }
finally { setBusy(false); }
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<PageHeader title="Secretária IA" description="Atendente virtual (motor NewWhats) — painel de teste" />
{err && <div style={{ background: '#fee2e2', color: '#991b1b', padding: '8px 12px', fontSize: 13 }}>Erro: {err}</div>}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', border: '1px solid #e5e7eb', borderRadius: 8, background: '#fff', overflow: 'hidden' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
{!turns.length && <div style={{ color: '#9ca3af', fontSize: 14 }}>Faça uma pergunta para testar a Secretária (ex.: "Quais procedimentos vocês fazem e os valores?").</div>}
{turns.map((t, i) => (
<div key={i} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 4 }}>
<div style={{ background: '#dbeafe', padding: '8px 12px', borderRadius: 10, fontSize: 14, maxWidth: '75%' }}>{t.q}</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<div style={{ background: '#f3f4f6', padding: '8px 12px', borderRadius: 10, fontSize: 14, maxWidth: '75%', whiteSpace: 'pre-wrap' }}>{t.a}</div>
</div>
</div>
))}
{busy && <div style={{ color: '#6b7280', fontSize: 13 }}>Secretária digitando</div>}
<div ref={endRef} />
</div>
<div style={{ display: 'flex', gap: 8, padding: 10, borderTop: '1px solid #e5e7eb' }}>
<input value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder="Pergunte algo…" style={{ flex: 1, padding: '8px 12px', borderRadius: 8, border: '1px solid #d1d5db' }} />
<button onClick={send} disabled={busy} style={{ background: '#2563eb', color: '#fff', border: 'none', borderRadius: 8, padding: '8px 16px', cursor: 'pointer', opacity: busy ? 0.6 : 1 }}>Perguntar</button>
</div>
</div>
</div>
);
};
+484
View File
@@ -0,0 +1,484 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Search,
MoreVertical,
User,
MessageSquare,
ChevronDown,
RefreshCw,
MessageSquarePlus,
RotateCw,
ArrowLeft,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import type { NewWhatsChat } from './types/inboxTypes';
import { getAvatarProxyUrl, formatPhone } from './utils/inboxUtils';
import { useNewInboxBridge } from './hooks/inbox/useNewInboxBridge';
import { useInstanceSelector } from './hooks/inbox/useInstanceSelector';
import { useChatFilter } from './hooks/inbox/useChatFilter';
import { useChatWindow } from './hooks/inbox/useChatWindow';
import { useMessageInput } from './hooks/inbox/useMessageInput';
import { nw } from './services/nwClient';
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 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';
type Chat = NewWhatsChat;
export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void }) {
// ── Seleção de instância (hook dedicado) ─────────────────────────────────
const {
instances,
activeInstance,
mappedActiveInstance,
connectedInstances,
isConnected,
dropdownRef,
selectInstance,
refreshInstances,
handleConnect: handleConnectInstance,
} = useInstanceSelector()
// ── Inbox bridge (chats + mensagens) ──────────────────────────────────────
const {
chats: chatsStore,
selectedChat,
messages,
loadingChats: loading,
loadingMessages,
presenceByJid,
handleSelectChat: handleSelectChatBridge,
handleSendText,
handleLoadMoreHistory,
handleMenuAction,
} = useNewInboxBridge()
// ── Filtros de chat (hook dedicado) ───────────────────────────────────────
const {
searchTerm,
activeFilter,
chatScope,
chatsByScope,
isFavoriteChat,
setSearchTerm,
setActiveFilter,
setChatScope,
toggleFavorite,
} = useChatFilter(chatsStore)
// ── Chat window (hook dedicado) ───────────────────────────────────────────
const {
scrollRef,
isTyping,
replyingTo,
reactionPickerFor,
hasMoreHistory,
loadingMore,
isProfileOpen,
isSyncingAvatar,
isChatMenuOpen,
setReplyingTo,
setIsProfileOpen,
setIsChatMenuOpen,
handleTyping,
handleScroll,
handleSelectChat: onChatWindowSelect,
handleReactToMessage,
handleSendMedia,
handleSendAudio,
handleSyncAvatar,
toggleReactionPicker,
} = useChatWindow({
selectedChat,
activeInstanceId: activeInstance?.instance ?? null,
onLoadMoreHistory: handleLoadMoreHistory,
})
// ── Mensagem de entrada (hook dedicado) ───────────────────────────────────
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
selectedChat,
replyingTo,
onClearReply: () => setReplyingTo(null),
onSendText: handleSendText,
})
// ── Painel de Protocolo ───────────────────────────────────────────────────
const [isProtocolOpen, setIsProtocolOpen] = useState(false)
const protocolPanel = useProtocolPanel(selectedChat ? String(selectedChat.id) : null)
// ── Estado local de UI ────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<TabType>('chats')
const [isInstanceDropdownOpen, setIsInstanceDropdownOpen] = useState(false)
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)
useEffect(() => {
let alive = true
HybridBackend.getWaAccess().then(a => { if (alive) setCanDeleteMsg(a.isOwner || a.delete_msg) }).catch(() => {})
return () => { alive = false }
}, [])
// 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'
// 2. ausência do flag sessionStorage 'nw:inbox-loaded' (primeira visita na sessão)
// sessionStorage sobrevive a navegações client-side mas é zerado em nova aba.
const [pageReady, setPageReady] = useState<boolean>(() => {
if (typeof window === 'undefined') return false
const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined
const isHardReload = navEntry ? navEntry.type === 'reload' : (performance.navigation?.type === 1)
if (isHardReload) return false
return sessionStorage.getItem('nw:inbox-loaded') === '1'
})
const prevLoadingRef = useRef(false)
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (prevLoadingRef.current && !loading) {
readyTimerRef.current = setTimeout(() => {
setPageReady(true)
sessionStorage.setItem('nw:inbox-loaded', '1')
}, 400)
}
prevLoadingRef.current = loading
return () => { if (readyTimerRef.current) clearTimeout(readyTimerRef.current) }
}, [loading])
const optionsRef = useRef<HTMLDivElement>(null)
const initialized = useRef(false)
// ── Setup mobile + click-outside ─────────────────────────────────────────
useEffect(() => {
if (initialized.current) return
initialized.current = true
const resize = () => setIsMobile(window.innerWidth < 768)
window.addEventListener('resize', resize)
resize()
const click = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) setIsInstanceDropdownOpen(false)
if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) setIsOptionsMenuOpen(false)
}
document.addEventListener('mousedown', click)
return () => { window.removeEventListener('resize', resize); document.removeEventListener('mousedown', click) }
}, [])
// ── Handlers ─────────────────────────────────────────────────────────────
const handleSelectChat = useCallback((chat: Chat | null) => {
handleSelectChatBridge(chat)
onChatWindowSelect()
setIsProtocolOpen(false)
}, [handleSelectChatBridge, onChatWindowSelect])
const handleChatMenuAction = useCallback((action: string, chat: Chat) => {
handleMenuAction(action, chat, toggleFavorite)
}, [handleMenuAction, toggleFavorite])
const handleFetchChats = useCallback(async () => {
await refreshInstances()
setIsOptionsMenuOpen(false)
}, [refreshInstances])
// Voltar: se há chat aberto, fecha-o; senão, página anterior
const handleBack = useCallback(() => {
if (selectedChat) {
handleSelectChat(null)
} else {
onNavigate?.('dashboard')
}
}, [selectedChat, handleSelectChat, onNavigate])
// ESC fecha chat ou vai para página anterior
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleBack()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [handleBack])
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="flex h-full w-full bg-[#f0f2f5] overflow-hidden select-none font-sans text-[#111b21]">
{/* Preloader global visível até o primeiro carregamento de chats completar */}
<AnimatePresence>
{!pageReady && (
<motion.div
key="inbox-preloader"
exit={{ opacity: 0 }}
transition={{ duration: 0.4 }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#070b14]"
>
<div className="flex flex-col items-center gap-5">
<motion.div
animate={{ opacity: [1, 0.2, 1] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
>
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" className="w-20 h-20">
<defs>
<linearGradient id="wpp-grad" x1="50" y1="100" x2="50" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#20b038"/>
<stop offset="1" stopColor="#60d66a"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="50" fill="url(#wpp-grad)"/>
<path d="M50 15.5c-19.05 0-34.5 15.45-34.5 34.5 0 6.36 1.72 12.3 4.73 17.4L15.5 84.5l17.6-4.62a34.36 34.36 0 0 0 16.9 4.42c19.05 0 34.5-15.45 34.5-34.5S69.05 15.5 50 15.5z" fill="rgba(0,0,0,0.12)"/>
<path d="M68.5 60.3c-1.1-.57-6.53-3.22-7.54-3.59-.99-.37-1.72-.55-2.44.55-.73 1.1-2.82 3.59-3.46 4.32-.64.73-1.28.82-2.38.27-6.53-3.26-10.81-5.83-15.1-13.22-1.14-1.97.24-1.83.65-3.05.34-1 .17-1.84-.1-2.39-.27-.55-2.44-5.99-3.4-8.2-.9-2.16-1.81-1.86-2.49-1.89-.64-.03-1.38-.04-2.12-.04s-1.93.28-2.94 1.38c-1.01 1.1-3.87 3.78-3.87 9.22s3.96 10.69 4.51 11.43c.55.73 7.8 11.9 18.9 16.7 7.02 3.03 9.76 3.28 13.28 2.76 2.13-.32 6.53-2.67 7.45-5.25.92-2.58.92-4.79.64-5.25-.27-.46-1-.73-2.09-1.27z" fill="#fff"/>
</svg>
</motion.div>
<span className="text-slate-500 text-sm">Carregando conversas</span>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="flex flex-1 overflow-hidden h-full">
<ThinSidebar activeTab={activeTab} setActiveTab={setActiveTab} activeInstance={activeInstance} onNavigate={onNavigate} />
{activeTab === 'chats' && (
<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">
<div className="flex items-center gap-1">
<button
onClick={handleBack}
title={selectedChat ? 'Voltar aos chats' : 'Voltar'}
className="p-2 hover:bg-black/5 rounded-full text-[#54656f] shrink-0"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="relative" ref={dropdownRef}>
<div onClick={() => setIsInstanceDropdownOpen(!isInstanceDropdownOpen)} className="flex items-center gap-3 cursor-pointer p-1">
<div className="w-10 h-10 rounded-full bg-white border border-black/5 overflow-hidden flex items-center justify-center">
{activeInstance?.instance
? <img
src={getAvatarProxyUrl({
instance_name: activeInstance.instance,
remote_jid: activeInstance.phone ? `${activeInstance.phone}@s.whatsapp.net` : null,
}) || activeInstance.avatar || undefined}
className="w-full h-full object-cover" alt=""
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
: <User className="text-gray-400" />}
</div>
<span className="font-bold text-sm truncate max-w-[120px]">{activeInstance?.nome || 'Instância'}</span>
<ChevronDown className={`w-4 h-4 transition-all ${isInstanceDropdownOpen ? 'rotate-180' : ''}`} />
</div>
<AnimatePresence>
{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 => (
<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">
<img
src={getAvatarProxyUrl({
instance_name: i.instance,
remote_jid: i.phone ? `${i.phone}@s.whatsapp.net` : null,
}) || i.avatar || undefined}
alt="" className="w-full h-full object-cover"
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>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>{/* end left flex */}
<div className="flex items-center gap-1">
<div className="relative" ref={optionsRef}>
<button onClick={() => setIsOptionsMenuOpen(!isOptionsMenuOpen)} className="p-2 hover:bg-black/5 rounded-full">
<MoreVertical className="w-5 h-5 text-[#54656f]" />
</button>
<AnimatePresence>
{isOptionsMenuOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute right-0 top-full mt-1 bg-white shadow-2xl rounded-xl border border-gray-100 w-56 z-50 py-2"
>
<button
onClick={handleFetchChats}
disabled={loading}
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Atualizar chats
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
{/* Busca */}
<div className="px-4 py-2 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
<input type="text" placeholder="Pesquisar..."
className="w-full bg-[#f0f2f5] h-[35px] pl-10 rounded-lg text-sm border-none focus:ring-0"
value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
</div>
<button onClick={() => setIsNewConvoModalOpen(true)}
className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-black/5">
<MessageSquarePlus className="w-4 h-4" />
</button>
</div>
{/* Lista de chats */}
<ChatSidebar
chats={chatsByScope}
loading={loading}
selectedChat={selectedChat}
searchTerm={searchTerm}
onSelectChat={handleSelectChat}
onMenuAction={handleChatMenuAction}
activeFilter={activeFilter}
selectedLabelFilter={null}
chatScope={chatScope}
activeCustomListName={null}
presenceByJid={presenceByJid}
isFavoriteChat={isFavoriteChat}
drafts={drafts}
canDelete={canDeleteMsg}
/>
</div>
)}
{/* Área principal */}
<div
className={`${isMobile ? ((selectedChat || activeTab === 'settings') ? 'w-full' : 'hidden') : 'flex-1'} flex h-full relative overflow-hidden`}
style={{ backgroundImage: "url('/chat-bg.png')", backgroundRepeat: 'repeat', backgroundColor: '#b5bdb5' }}
>
{/* Overlay branqueador sobre o padrão */}
<div className="absolute inset-0 bg-white/75 pointer-events-none z-0" />
{activeTab === 'settings' ? (
<SettingsPanel instanceId={activeInstance?.instance ?? null} />
) : selectedChat ? (
<>
<div className="flex-1 flex flex-col h-full relative z-[1] overflow-hidden border-r border-[#d1d7db]">
<InboxHeader
selectedChat={selectedChat}
isMobile={isMobile}
isTyping={isTyping}
onAvatarClick={() => { setIsProfileOpen(!isProfileOpen); setIsProtocolOpen(false) }}
onBack={() => handleSelectChat(null)}
onSyncAvatar={handleSyncAvatar}
isChatMenuOpen={isChatMenuOpen}
isSyncingAvatar={isSyncingAvatar}
isProtocolOpen={isProtocolOpen}
hasActiveProtocol={protocolPanel.hasActive}
onSearchClick={() => {}}
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
onProtocolClick={() => { setIsProtocolOpen(!isProtocolOpen); setIsProfileOpen(false) }}
/>
<MessageArea
messages={messages as any}
selectedChat={selectedChat}
scrollRef={scrollRef}
msgLoading={loadingMessages}
onScroll={handleScroll}
historyLoadingMore={loadingMore}
hasMoreHistory={hasMoreHistory}
reactionPickerFor={reactionPickerFor}
onReply={setReplyingTo}
onToggleReactionPicker={toggleReactionPicker}
onReact={handleReactToMessage}
onForward={() => {}}
onDelete={() => {}}
canDelete={canDeleteMsg}
onEdit={() => {}}
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
getGroupSenderLabel={(msg: any) => {
if (msg.direction === 'out') return 'Você'
if (msg.participant) return msg.participant
if (msg.senderJid) {
if (msg.senderJid.endsWith('@lid')) return msg.participant || ''
const phone = msg.senderJid.split('@')[0].split(':')[0]
return formatPhone(phone, msg.senderJid)
}
return ''
}}
/>
<InputBar
newMessage={newMessage}
onNewMessageChange={setNewMessage}
onSend={handleSendMessage}
onPresence={handleTyping}
onCancelReply={() => setReplyingTo(null)}
replyingTo={replyingTo}
selectedChat={selectedChat}
isMobile={isMobile}
onSendMedia={handleSendMedia}
onSendAudio={handleSendAudio}
instanceId={activeInstance?.instance ?? null}
mentions={mentions}
onMentionsChange={setMentions}
/>
</div>
<ContactProfile
isOpen={isProfileOpen && !isProtocolOpen && !isMobile}
chat={selectedChat}
messages={messages as any}
onClose={() => setIsProfileOpen(false)}
/>
<ProtocolPanel
isOpen={isProtocolOpen && !isMobile}
chatId={selectedChat ? String(selectedChat.id) : null}
onClose={() => setIsProtocolOpen(false)}
{...protocolPanel}
/>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-10 bg-[#f0f2f5]">
<MessageSquare className="w-20 h-20 opacity-10 mb-8" />
<h1 className="text-2xl font-light text-[#41525d] mb-4">NewWhats</h1>
<p className="text-sm text-[#667781]">Selecione uma conversa para começar.</p>
</div>
)}
</div>
</div>
<NewConversationModal
isOpen={isNewConvoModalOpen}
onClose={() => setIsNewConvoModalOpen(false)}
activeInstance={mappedActiveInstance as any}
connectedInstances={connectedInstances as any}
sendMessage={async (instanceId: string, phone: string, text: string) => {
try {
// ext: inicia nova conversa por telefone (resolve/cria o chat).
const res = await nw.post('/conversations', { sessionId: instanceId, phone, text })
return { ok: true, messageId: res?.messageId }
} catch {
return { ok: false }
}
}}
/>
</div>
)
}
File diff suppressed because it is too large Load Diff
+907
View File
@@ -0,0 +1,907 @@
// SessionsView — página /sessions do motor NewWhats, portada para o satélite
// scoreodonto. Dados/realtime via shims (ext API + túnel WS). Adaptações: sem
// next/link (navegação via onNavigate), export nomeado, wrapper de fundo escuro.
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Wifi, Plus, RefreshCw, Loader2, QrCode, X,
CheckCircle2, AlertCircle, Trash2, LogOut, Phone,
ArrowLeft, Zap, WifiOff, Clock, Terminal, Download,
ChevronDown, ChevronRight, Crown, Lock, ShieldCheck, UserCog,
} from 'lucide-react';
import { Button } from './components/ui';
import { useSessionsLogic } from './hooks/useSessionsLogic';
import { socketService } from './services/socketService';
import type { Instance } from './store/instanceStore';
import { HybridBackend, type WaPerms } from '../../services/backend';
// Permissões da conta logada sobre uma sessão específica.
interface SessionPerms {
isOwner: boolean;
canRescan: boolean;
canDelete: boolean;
ownerNome: string | null;
}
const OWNER_PERMS: SessionPerms = { isOwner: true, canRescan: true, canDelete: true, ownerNome: null };
// ─── Status config ────────────────────────────────────────────────────────────
const STATUS_CONFIG = {
CONNECTED: {
label: 'Conectado',
dot: 'bg-emerald-400',
badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
icon: <Wifi className="w-4 h-4" />,
},
QR_PENDING: {
label: 'Aguardando QR',
dot: 'bg-amber-400 animate-pulse',
badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
icon: <QrCode className="w-4 h-4" />,
},
CONNECTING: {
label: 'Conectando...',
dot: 'bg-blue-400 animate-pulse',
badge: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
icon: <Loader2 className="w-4 h-4 animate-spin" />,
},
DISCONNECTED: {
label: 'Desconectado',
dot: 'bg-gray-400',
badge: 'bg-gray-100 text-gray-600 border-gray-300',
icon: <WifiOff className="w-4 h-4" />,
},
BANNED: {
label: 'Banido',
dot: 'bg-red-500',
badge: 'bg-red-500/10 text-red-400 border-red-500/20',
icon: <AlertCircle className="w-4 h-4" />,
},
};
// ─── Baileys Log Types ────────────────────────────────────────────────────────
interface BaileysLogEntry {
id: string;
ts: number;
event: string;
data: any;
}
const EVENT_COLOR: Record<string, string> = {
'connection.update': 'text-emerald-400 bg-emerald-500/15',
'messages.upsert': 'text-blue-400 bg-blue-500/15',
'contacts.upsert': 'text-purple-400 bg-purple-500/15',
'contacts.update': 'text-purple-300 bg-purple-500/10',
'chats.upsert': 'text-yellow-400 bg-yellow-500/15',
'chats.update': 'text-yellow-300 bg-yellow-500/10',
'groups.upsert': 'text-orange-400 bg-orange-500/15',
'groups.update': 'text-orange-300 bg-orange-500/10',
'messaging-history.set': 'text-cyan-400 bg-cyan-500/15',
'message': 'text-blue-300 bg-blue-500/10',
};
function eventColor(event: string): string {
return EVENT_COLOR[event] ?? 'text-gray-600 bg-gray-100';
}
function logSummary(entry: BaileysLogEntry): string {
const d = entry.data;
if (!d) return '—';
if (entry.event === 'connection.update') {
if (d.connection) return `${d.connection}`;
if (d.qr) return 'QR gerado';
}
if (entry.event === 'message' || entry.event === 'messages.upsert') {
const body = d.body ?? d.message?.conversation ?? d.message?.extendedTextMessage?.text;
if (body) return String(body).slice(0, 60);
const jid = d.remoteJid ?? d.key?.remoteJid;
if (jid) return jid;
}
if (typeof d === 'object') {
const count = Array.isArray(d) ? d.length : (d.count ?? d.chats ?? d.contacts ?? d.messages);
if (count !== undefined) return `${count} item(s)`;
const keys = Object.keys(d).slice(0, 3).join(', ');
return keys || '{}';
}
return String(d).slice(0, 60);
}
// ─── Log Row (expandable) ─────────────────────────────────────────────────────
function LogRow({ entry, index }: { entry: BaileysLogEntry; index: number }) {
const [open, setOpen] = useState(false);
const time = new Date(entry.ts).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
return (
<div className="border-b border-white/[0.04] last:border-0">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-gray-100 text-left group transition-colors"
>
<span className="text-[9px] font-mono text-gray-400 w-5 shrink-0 text-right">{index + 1}</span>
<span className={`text-[9px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap ${eventColor(entry.event)}`}>
{entry.event}
</span>
<span className="text-[11px] text-gray-600 truncate flex-1 font-mono">{logSummary(entry)}</span>
<span className="text-[9px] text-gray-400 shrink-0 font-mono">{time}</span>
<span className="shrink-0 text-gray-400 group-hover:text-gray-500">
{open ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
</span>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<pre className="mx-3 mb-2 p-2 bg-gray-100 rounded-lg text-[10px] font-mono text-gray-700 overflow-x-auto whitespace-pre-wrap max-h-48 border border-gray-100">
{JSON.stringify(entry.data, null, 2)}
</pre>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// ─── QR Modal ─────────────────────────────────────────────────────────────────
function QrModal({
instanceId,
instanceName,
qrBase64,
onClose,
onNewQr,
}: {
instanceId: string;
instanceName: string;
qrBase64: string | null;
onClose: () => void;
onNewQr: (id: string) => Promise<void>;
}) {
const [expired, setExpired] = useState(false);
const [logs, setLogs] = useState<BaileysLogEntry[]>([]);
const logEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!qrBase64) return;
setExpired(false);
const t = setTimeout(() => setExpired(true), 60_000);
return () => clearTimeout(t);
}, [qrBase64]);
useEffect(() => {
const handler = (log: any) => {
if (log.sessionId !== instanceId) return;
setLogs(prev => {
const entry: BaileysLogEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
ts: log.timestamp ? new Date(log.timestamp).getTime() : Date.now(),
event: log.event ?? 'unknown',
data: log.data,
};
return [...prev, entry].slice(-300);
});
};
socketService.on('baileys:log', handler);
return () => socketService.off('baileys:log', handler);
}, [instanceId]);
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs.length]);
const saveJson = useCallback(() => {
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `baileys-${instanceId}-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}, [logs, instanceId]);
return (
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative bg-white border border-gray-200 rounded-[2rem] shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
<div>
<h2 className="text-lg font-black text-gray-800">Conectar WhatsApp</h2>
<p className="text-gray-500 text-xs mt-0.5">{instanceName}</p>
</div>
<button onClick={onClose} className="p-2 text-gray-500 hover:text-gray-800 bg-gray-50 rounded-xl transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-1 min-h-0">
<div className="w-72 shrink-0 flex flex-col items-center justify-center gap-5 p-6 border-r border-gray-100">
<div className="bg-gray-50 border border-gray-100 p-5 rounded-3xl w-full flex flex-col items-center justify-center min-h-[260px]">
{expired ? (
<div className="flex flex-col items-center gap-4">
<Clock className="w-10 h-10 text-gray-500" />
<p className="font-bold text-gray-800 text-sm">QR expirado</p>
<Button variant="primary" size="sm" onClick={async () => {
setExpired(false);
await onNewQr(instanceId);
}}>
Novo QR
</Button>
</div>
) : qrBase64 ? (
<div className="bg-white p-2.5 rounded-2xl shadow-xl">
<img src={qrBase64.startsWith('data:') ? qrBase64 : `data:image/png;base64,${qrBase64}`} alt="QR Code" className="w-48 h-48 rounded-xl" />
</div>
) : (
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-10 h-10 animate-spin text-emerald-500" />
<p className="text-xs text-gray-500 font-semibold uppercase tracking-widest">Gerando QR...</p>
</div>
)}
</div>
{!expired && (
<div className="flex items-center gap-2 text-emerald-400 text-[10px] font-bold uppercase tracking-widest animate-pulse">
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> Aguardando pareamento...
</div>
)}
<button onClick={onClose} className="w-full py-2.5 text-gray-500 hover:text-gray-800 font-bold transition-colors text-sm">
Fechar
</button>
</div>
<div className="flex-1 flex flex-col min-w-0">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-gray-100 bg-gray-50 shrink-0">
<Terminal className="w-3.5 h-3.5 text-gray-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500">Console Baileys</span>
<span className="ml-1 text-[9px] font-mono text-gray-400 bg-gray-50 px-1.5 py-0.5 rounded-full">
{logs.length} eventos
</span>
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => setLogs([])}
className="text-[9px] font-bold uppercase tracking-wider text-gray-500 hover:text-gray-600 transition-colors px-2 py-0.5 rounded hover:bg-gray-50"
>
Limpar
</button>
<button
onClick={saveJson}
disabled={logs.length === 0}
className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-emerald-500 hover:text-emerald-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors px-2 py-0.5 rounded hover:bg-emerald-500/10"
>
<Download size={10} /> Salvar JSON
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400 gap-2 py-16">
<Terminal size={28} className="opacity-30" />
<p className="text-xs">Aguardando eventos do Baileys...</p>
</div>
) : (
<div>
{logs.map((entry, i) => (
<LogRow key={entry.id} entry={entry} index={i} />
))}
<div ref={logEndRef} />
</div>
)}
</div>
<div className="px-4 py-2 border-t border-gray-100 bg-gray-50 shrink-0">
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[9px] font-mono text-gray-500">
{instanceId.slice(0, 8)}... · live
</span>
</div>
</div>
</div>
</div>
</motion.div>
</div>
);
}
// ─── Session Card ─────────────────────────────────────────────────────────────
const SessionCard = React.forwardRef<HTMLDivElement, {
inst: Instance;
perms: SessionPerms;
onConnect: () => void;
onDisconnect: () => void;
onDelete: () => void;
onShowQr: () => void;
onManagePerms: () => void;
}>(function SessionCard({
inst,
perms,
onConnect,
onDisconnect,
onDelete,
onShowQr,
onManagePerms,
}, ref) {
const cfg = STATUS_CONFIG[inst.status] ?? STATUS_CONFIG.DISCONNECTED;
const isConnected = inst.status === 'CONNECTED';
const isQrPending = inst.status === 'QR_PENDING';
const { isOwner, canRescan, canDelete, ownerNome } = perms;
const rescanLocked = !isOwner && !canRescan;
const deleteLocked = !isOwner && !canDelete;
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }}
className="bg-gray-100 backdrop-blur-xl p-6 rounded-[2rem] shadow-2xl border border-gray-100 flex flex-col gap-5 hover:border-gray-200 transition-all group relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-brand-500/5 blur-[60px] rounded-full translate-x-1/2 -translate-y-1/2 pointer-events-none" />
<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'}`}>
{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}
<span className={`w-full h-full items-center justify-center ${inst.avatar ? 'hidden' : 'flex'}`}>
<Phone className="w-6 h-6" />
</span>
</div>
<div>
<h3 className="font-black text-gray-800 text-lg tracking-tight">{inst.name}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{inst.phone && (
<span className="text-xs font-mono text-gray-500 bg-gray-50 px-2 py-0.5 rounded border border-gray-100">{inst.phone}</span>
)}
<span className={`inline-flex items-center gap-1.5 text-[10px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full border ${cfg.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${cfg.dot}`} />
{cfg.label}
</span>
{isOwner ? (
<span className="inline-flex items-center gap-1 text-[10px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full border bg-amber-500/10 text-amber-500 border-amber-500/20" title="Você é o dono desta conta">
<Crown className="w-3 h-3" /> Dono
</span>
) : ownerNome ? (
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full border bg-gray-500/10 text-gray-500 border-gray-200" title={`Dono: ${ownerNome}`}>
<Crown className="w-3 h-3" /> {ownerNome}
</span>
) : null}
</div>
</div>
</div>
{deleteLocked ? (
<button disabled title="Somente o dono pode excluir esta sessão — peça autorização de exclusão."
className="p-2 text-gray-300 rounded-xl shrink-0 cursor-not-allowed"
>
<Lock className="w-4 h-4" />
</button>
) : (
<button onClick={onDelete} title="Deletar instância"
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-colors shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
{isQrPending && (
rescanLocked ? (
<div className="flex items-center gap-3 p-3 bg-gray-500/5 border border-gray-200 rounded-2xl text-gray-400 text-sm font-semibold w-full"
title="Somente o dono pode parear esta sessão — peça autorização de re-scan."
>
<Lock className="w-5 h-5 shrink-0" />
<span className="text-left">QR disponível somente o dono pode escanear</span>
</div>
) : (
<button onClick={onShowQr}
className="flex items-center gap-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-2xl text-amber-300 text-sm font-semibold hover:bg-amber-500/15 transition-colors w-full"
>
<QrCode className="w-5 h-5 shrink-0" />
<span className="text-left">QR aguardando escaneamento clique para ver</span>
</button>
)
)}
<div className="flex items-center gap-2 mt-auto relative z-10 pt-2 border-t border-gray-100">
{isConnected ? (
<>
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold uppercase tracking-widest flex-1">
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> Sessão ativa
</div>
{isOwner && (
<button onClick={onManagePerms} title="Gerenciar quem pode reconectar/excluir"
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-brand-600 hover:text-brand-700 bg-brand-500/10 hover:bg-brand-500/15 rounded-xl transition-colors border border-brand-500/20"
>
<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>
</>
) : 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"
title="Somente o dono pode reconectar esta sessão — peça autorização de re-scan."
>
<Lock className="w-3.5 h-3.5" /> Conectar
</div>
) : (
<>
<button onClick={onConnect}
className="flex-1 flex items-center justify-center gap-2 bg-brand-500 text-white font-black py-3 rounded-xl hover:bg-brand-600 transition-all text-[11px] uppercase tracking-widest"
>
<Zap className="w-3.5 h-3.5" fill="currentColor" /> Conectar
</button>
{isOwner && (
<button onClick={onManagePerms} title="Gerenciar quem pode reconectar/excluir"
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-brand-600 hover:text-brand-700 bg-brand-500/10 hover:bg-brand-500/15 rounded-xl transition-colors border border-brand-500/20"
>
<UserCog className="w-3.5 h-3.5" /> Acesso
</button>
)}
</>
)}
</div>
</motion.div>
);
});
// ─── Confirm Delete Modal ─────────────────────────────────────────────────────
function ConfirmDeleteModal({
inst,
onClose,
onConfirm,
loading,
}: {
inst: Instance;
onClose: () => void;
onConfirm: () => void;
loading: boolean;
}) {
return (
<div className="fixed inset-0 z-[130] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={onClose} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
<motion.div initial={{ opacity: 0, scale: 0.92, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9 }}
className="relative bg-white backdrop-blur-2xl border border-red-500/20 border-t-[3px] border-t-red-500 rounded-[28px] p-7 w-full max-w-sm shadow-2xl"
>
<div className="flex items-center gap-3 mb-4 mt-2">
<div className="w-11 h-11 rounded-xl bg-red-500/15 border border-red-500/30 flex items-center justify-center">
<Trash2 className="w-5 h-5 text-red-500" />
</div>
<div>
<h3 className="font-bold text-gray-800">Deletar instância</h3>
<p className="text-xs text-gray-500">{inst.name}</p>
</div>
</div>
<p className="text-sm text-gray-600 mb-6 leading-relaxed">
Isso removerá permanentemente a instância, seus chats e sessão Baileys.<br />
<strong className="text-red-400">Esta ação é irreversível.</strong>
</p>
<div className="flex gap-3">
<Button variant="ghost" onClick={onClose} className="flex-1">Cancelar</Button>
<button onClick={onConfirm} disabled={loading}
className="flex-1 py-3 bg-red-500 hover:bg-red-600 text-white font-bold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Deletar
</button>
</div>
</motion.div>
</div>
);
}
// ─── Main View ────────────────────────────────────────────────────────────────
// ─── Modal: gestão de acesso (autorizações por sessão) — só dono ───────────────
type PermMember = { usuarioId: string; nome: string; email: string; role: string } & WaPerms;
type PermField = keyof WaPerms;
function ManagePermsModal({ inst, onClose }: { inst: Instance; onClose: () => void }) {
const [members, setMembers] = useState<PermMember[]>([]);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null);
useEffect(() => {
let alive = true;
HybridBackend.getWaSessionAuthz(inst.id).then((r) => {
if (alive) { setMembers((r.members as PermMember[]) || []); setLoading(false); }
}).catch(() => alive && setLoading(false));
return () => { alive = false; };
}, [inst.id]);
const toggle = useCallback(async (usuarioId: string, field: PermField, value: boolean) => {
const target = members.find((m) => m.usuarioId === usuarioId);
if (!target) return;
const next: PermMember = { ...target, [field]: value };
setSavingId(usuarioId);
// Otimista
setMembers((prev) => prev.map((m) => m.usuarioId === usuarioId ? next : m));
const res = await HybridBackend.setWaSessionAuthz(inst.id, usuarioId, {
can_rescan: next.can_rescan, can_delete: next.can_delete, can_inbox: next.can_inbox, can_secretaria: next.can_secretaria, can_delete_msg: next.can_delete_msg,
});
if (!res.ok) {
// Reverte
setMembers((prev) => prev.map((m) => m.usuarioId === usuarioId ? target : m));
}
setSavingId(null);
}, [members, inst.id]);
return (
<div className="fixed inset-0 z-[130] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={onClose} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
<motion.div initial={{ opacity: 0, scale: 0.92, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9 }}
className="relative bg-white border border-brand-500/20 border-t-[3px] border-t-brand-500 rounded-[28px] p-7 w-full max-w-lg shadow-2xl max-h-[85vh] flex flex-col"
>
<button onClick={onClose} className="absolute top-5 right-5 p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-lg"><X className="w-4 h-4" /></button>
<div className="flex items-center gap-3 mb-1 mt-1">
<div className="w-11 h-11 rounded-xl bg-brand-500/15 border border-brand-500/30 flex items-center justify-center">
<ShieldCheck className="w-5 h-5 text-brand-600" />
</div>
<div>
<h3 className="font-black text-gray-800">Gerenciar acesso</h3>
<p className="text-xs text-gray-500">{inst.name} · o que cada conta pode fazer</p>
</div>
</div>
<p className="text-[11px] text-gray-500 leading-snug bg-gray-50 rounded-xl p-3 mt-3 mb-4">
Você é o <b>dono</b> desta conta você faz o pareamento inicial. Autorize contas da equipe a acessar o
<b> Inbox</b> e a <b>Secretária</b>, <b>reconectar</b>, <b>apagar conversas/mensagens</b> e/ou <b>excluir</b> a sessão.
O scan inicial permanece exclusivo seu.
</p>
<div className="flex-1 overflow-y-auto custom-scrollbar -mx-1 px-1 space-y-2">
{loading ? (
<div className="py-10 flex items-center justify-center text-gray-400"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : members.length === 0 ? (
<p className="text-center text-xs text-gray-400 font-semibold py-10 uppercase">Nenhuma outra conta neste workspace.</p>
) : members.map((m) => (
<div key={m.usuarioId} className="flex items-center gap-3 p-3 rounded-2xl border border-gray-100 bg-gray-50/60">
<div className="w-9 h-9 rounded-full bg-brand-500/10 flex items-center justify-center text-brand-600 shrink-0 text-xs font-black uppercase">
{(m.nome || m.email || '?').slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-gray-800 truncate">{m.nome || m.email}</p>
<p className="text-[10px] text-gray-400 font-semibold truncate">{m.email}{m.role ? ` · ${m.role}` : ''}</p>
</div>
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end max-w-[190px]">
<PermToggle label="Inbox" active={m.can_inbox} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_inbox', !m.can_inbox)} />
<PermToggle label="Secretária" active={m.can_secretaria} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_secretaria', !m.can_secretaria)} />
<PermToggle label="Re-scan" active={m.can_rescan} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_rescan', !m.can_rescan)} />
<PermToggle label="Apagar msg" danger active={m.can_delete_msg} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_delete_msg', !m.can_delete_msg)} />
<PermToggle label="Excluir sessão" danger active={m.can_delete} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_delete', !m.can_delete)} />
</div>
</div>
))}
</div>
</motion.div>
</div>
);
}
function PermToggle({ label, active, danger, busy, onClick }: { label: string; active: boolean; danger?: boolean; busy?: boolean; onClick: () => void }) {
const on = danger
? 'bg-red-500/15 text-red-600 border-red-500/30'
: 'bg-emerald-500/15 text-emerald-600 border-emerald-500/30';
const off = 'bg-white text-gray-400 border-gray-200 hover:border-gray-300';
return (
<button onClick={onClick} disabled={busy}
className={`px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider border transition-all disabled:opacity-60 ${active ? on : off}`}
>
{active ? '✓ ' : ''}{label}
</button>
);
}
export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => void }) {
const {
instances,
loading,
toast,
addOpen,
newName,
creating,
setNewName,
openAddModal,
closeAddModal,
handleCreate,
qrModal,
closeQrModal,
handleNewQr,
deleteTarget,
deleting,
setDeleteTarget,
closeDeleteModal,
handleDelete,
handleConnect,
handleDisconnect,
handleShowQr,
refreshInstances,
} = useSessionsLogic();
// ── 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);
const [permsModalFor, setPermsModalFor] = useState<Instance | null>(null);
const instanceIdsKey = instances.map((i) => i.id).join(',');
useEffect(() => {
let alive = true;
(async () => {
if (instances.length === 0) {
const r = await HybridBackend.getWaSessionAuthz('').catch(() => null);
if (!alive) return;
setAuthzAvailable(!!r?.available);
if (r) setWorkspaceOwner({ isOwner: r.isOwner, ownerNome: r.ownerNome });
return;
}
const results = await Promise.all(instances.map(async (inst) => {
const r = await HybridBackend.getWaSessionAuthz(inst.id).catch(() => null);
return [inst.id, r] as const;
}));
if (!alive) return;
const map: Record<string, SessionPerms> = {};
let owner = { isOwner: false, ownerNome: null as string | null };
let available = false;
for (const [id, r] of results) {
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 };
if (r?.available && r.isOwner) owner = { isOwner: true, ownerNome: r.ownerNome };
else if (r?.available && !owner.ownerNome && r.ownerNome) owner.ownerNome = r.ownerNome;
}
setAuthzAvailable(available);
setAuthzByInstance(map);
setWorkspaceOwner(owner);
})();
return () => { alive = false; };
}, [instanceIdsKey]);
// Permissões efetivas de uma instância.
// Sem authz disponível → permissivo (não bloqueia; backend é a autoridade).
const permsFor = (inst: Instance): SessionPerms => {
if (!authzAvailable) return { isOwner: false, canRescan: true, canDelete: true, 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;
// Já existe uma sessão ativa no motor para esta conta → mostra mensagem de
// sucesso em vez de sugerir novo pareamento.
const hasActiveSession = instances.some((i) => i.status === 'CONNECTED');
return (
<div className="bg-gray-50 min-h-full text-gray-800">
<div className="max-w-5xl mx-auto p-6 lg:p-10 pb-20 font-sans space-y-10">
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }}
className={`fixed top-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-2.5 px-5 py-3 rounded-2xl shadow-2xl border text-sm font-semibold ${toast.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-300' : 'bg-red-500/10 border-red-500/20 text-red-300'}`}
>
{toast.ok ? <CheckCircle2 className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
{toast.msg}
</motion.div>
)}
</AnimatePresence>
<header className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 relative">
<div className="absolute -top-16 -left-16 w-56 h-56 bg-brand-500/10 blur-[100px] rounded-full pointer-events-none" />
<div className="relative z-10 flex items-start gap-4">
<button onClick={() => onNavigate?.('dashboard')} className="p-2 hover:bg-gray-50 rounded-xl transition-colors text-gray-600 hover:text-gray-800 mt-1">
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex-1">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-brand-500 to-brand-700 rounded-xl flex items-center justify-center text-white shadow-glow-brand shrink-0">
<Wifi className="w-6 h-6" />
</div>
<h1 className="text-3xl font-black text-gray-800 tracking-tight">Instâncias</h1>
</div>
<p className="text-sm text-gray-500 font-medium mt-1 ml-[60px]">WhatsApp Multi-Device · Baileys</p>
</div>
</div>
<div className="flex items-center gap-3 relative z-10">
<button onClick={refreshInstances} title="Recarregar"
className="p-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-600 hover:text-gray-800 transition-all hover:bg-gray-100"
>
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
</button>
{canCreateInstance ? (
<button onClick={openAddModal}
className="flex items-center gap-2 px-5 py-3 bg-brand-500 hover:bg-brand-600 text-white font-bold rounded-xl transition-all shadow-glow-brand text-sm"
>
<Plus className="w-5 h-5" /> Nova Instância
</button>
) : (
<button disabled title="Somente o dono do workspace pode conectar um novo WhatsApp."
className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-400 font-bold rounded-xl text-sm cursor-not-allowed border border-gray-200"
>
<Lock className="w-5 h-5" /> Nova Instância
</button>
)}
</div>
</header>
{hasActiveSession && (
<motion.div
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-4 p-5 rounded-2xl bg-emerald-500/10 border border-emerald-500/20"
>
<div className="w-11 h-11 rounded-xl bg-emerald-500/15 flex items-center justify-center text-emerald-400 shrink-0">
<CheckCircle2 className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-base font-black text-emerald-300">Você possui acesso ao WhatsApp</h2>
<p className="text-sm text-emerald-400/70 font-medium">Sua sessão está ativa e conectada não é preciso parear novamente.</p>
</div>
{onNavigate && (
<button onClick={() => onNavigate('wa-inbox')}
className="shrink-0 flex items-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white font-bold rounded-xl transition-all text-sm"
>
Abrir conversas
</button>
)}
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<AnimatePresence mode="popLayout">
{loading ? (
Array(3).fill(0).map((_, i) => (
<div key={i} className="h-56 rounded-[2rem] animate-pulse bg-gray-50" />
))
) : instances.length === 0 ? (
<motion.div key="empty" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
className="col-span-full py-24 flex flex-col items-center justify-center text-center gap-6"
>
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center text-gray-500">
<Phone className="w-10 h-10" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-800">Nenhuma instância</h2>
<p className="text-gray-500 mt-1 text-sm">
{canCreateInstance
? 'Crie uma para começar a usar o WhatsApp.'
: 'O dono do workspace ainda não conectou um WhatsApp.'}
</p>
</div>
{canCreateInstance && (
<button onClick={openAddModal}
className="flex items-center gap-2 px-6 py-3 bg-gray-50 hover:bg-gray-100 text-gray-800 font-bold rounded-2xl border border-gray-200 transition-all"
>
<Plus className="w-5 h-5" /> Criar instância
</button>
)}
</motion.div>
) : (
instances.map((inst) => (
<SessionCard
key={inst.id}
inst={inst}
perms={permsFor(inst)}
onConnect={() => handleConnect(inst)}
onDisconnect={() => handleDisconnect(inst)}
onDelete={() => setDeleteTarget(inst)}
onShowQr={() => handleShowQr(inst)}
onManagePerms={() => setPermsModalFor(inst)}
/>
))
)}
</AnimatePresence>
</div>
<AnimatePresence>
{addOpen && (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={closeAddModal} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative bg-gray-100 backdrop-blur-2xl p-8 rounded-[3rem] border border-gray-200 max-w-sm w-full space-y-6 shadow-2xl"
>
<div>
<h2 className="text-2xl font-black text-gray-800">Nova Instância</h2>
<p className="text-gray-500 text-sm mt-1"> um nome para identificar esta conexão.</p>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Nome</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
placeholder="ex: principal, comercial, suporte..."
autoFocus
className="w-full bg-gray-50 border border-gray-200 rounded-2xl px-4 py-3 text-gray-800 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-brand-500/40 text-sm"
/>
</div>
<div className="flex gap-3">
<Button variant="ghost" onClick={closeAddModal} className="flex-1">
Cancelar
</Button>
<button onClick={handleCreate} disabled={creating || !newName.trim()}
className="flex-1 py-3 bg-emerald-500 hover:bg-emerald-600 text-white font-bold rounded-2xl transition-all disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Criar e conectar
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
<AnimatePresence>
{qrModal && (
<QrModal
instanceId={qrModal.instanceId}
instanceName={qrModal.instanceName}
qrBase64={qrModal.qrBase64}
onClose={closeQrModal}
onNewQr={handleNewQr}
/>
)}
</AnimatePresence>
<AnimatePresence>
{deleteTarget && (
<ConfirmDeleteModal
inst={deleteTarget}
onClose={closeDeleteModal}
onConfirm={handleDelete}
loading={deleting}
/>
)}
</AnimatePresence>
<AnimatePresence>
{permsModalFor && (
<ManagePermsModal inst={permsModalFor} onClose={() => setPermsModalFor(null)} />
)}
</AnimatePresence>
</div>
</div>
);
}
export default SessionsView;
+452
View File
@@ -0,0 +1,452 @@
'use client';
/**
* WhatsChatDrawer painel slide-over (direita) com um clone FOCADO da área de
* mensagens do NewWhats, aberto a partir de um agendamento da agenda.
*
* Diferente do InboxView (rota /wa-inbox), NÃO mostra a lista lateral de chats:
* abre direto na conversa do paciente, resolvida pelo telefone do cadastro.
* Um dropdown permite trocar entre o número do paciente e os do grupo familiar.
* Se o paciente ainda não tem conversa, oferece iniciar uma (POST /conversations,
* mesmo fluxo do NewConversationModal, que funciona no satélite via proxy nw).
*
* Reaproveita a MESMA fiação de hooks do InboxView (stores zustand globais +
* socket com guard de conexão), então montar este drawer não duplica a conexão.
*/
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 { HybridBackend } from '../../services/backend';
import { useChatStore } from './store/chatStore';
import { useInstanceStore } from './store/instanceStore';
import { formatPhone } from './utils/inboxUtils';
import { nw } from './services/nwClient';
import { useNewInboxBridge } from './hooks/inbox/useNewInboxBridge';
import { useInstanceSelector } from './hooks/inbox/useInstanceSelector';
import { useChatWindow } from './hooks/inbox/useChatWindow';
import { useMessageInput } from './hooks/inbox/useMessageInput';
import InboxHeader from './components/InboxHeader';
import MessageArea from './components/MessageArea';
import InputBar from './components/InputBar';
import ContactProfile from './components/ContactProfile';
// ─── Tipos ──────────────────────────────────────────────────────────────────
export interface WhatsChatDrawerProps {
isOpen: boolean;
onClose: () => void;
pacienteId?: string | null;
pacienteNome?: string | null;
onNavigate?: (view: string) => void;
}
interface NumeroOpcao {
phone: string; // dígitos crus do cadastro
nome: string;
relacao: string; // 'Paciente' | grupofamiliarrelacao
}
// Normaliza para o formato de envio brasileiro (55 + DDD + número).
function normalizeSendPhone(input: string): string {
const digits = String(input || '').replace(/\D/g, '');
if (digits.startsWith('55') && (digits.length === 12 || digits.length === 13)) return digits;
if (digits.length === 10 || digits.length === 11) return '55' + digits;
return digits;
}
// Compara dois telefones de forma tolerante (Brasil: 9º dígito, DDI opcional):
// casa pelos últimos 8 dígitos, que são estáveis.
function samePhone(a: string, b: string): boolean {
const da = String(a || '').replace(/\D/g, '');
const db = String(b || '').replace(/\D/g, '');
if (!da || !db) return false;
return da.slice(-8) === db.slice(-8);
}
// ─── Conteúdo interno (só monta quando aberto → hooks/socket sob demanda) ──────
const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose, pacienteId, pacienteNome, onNavigate }) => {
const { activeInstance, instances, connectedInstances } = useInstanceSelector();
const loadingInstances = useInstanceStore((s) => s.loading);
// Há WhatsApp utilizável? (pelo menos uma instância conectada)
const hasConnected = connectedInstances.length > 0;
const {
selectedChat,
messages,
loadingMessages,
handleSendText,
handleLoadMoreHistory,
activeInstanceId,
} = useNewInboxBridge();
const {
scrollRef, isTyping, replyingTo, reactionPickerFor, hasMoreHistory, loadingMore,
isProfileOpen, isSyncingAvatar, isChatMenuOpen,
setReplyingTo, setIsProfileOpen, setIsChatMenuOpen,
handleTyping, handleScroll, handleSelectChat: onChatWindowSelect,
handleReactToMessage, handleSendMedia, handleSendAudio, handleSyncAvatar, toggleReactionPicker,
} = useChatWindow({
selectedChat,
activeInstanceId: activeInstance?.instance ?? null,
onLoadMoreHistory: handleLoadMoreHistory,
});
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
selectedChat,
replyingTo,
onClearReply: () => setReplyingTo(null),
onSendText: handleSendText,
});
// ── Números disponíveis (paciente + grupo familiar) ──────────────────────
const [numeros, setNumeros] = useState<NumeroOpcao[]>([]);
const [selectedPhone, setSelectedPhone] = useState<string>('');
const [numDropdownOpen, setNumDropdownOpen] = useState(false);
// ── Estado de resolução da conversa ──────────────────────────────────────
const [resolving, setResolving] = useState(false);
const [notFound, setNotFound] = useState(false); // número válido, mas sem conversa
const [initText, setInitText] = useState('');
const [initSending, setInitSending] = useState(false);
const [initError, setInitError] = useState('');
// Carrega telefone do paciente + membros da família ao abrir.
useEffect(() => {
let alive = true;
(async () => {
const opts: NumeroOpcao[] = [];
const seen = new Set<string>();
const push = (phone: string, nome: string, relacao: string) => {
const d = String(phone || '').replace(/\D/g, '');
if (!d) return;
const key = d.slice(-8);
if (seen.has(key)) return;
seen.add(key);
opts.push({ phone: d, nome, relacao });
};
// 1) Telefone do próprio paciente — via cadastro (desambigua por id).
if (pacienteNome) {
try {
const r = await HybridBackend.getPacientes(pacienteNome);
const self = (r?.data || []).find((p: any) => p.id === pacienteId)
?? (r?.data || []).find((p: any) => (p.nome || '').toUpperCase() === pacienteNome.toUpperCase());
if (self?.telefone) push(self.telefone, self.nome || pacienteNome, 'Paciente');
} catch { /* ignore */ }
}
// 2) Grupo familiar / dependentes.
if (pacienteId) {
try {
const { membros } = await HybridBackend.getFamiliaPaciente(pacienteId);
for (const m of membros || []) {
if (m.telefone) push(m.telefone, m.nome, m.grupofamiliarrelacao || (m.id === pacienteId ? 'Paciente' : 'Familiar'));
}
} catch { /* ignore */ }
}
if (!alive) return;
setNumeros(opts);
setSelectedPhone(opts[0]?.phone || '');
})();
return () => { alive = false; };
}, [pacienteId, pacienteNome]);
// Resolve a conversa (chatId) a partir do telefone selecionado, sem enviar nada.
const resolveChat = useCallback(async (phoneDigits: string) => {
const store = useChatStore.getState();
if (!activeInstanceId || !phoneDigits) { setNotFound(true); return; }
setResolving(true);
setNotFound(false);
store.setActiveChat(null);
try {
const send = normalizeSendPhone(phoneDigits);
// Busca pelos ÚLTIMOS 8 dígitos (estáveis): o WhatsApp grava o jid do BR sem o
// 9º dígito (ex.: 556799591687), então buscar o número completo (13 dígitos)
// não casaria no `jid contains` do backend. O samePhone (últimos 8) filtra.
const tail8 = send.replace(/\D/g, '').slice(-8);
await store.loadChats(activeInstanceId, { search: tail8 });
const chats = useChatStore.getState().chats;
const hit = chats.find((c) =>
samePhone(c.contactPhone || c.jid.split('@')[0], send)) ?? null;
if (hit) {
await store.selectChat(hit.id);
onChatWindowSelect();
setNotFound(false);
} else {
setNotFound(true);
}
} catch {
setNotFound(true);
} finally {
setResolving(false);
}
}, [activeInstanceId, onChatWindowSelect]);
// (Re)resolve quando muda o número selecionado ou a instância fica pronta.
useEffect(() => {
if (selectedPhone && activeInstanceId) resolveChat(selectedPhone);
}, [selectedPhone, activeInstanceId, resolveChat]);
// Inicia conversa (paciente sem chat): mesmo fluxo do NewConversationModal.
const handleIniciarConversa = useCallback(async () => {
if (!activeInstanceId || !selectedPhone) return;
if (!initText.trim()) { setInitError('Escreva uma mensagem para iniciar a conversa.'); return; }
setInitSending(true);
setInitError('');
try {
const phone = normalizeSendPhone(selectedPhone);
await nw.post('/conversations', { sessionId: activeInstanceId, phone, text: initText.trim() });
setInitText('');
// Dá um respiro para o motor registrar o chat e então re-resolve.
await new Promise((r) => setTimeout(r, 900));
await resolveChat(selectedPhone);
} catch (e: any) {
setInitError(e?.message || 'Erro ao enviar. Verifique o número e tente novamente.');
} finally {
setInitSending(false);
}
}, [activeInstanceId, selectedPhone, initText, resolveChat]);
const selectedNumero = useMemo(
() => numeros.find((n) => n.phone === selectedPhone) ?? null,
[numeros, selectedPhone]
);
const tituloContato = selectedNumero?.nome || pacienteNome || 'Paciente';
return (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
className="fixed inset-0 z-[190] bg-black/40 backdrop-blur-[2px]"
/>
{/* Painel */}
<motion.aside
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
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">
<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>
<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>
)}
</AnimatePresence>
</div>
<button onClick={onClose} className="p-1.5 hover:bg-white/20 rounded-full shrink-0" title="Fechar">
<X className="w-5 h-5" />
</button>
</div>
{/* Corpo */}
<div
className="flex-1 flex flex-col overflow-hidden relative"
style={{ backgroundImage: "url('/chat-bg.png')", backgroundRepeat: 'repeat', backgroundColor: '#b5bdb5' }}
>
<div className="absolute inset-0 bg-white/75 pointer-events-none z-0" />
{(loadingInstances && instances.length === 0) ? (
// ── Carregando estado das sessões ──────────────────────────────
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
<Loader2 className="w-8 h-8 animate-spin text-[#008069]" />
<span className="text-sm">Verificando conexão do WhatsApp</span>
</div>
) : !hasConnected ? (
// ── Nenhuma sessão de WhatsApp conectada ────────────────────────
<div className="flex-1 flex flex-col items-center justify-center gap-5 relative z-[1] p-8 text-center">
<div className="relative">
<div className="w-20 h-20 rounded-full bg-[#008069]/10 flex items-center justify-center">
<WifiOff className="w-9 h-9 text-[#008069]" />
</div>
<span className="absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-white shadow-md flex items-center justify-center">
<MessageCircle className="w-4 h-4 text-[#008069]" />
</span>
</div>
<div className="max-w-xs">
<h3 className="text-lg font-bold text-[#111b21]">
{instances.length === 0 ? 'WhatsApp não conectado' : 'Sessão desconectada'}
</h3>
<p className="text-[13px] text-[#667781] leading-relaxed mt-1.5">
{instances.length === 0
? 'Nenhuma conta de WhatsApp está vinculada a esta clínica. Conecte um número para conversar com os pacientes direto da agenda.'
: 'A sessão do WhatsApp está fora do ar no momento. Reconecte o número lendo o QR Code para retomar as conversas.'}
</p>
</div>
{onNavigate && (
<button
onClick={() => { onClose(); onNavigate('wa-sessions'); }}
className="flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold text-white bg-[#008069] hover:bg-[#017259] transition-all active:scale-[0.98] shadow-sm"
>
<QrCode className="w-4 h-4" />
{instances.length === 0 ? 'Conectar WhatsApp' : 'Reconectar sessão'}
</button>
)}
</div>
) : resolving ? (
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
<Loader2 className="w-8 h-8 animate-spin text-[#008069]" />
<span className="text-sm">Abrindo conversa</span>
</div>
) : !selectedPhone ? (
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] p-8 text-center">
<Phone className="w-12 h-12 opacity-10" />
<p className="text-sm text-[#667781]">Este paciente não tem telefone no cadastro.</p>
</div>
) : notFound ? (
// ── Sem conversa: iniciar uma nova ──────────────────────────────
<div className="flex-1 flex flex-col items-center justify-center gap-4 relative z-[1] p-6">
<div className="w-14 h-14 rounded-full bg-[#008069]/10 flex items-center justify-center">
<MessageCircle className="w-7 h-7 text-[#008069]" />
</div>
<div className="text-center">
<p className="text-sm font-bold text-[#111b21]">Sem conversa com {tituloContato}</p>
<p className="text-[12px] text-[#667781] mt-0.5">{formatPhone(normalizeSendPhone(selectedPhone))}</p>
</div>
<div className="w-full max-w-sm space-y-2">
<textarea
rows={3}
value={initText}
onChange={(e) => { setInitText(e.target.value); setInitError(''); }}
placeholder="Olá! Tudo bem? Passando para confirmar seu horário…"
disabled={initSending}
className="w-full bg-white rounded-xl px-4 py-3 text-sm text-[#111b21] placeholder:text-[#8696a0] focus:outline-none focus:ring-2 focus:ring-[#008069]/40 resize-none disabled:opacity-60 border border-gray-100"
/>
{initError && <p className="text-[12px] text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{initError}</p>}
<button
onClick={handleIniciarConversa}
disabled={initSending || !initText.trim()}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-sm font-semibold text-white bg-[#008069] hover:bg-[#017259] transition-all active:scale-[0.98] disabled:opacity-50"
>
{initSending
? <><Loader2 className="w-4 h-4 animate-spin" /> Enviando</>
: <><Send className="w-4 h-4" /> Iniciar conversa</>}
</button>
</div>
</div>
) : selectedChat ? (
// ── Conversa ativa ──────────────────────────────────────────────
<div className="flex-1 flex h-full relative z-[1] overflow-hidden">
<div className="flex-1 flex flex-col h-full overflow-hidden">
<InboxHeader
selectedChat={selectedChat}
isMobile={true}
isTyping={isTyping}
onAvatarClick={() => setIsProfileOpen(!isProfileOpen)}
onBack={onClose}
onSyncAvatar={handleSyncAvatar}
isChatMenuOpen={isChatMenuOpen}
isSyncingAvatar={isSyncingAvatar}
onSearchClick={() => {}}
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
/>
<MessageArea
messages={messages as any}
selectedChat={selectedChat}
scrollRef={scrollRef}
msgLoading={loadingMessages}
onScroll={handleScroll}
historyLoadingMore={loadingMore}
hasMoreHistory={hasMoreHistory}
reactionPickerFor={reactionPickerFor}
onReply={setReplyingTo}
onToggleReactionPicker={toggleReactionPicker}
onReact={handleReactToMessage}
onForward={() => {}}
onDelete={() => {}}
onEdit={() => {}}
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
getGroupSenderLabel={(msg: any) => {
if (msg.direction === 'out') return 'Você';
if (msg.participant) return msg.participant;
if (msg.senderJid) {
if (msg.senderJid.endsWith('@lid')) return msg.participant || '';
const phone = msg.senderJid.split('@')[0].split(':')[0];
return formatPhone(phone, msg.senderJid);
}
return '';
}}
/>
<InputBar
newMessage={newMessage}
onNewMessageChange={setNewMessage}
onSend={handleSendMessage}
onPresence={handleTyping}
onCancelReply={() => setReplyingTo(null)}
replyingTo={replyingTo}
selectedChat={selectedChat}
isMobile={true}
onSendMedia={handleSendMedia}
onSendAudio={handleSendAudio}
instanceId={activeInstance?.instance ?? null}
mentions={mentions}
onMentionsChange={setMentions}
/>
</div>
<ContactProfile
isOpen={isProfileOpen}
chat={selectedChat}
messages={messages as any}
onClose={() => setIsProfileOpen(false)}
/>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
)}
</div>
</motion.aside>
</>
);
};
// ─── Wrapper: só monta o conteúdo (e os hooks) quando aberto ──────────────────
export const WhatsChatDrawer: React.FC<WhatsChatDrawerProps> = ({ isOpen, ...rest }) => (
<AnimatePresence>
{isOpen && <DrawerInner {...rest} />}
</AnimatePresence>
);
export default WhatsChatDrawer;
@@ -0,0 +1,67 @@
'use client';
import React from 'react';
import { MessageCircle } from 'lucide-react';
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
import ChatListItem from './ChatListItem';
interface ChatListProps {
chats: NewWhatsChat[];
loading: boolean;
selectedChat: NewWhatsChat | null;
onSelectChat: (chat: NewWhatsChat) => void;
onMenuAction: (action: any, chat: NewWhatsChat) => void;
presenceByJid: Record<string, PresenceState>;
isFavoriteChat: (chat: NewWhatsChat) => boolean;
drafts?: Record<string, string>;
canDelete?: boolean;
}
export default function ChatList({
chats,
loading,
selectedChat,
onSelectChat,
onMenuAction,
presenceByJid,
isFavoriteChat,
drafts = {},
canDelete = true,
}: ChatListProps) {
if (loading) {
return (
<div className="flex items-center justify-center h-40">
<div className="w-8 h-8 border-3 border-[#00a884] border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
if (chats.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 px-10 text-center">
<div className="w-16 h-16 bg-[#f0f2f5] rounded-full flex items-center justify-center mb-4">
<MessageCircle className="w-8 h-8 text-[#8696a0]" />
</div>
<p className="text-[#667781] text-sm font-normal">Nenhuma conversa encontrada</p>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto bg-white custom-scrollbar">
{chats.map((chat) => (
<ChatListItem
key={`${chat.instance_name}-${chat.remote_jid}`}
chat={chat}
isActive={selectedChat?.id === chat.id}
onSelect={onSelectChat}
onMenuAction={onMenuAction}
presenceByJid={presenceByJid}
isFavorite={isFavoriteChat(chat)}
draft={drafts[chat.remote_jid]}
canDelete={canDelete}
/>
))}
</div>
);
}
@@ -0,0 +1,294 @@
'use client';
import React, { useMemo, useRef, useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
Pin, Check, CheckCheck, Clock, ChevronDown,
VolumeX, Star, StarOff, Archive, ArchiveRestore,
Trash2, MailOpen, Volume2, PinOff,
Mic, FileText, Video, Image as ImageIcon,
AlertCircle, Bot,
} from 'lucide-react';
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
import {
normalizeJid,
} from '../utils/inboxUtils';
import Avatar from './ui/Avatar';
interface ChatListItemProps {
chat: NewWhatsChat;
isActive: boolean;
onSelect: (chat: NewWhatsChat) => void;
onMenuAction: (action: any, chat: NewWhatsChat) => void;
presenceByJid: Record<string, PresenceState>;
isFavorite: boolean;
draft?: string;
canDelete?: boolean; // false esconde "Apagar conversa" (sem permissão)
}
const renderLastStatus = (chat: NewWhatsChat) => {
if (chat.last_message_from_me !== 1) return null;
switch (chat.last_message_status) {
case 'pending':
case 'sending':
return <Clock className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
case 'sent':
return <Check className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
case 'delivered':
return <CheckCheck className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
case 'read':
return <CheckCheck className="w-3.5 h-3.5 text-[#53bdeb] flex-shrink-0" />;
case 'failed':
return <AlertCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />;
default:
return null;
}
};
const getLastMessageSnippet = (chat: NewWhatsChat, draft?: string): React.ReactNode => {
if (draft) return <span className="text-[#00a884]">Rascunho: {draft}</span>;
const truncate = (value: string, limit = 35) => value.length > limit ? `${value.slice(0, limit - 1)}...` : value;
// Group Sender Prefix — WhatsApp exibe "Você:" ou primeiro nome do remetente
const isGroup = chat.remote_jid.endsWith('@g.us');
const senderPrefix = isGroup
? (chat.last_message_from_me === 1
? 'Você: '
: (chat.last_message_sender ? `${chat.last_message_sender.split(' ')[0]}: ` : ''))
: '';
const IconLabel = ({ icon: Icon, label }: { icon: any, label: string }) => (
<span className="flex items-center gap-1">
<Icon className="w-3.5 h-3.5" />
{senderPrefix}{label}
</span>
);
// Use last_message_type (pipeline do chatStore) para ícones de mídia
const msgType = (chat.last_message_type ?? '').toLowerCase();
if (msgType === 'audio' || msgType === 'ptt') return <IconLabel icon={Mic} label="Áudio" />;
if (msgType === 'sticker') return <IconLabel icon={Star} label="Figurinha" />;
if (msgType === 'image') {
const caption = chat.last_message_text?.trim();
return caption
? <IconLabel icon={ImageIcon} label={truncate(caption)} />
: <IconLabel icon={ImageIcon} label="Imagem" />;
}
if (msgType === 'video') {
const caption = chat.last_message_text?.trim();
return caption
? <IconLabel icon={Video} label={truncate(caption)} />
: <IconLabel icon={Video} label="Vídeo" />;
}
if (msgType === 'document') return <IconLabel icon={FileText} label={chat.last_message_text?.trim() || 'Documento'} />;
// Texto puro (text, extendedtext, etc.)
const text = chat.last_message_text ?? '';
if (text.trim()) return `${senderPrefix}${truncate(text.trim())}`;
return senderPrefix || '';
};
export default function ChatListItem({
chat,
isActive,
onSelect,
onMenuAction,
presenceByJid,
isFavorite,
draft,
canDelete = true
}: ChatListItemProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
const menuBtnRef = useRef<HTMLButtonElement>(null);
const relativeTime = useMemo(() => {
if (!chat.last_message_time) return '';
const date = new Date(chat.last_message_time);
if (isNaN(date.getTime())) return '';
// Comparação por data de calendário (não por 24h corridas)
const today = new Date(); today.setHours(0, 0, 0, 0);
const msgDay = new Date(date); msgDay.setHours(0, 0, 0, 0);
const diffDays = Math.round((today.getTime() - msgDay.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
if (diffDays === 1) return 'Ontem';
if (diffDays < 7) {
// "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"
const short = date.toLocaleDateString('pt-BR', { weekday: 'short' });
return short.replace('.', '').replace(/^\w/, (c) => c.toUpperCase());
}
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' });
}, [chat.last_message_time]);
const isMuted = useMemo(() => {
return Boolean(chat.muted_until && new Date(chat.muted_until).getTime() > Date.now());
}, [chat.muted_until]);
const menuItems = useMemo(() => {
const isPinned = Boolean(chat.is_pinned);
const isArchived = Boolean(chat.is_archived);
const isGroup = chat.remote_jid.endsWith('@g.us');
return [
{ key: 'favorite', label: isFavorite ? 'Remover favorito' : 'Favoritar', icon: isFavorite ? <StarOff className="w-4 h-4" /> : <Star className="w-4 h-4" /> },
{ key: 'archive', label: isArchived ? 'Desarquivar' : 'Arquivar', icon: isArchived ? <ArchiveRestore className="w-4 h-4" /> : <Archive className="w-4 h-4" /> },
{ key: 'mute', label: isMuted ? 'Desativar silêncio' : 'Silenciar', icon: isMuted ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" /> },
{ key: 'pin', label: isPinned ? 'Desafixar' : 'Fixar conversa', icon: isPinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" /> },
{ key: 'mark_read', label: 'Marcar como lida', icon: <MailOpen className="w-4 h-4" /> },
...(canDelete ? [{ key: 'delete', label: 'Apagar conversa', icon: <Trash2 className="w-4 h-4" />, danger: true }] : []),
];
}, [chat, isFavorite, isMuted, canDelete]);
const MENU_H = 252; // 6 itens × ~40px + 12px padding container
const MENU_W = 224; // w-56
const openMenu = (anchorX: number, anchorY: number, anchorBottom?: number) => {
const triggerBottom = anchorBottom ?? anchorY;
const spaceBelow = window.innerHeight - triggerBottom;
// Abre abaixo se couber; senão ancora o BOTTOM do menu no TOP do trigger
const top = spaceBelow >= MENU_H + 8
? triggerBottom + 4
: anchorY - MENU_H;
const left = Math.min(anchorX, window.innerWidth - MENU_W - 8);
setMenuPos({ top: Math.max(8, top), left: Math.max(8, left) });
setMenuOpen(true);
};
return (
<>
{/* Usamos div+role="button" em vez de <button> porque dentro há outro
<button> (o menu chevron) aninhar botões é inválido em HTML e
dispara validateDOMNesting warning no React. */}
<div
role="button"
tabIndex={0}
onClick={() => onSelect(chat)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(chat);
}
}}
onContextMenu={(e) => {
e.preventDefault();
// Abre à direita do cursor se couber, senão à esquerda
const spaceRight = window.innerWidth - e.clientX;
const x = spaceRight >= MENU_W + 8 ? e.clientX : e.clientX - MENU_W;
// Passa anchorY = cursor Y, anchorBottom = cursor Y (sem item height)
openMenu(Math.max(8, x), e.clientY, e.clientY);
}}
className={`w-full flex items-center gap-3 px-3 py-2 transition-all text-[#111b21] group border-b border-[#f0f2f5] last:border-none cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[#00a884]/40 ${isActive ? 'bg-[#f0f2f5]' : 'hover:bg-[#f5f6f6]'}`}
>
{/* Avatar Section */}
<Avatar chat={chat} className="flex-shrink-0" />
{/* Content Section */}
<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}
</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>
<div className="flex items-center justify-between mt-1 h-5">
<div className="flex items-center gap-1 flex-1 min-w-0">
{(() => {
const jid = normalizeJid(chat.remote_jid);
const p = presenceByJid[jid];
const isPresenceLive = p && (Date.now() - p.ts) <= 6000;
if (isPresenceLive && (p.state === 'composing' || p.state === 'recording')) {
return (
<p className="text-[13px] text-[#00a884] font-normal truncate">
{p.state === 'recording' ? 'gravando áudio...' : 'digitando...'}
</p>
);
}
return (
<div className="flex items-center gap-1 min-w-0">
{!draft && renderLastStatus(chat)}
<div className={`text-[13px] leading-[20px] truncate max-w-full ${draft ? 'text-[#00a884]' : 'text-[#667781]'}`}>
{getLastMessageSnippet(chat, draft)}
</div>
</div>
);
})()}
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-1">
{chat.bot_paused && (
<span title="Bot pausado — aguardando atendente">
<Bot className="w-4 h-4 text-amber-500" />
</span>
)}
{isMuted && <VolumeX className="w-4 h-4 text-[#8696a0]" />}
{Boolean(chat.is_pinned) && <Pin className="w-4 h-4 text-[#8696a0]" />}
{chat.unread_count > 0 && (
<span className="bg-[#00a884] text-white text-[12px] font-bold px-1.5 py-0.5 rounded-full min-w-[20px] h-5 flex items-center justify-center">
{chat.unread_count > 99 ? '99+' : chat.unread_count}
</span>
)}
{/* Hover Chevron for Menu */}
<div className="relative group-hover:block hidden">
<button
ref={menuBtnRef}
className="p-1 rounded-full hover:bg-black/5"
onClick={(e) => {
e.stopPropagation();
const rect = menuBtnRef.current?.getBoundingClientRect();
if (rect) {
openMenu(rect.right - MENU_W, rect.top, rect.bottom);
}
}}
>
<ChevronDown className="w-4 h-4 text-[#8696a0]" />
</button>
</div>
</div>
</div>
</div>
</div>
{/* Context Menu Portal (fora do item pra não ficar dentro do role=button) */}
{menuOpen && menuPos && typeof window !== 'undefined' && createPortal(
<>
<div className="fixed inset-0 z-[1000]" onClick={(e) => { e.stopPropagation(); setMenuOpen(false); }} />
<div
className="fixed bg-white py-1.5 rounded-lg shadow-xl border border-gray-100 z-[1001] w-56 flex flex-col"
style={{ top: menuPos.top, left: menuPos.left }}
onClick={(e) => e.stopPropagation()}
>
{menuItems.map((item: any) => (
<button
key={item.key}
onClick={() => {
onMenuAction(item.key, chat);
setMenuOpen(false);
}}
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left ${
item.danger ? 'text-red-600 hover:bg-red-50' : 'text-[#3b4a54] hover:bg-[#f5f6f6]'
}`}
>
<span className={item.danger ? 'text-red-500' : 'text-[#8696a0]'}>{item.icon}</span>
{item.label}
</button>
))}
</div>
</>,
document.body
)}
</>
);
}
@@ -0,0 +1,110 @@
'use client';
import React, { useMemo, useState } from 'react';
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
import ChatList from './ChatList';
interface ChatSidebarProps {
chats: NewWhatsChat[];
loading: boolean;
selectedChat: NewWhatsChat | null;
searchTerm: string;
selectedLabelFilter: string | null;
activeFilter: string;
chatScope: 'inbox' | 'favorites' | 'archived' | 'custom_list';
activeCustomListName?: string | null;
presenceByJid: Record<string, PresenceState>;
isFavoriteChat: (chat: NewWhatsChat) => boolean;
onSelectChat: (chat: NewWhatsChat) => void;
onMenuAction: (action: any, chat: NewWhatsChat) => void;
drafts?: Record<string, string>;
canDelete?: boolean;
}
export default function ChatSidebar({
chats,
loading,
selectedChat,
searchTerm,
selectedLabelFilter,
activeFilter,
chatScope,
activeCustomListName,
presenceByJid,
isFavoriteChat,
onSelectChat,
onMenuAction,
drafts = {},
canDelete = true,
}: ChatSidebarProps) {
// UI Pills (Simplified, for now we keep layout consistent)
const [pillFilter, setPillFilter] = useState<'all' | 'unread' | 'favorites' | 'groups'>('all');
const filteredChats = useMemo(() => chats.filter((chat) => {
const isGroup = chat.remote_jid.endsWith('@g.us');
// Filter by pill
if (pillFilter === 'unread' && chat.unread_count === 0) return false;
if (pillFilter === 'groups' && !isGroup) return false;
if (pillFilter === 'favorites' && !isFavoriteChat(chat)) return false;
const displayName = chat.displayName || chat.subject || chat.phone || chat.remote_jid;
const matchesSearch = displayName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(chat.last_message_text || '').toLowerCase().includes(searchTerm.toLowerCase());
const matchesLabel = !selectedLabelFilter ||
(chat.labels && chat.labels.some((l) => l.label_id === selectedLabelFilter));
return matchesSearch && matchesLabel;
}), [chats, searchTerm, selectedLabelFilter, pillFilter, isFavoriteChat]);
const sortedFilteredChats = useMemo(
() => [...filteredChats].sort((a, b) => {
const aPinned = Boolean(a.is_pinned);
const bPinned = Boolean(b.is_pinned);
if (aPinned !== bPinned) return aPinned ? -1 : 1;
const tA = a.last_message_time ? new Date(a.last_message_time).getTime() : 0;
const tB = b.last_message_time ? new Date(b.last_message_time).getTime() : 0;
return tB - tA;
}),
[filteredChats]
);
return (
<div className="flex flex-col h-full bg-white border-r border-[#e9edef] overflow-hidden">
{/* Filter Pills */}
<div className="px-3 py-2 flex items-center gap-2 overflow-x-auto no-scrollbar flex-shrink-0 border-b border-[#f0f2f5]">
{(['all', 'unread', 'favorites', 'groups'] as const).map((filter) => (
<button
key={filter}
onClick={() => setPillFilter(filter)}
className={`px-3 py-1 rounded-full text-sm whitespace-nowrap transition-colors ${
pillFilter === filter
? 'bg-[#00a884] text-white'
: 'bg-[#f0f2f5] text-[#54656f] hover:bg-[#e9edef]'
}`}
>
{filter === 'all' && 'Tudo'}
{filter === 'unread' && 'Não lidas'}
{filter === 'favorites' && 'Favoritos'}
{filter === 'groups' && 'Grupos'}
</button>
))}
</div>
{/* Scrollable Chat List */}
<ChatList
chats={sortedFilteredChats}
loading={loading}
selectedChat={selectedChat}
onSelectChat={onSelectChat}
onMenuAction={onMenuAction}
presenceByJid={presenceByJid}
isFavoriteChat={isFavoriteChat}
drafts={drafts}
canDelete={canDelete}
/>
</div>
);
}
@@ -0,0 +1,173 @@
'use client';
import React, { useMemo } from 'react';
import {
X,
ChevronRight,
Image as ImageIcon,
Video as VideoIcon,
FileText,
Mic,
Bell,
Star,
Clock,
Ban,
ThumbsDown,
Trash2
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
formatPhone,
getMediaUrl
} from '../utils/inboxUtils';
import Avatar from './ui/Avatar';
import { Message } from '../types/inboxTypes';
interface ContactProfileProps {
isOpen: boolean;
chat: any;
messages: Message[];
onClose: () => void;
}
const ContactProfile: React.FC<ContactProfileProps> = ({
isOpen,
chat,
messages,
onClose,
}) => {
// Filter media messages
const mediaMessages = useMemo(() => {
return messages.filter(m => m.type === 'image' || m.type === 'video' || m.type === 'document' || m.type === 'audio');
}, [messages]);
const photoVideos = mediaMessages.filter(m => m.type === 'image' || m.type === 'video');
const documents = mediaMessages.filter(m => m.type === 'document');
const audios = mediaMessages.filter(m => m.type === 'audio');
if (!chat) return null;
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-[400px] h-full bg-[#f0f2f5] border-l border-[#d1d7db] flex flex-col z-40 relative flex-shrink-0"
>
{/* Header */}
<div className="h-[60px] px-4 bg-white flex items-center gap-6 border-b border-[#d1d7db] shrink-0">
<button
onClick={onClose}
className="p-2 hover:bg-black/5 rounded-full transition-colors"
>
<X className="w-6 h-6 text-[#54656f]" />
</button>
<h2 className="text-[16px] font-normal text-[#111b21]">Dados do contato</h2>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar pb-10">
{/* Profile Section */}
<div className="bg-white px-8 py-8 flex flex-col items-center mb-2 shadow-sm">
{/* 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)}
</h1>
<p className="text-[16px] text-[#667781] font-normal mb-1">
{formatPhone(chat.phone, chat.remote_jid)}
</p>
{chat.bio && (
<p className="text-[14px] text-[#667781] font-normal mt-2 text-center max-w-xs">
{chat.bio}
</p>
)}
</div>
{/* Status (Bio) */}
<div className="bg-white px-8 py-4 mb-2 shadow-sm">
<h3 className="text-[14px] text-[#667781] font-normal mb-3">Recado</h3>
<p className="text-[16px] text-[#111b21] font-normal leading-relaxed">
{chat.bio || 'Sem recado disponível'}
</p>
</div>
{/* Media Section */}
<div className="bg-white mb-2 shadow-sm">
<button className="w-full px-8 py-4 flex items-center justify-between hover:bg-[#f5f6f6] transition-colors group">
<span className="text-[14px] text-[#667781] font-normal">Mídia, links e documentos</span>
<div className="flex items-center gap-1">
<span className="text-[14px] text-[#667781] font-normal">{mediaMessages.length}</span>
<ChevronRight className="w-5 h-5 text-[#8696a0] group-hover:translate-x-0.5 transition-transform" />
</div>
</button>
{mediaMessages.length > 0 ? (
<div className="px-8 pb-4 grid grid-cols-3 gap-1">
{photoVideos.slice(0, 3).map((m, i) => (
<div
key={m.id || i}
className="aspect-square bg-gray-100 rounded-sm overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const url = getMediaUrl(m.media_url);
if (url) window.open(url, '_blank');
}}
>
<div className="w-full h-full flex items-center justify-center bg-black/10">
{m.type === 'video' ? <VideoIcon className="w-6 h-6 text-white" /> : <ImageIcon className="w-6 h-6 text-gray-400" />}
</div>
</div>
))}
{mediaMessages.length > 3 && (
<div className="aspect-square flex items-center justify-center bg-gray-50 border border-dashed border-gray-200 text-gray-400 text-xs rounded-sm">
+{mediaMessages.length - 3}
</div>
)}
</div>
) : (
<div className="px-8 pb-6 flex flex-col items-center justify-center text-center">
<p className="text-[14px] text-[#667781] italic">Nenhuma mídia compartilhada</p>
</div>
)}
</div>
{/* Actions Section */}
<div className="bg-white mb-2 shadow-sm py-2">
{[
{ icon: Bell, label: 'Silenciar notificações', color: '#111b21' },
{ icon: Star, label: 'Mensagens favoritas', color: '#111b21' },
{ icon: Clock, label: 'Mensagens temporárias', sub: 'Desativadas', color: '#111b21' },
].map((item, idx) => (
<button key={idx} className="w-full px-8 py-3.5 flex items-center gap-6 hover:bg-[#f5f6f6] transition-colors text-left">
<item.icon className="w-5 h-5 text-[#8696a0]" />
<div className="flex flex-col">
<span className="text-[16px] text-[#111b21] font-normal leading-none">{item.label}</span>
{item.sub && <span className="text-[12px] text-[#667781] mt-1">{item.sub}</span>}
</div>
</button>
))}
</div>
<div className="bg-white mb-2 shadow-sm py-2">
{[
{ icon: Ban, label: `Bloquear ${chat.lead_name || 'Contato'}`, color: '#ea0038' },
{ icon: ThumbsDown, label: `Denunciar ${chat.lead_name || 'Contato'}`, color: '#ea0038' },
{ icon: Trash2, label: 'Apagar conversa', color: '#ea0038' }
].map((item, idx) => (
<button key={idx} className="w-full px-8 py-3.5 flex items-center gap-6 hover:bg-[#f5f6f6] transition-colors text-left">
<item.icon className="w-5 h-5" style={{ color: item.color }} />
<span className="text-[16px] font-normal" style={{ color: item.color }}>{item.label}</span>
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
};
export default ContactProfile;
@@ -0,0 +1,244 @@
'use client';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Send, FileText, Film, Music, Archive, File, ZoomIn, ZoomOut } from 'lucide-react';
interface FilePreviewModalProps {
file: File | null;
onConfirm: (file: File, caption: string) => Promise<void>;
onCancel: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getFileIcon(type: string) {
if (type.startsWith('video/')) return <Film className="w-16 h-16 text-blue-400" />;
if (type.startsWith('audio/')) return <Music className="w-16 h-16 text-purple-400" />;
if (type.includes('pdf') || type.includes('document') || type.includes('word'))
return <FileText className="w-16 h-16 text-red-400" />;
if (type.includes('zip') || type.includes('compressed') || type.includes('tar'))
return <Archive className="w-16 h-16 text-yellow-400" />;
return <File className="w-16 h-16 text-[#8696a0]" />;
}
function getFileLabel(type: string): string {
if (type.startsWith('image/')) return 'Imagem';
if (type.startsWith('video/')) return 'Vídeo';
if (type.startsWith('audio/')) return 'Áudio';
if (type.includes('pdf')) return 'PDF';
return 'Arquivo';
}
function ModalContent({ file, onConfirm, onCancel }: FilePreviewModalProps & { file: File }) {
const [caption, setCaption] = useState('');
const [sending, setSending] = useState(false);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [zoomed, setZoomed] = useState(false);
// Posição do mouse como % dentro da área da imagem (para o zoom seguir o cursor)
const [mouseOrigin, setMouseOrigin] = useState({ x: 50, y: 50 });
const captionRef = useRef<HTMLInputElement>(null);
const previewAreaRef = useRef<HTMLDivElement>(null);
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
useEffect(() => {
const url = URL.createObjectURL(file);
setObjectUrl(url);
setCaption('');
setSending(false);
setZoomed(false);
setMouseOrigin({ x: 50, y: 50 });
setTimeout(() => captionRef.current?.focus(), 250);
return () => URL.revokeObjectURL(url);
}, [file]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onCancel]);
const handleSend = useCallback(async () => {
if (sending) return;
setSending(true);
try { await onConfirm(file, caption.trim()); }
finally { setSending(false); }
}, [file, caption, sending, onConfirm]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { e.preventDefault(); handleSend(); }
};
// Atualiza a origem do zoom conforme o mouse se move na área de preview
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isImage) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
setMouseOrigin({ x, y });
}, [isImage]);
const toggleZoom = useCallback(() => setZoomed(z => !z), []);
return (
<motion.div
key="file-preview-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="fixed inset-0 flex flex-col bg-[#0b141a]"
style={{ zIndex: 99999 }}
>
{/* ── Top bar ── */}
<div className="flex items-center gap-3 px-4 py-3 bg-[#202c33] shrink-0 shadow-md">
{/* Info do arquivo — lado esquerdo */}
<div className="flex-1 min-w-0">
<p className="text-[#e9edef] text-sm font-medium truncate">{file.name}</p>
<p className="text-[#8696a0] text-xs">{getFileLabel(file.type)} · {formatFileSize(file.size)}</p>
</div>
{/* Botões — lado direito: zoom (se imagem) → fechar */}
<div className="flex items-center gap-1 shrink-0">
{isImage && (
<button
onClick={toggleZoom}
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
title={zoomed ? 'Zoom normal' : 'Ampliar'}
>
{zoomed ? <ZoomOut className="w-5 h-5" /> : <ZoomIn className="w-5 h-5" />}
</button>
)}
<button
onClick={onCancel}
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
title="Fechar"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* ── Preview area ── */}
<div
ref={previewAreaRef}
className="flex-1 flex items-center justify-center min-h-0 overflow-hidden p-4"
onMouseMove={handleMouseMove}
onClick={isImage ? toggleZoom : undefined}
style={{ cursor: isImage ? (zoomed ? 'zoom-out' : 'zoom-in') : 'default' }}
>
{isImage && objectUrl && (
<motion.img
src={objectUrl}
alt="preview"
initial={{ scale: 0.94, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.22 }}
draggable={false}
className="select-none rounded-lg shadow-2xl"
style={zoomed ? {
// Zoom 2.5× centrado no cursor
transform: `scale(2.5)`,
transformOrigin: `${mouseOrigin.x}% ${mouseOrigin.y}%`,
transition: 'transform-origin 0s', // origin segue o mouse instantaneamente
maxWidth: '100%',
maxHeight: 'calc(100vh - 160px)',
objectFit: 'contain',
cursor: 'zoom-out',
} : {
maxWidth: '100%',
maxHeight: 'calc(100vh - 160px)',
objectFit: 'contain',
cursor: 'zoom-in',
transition: 'transform 0.2s ease',
transform: 'scale(1)',
transformOrigin: 'center center',
}}
/>
)}
{isVideo && objectUrl && (
<motion.video
src={objectUrl}
controls
initial={{ scale: 0.94, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.22 }}
className="max-w-full max-h-full rounded-lg shadow-2xl"
style={{ maxHeight: 'calc(100vh - 160px)' }}
/>
)}
{!isImage && !isVideo && (
<motion.div
initial={{ scale: 0.92, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.22 }}
className="flex flex-col items-center gap-5 bg-[#202c33] rounded-2xl px-14 py-12 shadow-2xl max-w-sm w-full"
>
<div className="w-24 h-24 rounded-2xl bg-[#2a3942] flex items-center justify-center">
{getFileIcon(file.type)}
</div>
<div className="text-center space-y-1">
<p className="text-[#e9edef] text-sm font-medium break-all">{file.name}</p>
<p className="text-[#8696a0] text-xs">{formatFileSize(file.size)}</p>
</div>
</motion.div>
)}
</div>
{/* ── Caption + Send bar ── */}
<div className="shrink-0 bg-[#202c33] px-4 py-3 flex items-center gap-3 shadow-[0_-1px_0_rgba(255,255,255,0.06)]">
<input
ref={captionRef}
type="text"
placeholder="Adicionar legenda…"
value={caption}
onChange={(e) => setCaption(e.target.value)}
onKeyDown={handleKeyDown}
disabled={sending}
maxLength={1024}
className="flex-1 bg-[#2a3942] text-[#e9edef] placeholder:text-[#8696a0] text-sm rounded-full px-5 py-2.5 outline-none border-none focus:ring-0 disabled:opacity-50 transition-opacity"
/>
<button
onClick={handleSend}
disabled={sending}
className="w-12 h-12 bg-[#00a884] hover:bg-[#00c49a] disabled:opacity-60 rounded-full flex items-center justify-center transition-all shadow-lg active:scale-95 shrink-0"
>
{sending
? <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Send className="w-5 h-5 text-white translate-x-px" />
}
</button>
</div>
</motion.div>
);
}
export default function FilePreviewModal({ file, onConfirm, onCancel }: FilePreviewModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{file && (
<ModalContent
file={file}
onConfirm={onConfirm}
onCancel={onCancel}
/>
)}
</AnimatePresence>,
document.body
);
}
@@ -0,0 +1,159 @@
'use client';
import React from 'react';
import {
ArrowLeft,
Search,
MoreVertical,
RefreshCw,
Tag,
X,
ClipboardList,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
formatPhone,
} from '../utils/inboxUtils';
import Avatar from './ui/Avatar';
interface InboxHeaderProps {
selectedChat: any;
isMobile: boolean;
isTyping: boolean;
isChatMenuOpen: boolean;
isSyncingAvatar: boolean;
isProtocolOpen?: boolean;
hasActiveProtocol?: boolean;
onBack: () => void;
onSearchClick: () => void;
onToggleMenu: () => void;
onSyncAvatar: () => void;
onAvatarClick?: () => void;
onProtocolClick?: () => void;
}
const InboxHeader: React.FC<InboxHeaderProps> = ({
selectedChat,
isMobile,
isTyping,
isChatMenuOpen,
isSyncingAvatar,
isProtocolOpen,
hasActiveProtocol,
onBack,
onSearchClick,
onToggleMenu,
onSyncAvatar,
onAvatarClick,
onProtocolClick,
}) => {
return (
<header className="h-[60px] px-4 bg-[#f0f2f5] flex items-center justify-between border-b border-[#d1d7db] z-30 relative shrink-0">
<div className="flex items-center gap-3 flex-1 min-w-0">
{isMobile && (
<button
onClick={onBack}
className="p-1 -ml-1 mr-1 hover:bg-black/5 rounded-full transition-colors active:scale-90"
>
<ArrowLeft className="w-6 h-6 text-[#54656f]" />
</button>
)}
{/* Identity Area - Clickable */}
<div
onClick={onAvatarClick}
className="flex items-center gap-3 min-w-0 cursor-pointer group/header hover:bg-black/5 px-2 py-1 -ml-2 rounded-md transition-all active:scale-[0.98]"
>
{/* Avatar */}
<Avatar chat={selectedChat} size={40} className="flex-shrink-0" />
<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)}
</h3>
<div className="flex items-center gap-1 min-w-0">
{isTyping ? (
<span className="text-[13px] text-[#00a884] font-medium animate-pulse">
digitando...
</span>
) : (
<span className="text-[12.5px] text-[#667781] truncate max-w-[300px] font-normal">
{selectedChat.bio || (
selectedChat.remote_jid?.endsWith('@g.us')
? `Clique para dados do grupo`
: 'Clique para informações do contato'
)}
</span>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-1">
{onProtocolClick && (
<button
onClick={onProtocolClick}
className={`p-2 rounded-full transition-all relative ${isProtocolOpen ? 'bg-black/10 text-[#111b21]' : 'hover:bg-black/5 text-[#54656f] hover:text-[#111b21]'}`}
title="Protocolos de atendimento"
>
<ClipboardList className="w-5 h-5" />
{hasActiveProtocol && !isProtocolOpen && (
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-brand-500 border border-white" />
)}
</button>
)}
<button
onClick={onSearchClick}
className="p-2 hover:bg-black/5 rounded-full transition-all text-[#54656f] hover:text-[#111b21]"
title="Procurar na conversa"
>
<Search className="w-5 h-5" />
</button>
<div className="relative">
<button
onClick={onToggleMenu}
className={`p-2 rounded-full transition-all ${isChatMenuOpen ? 'bg-black/10 text-[#111b21]' : 'hover:bg-black/5 text-[#54656f]'}`}
>
<MoreVertical className="w-5 h-5" />
</button>
<AnimatePresence>
{isChatMenuOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="absolute right-0 top-12 w-56 bg-white rounded-lg shadow-2xl border border-gray-100 py-2 z-50 overflow-hidden"
>
<button
onClick={() => {
onSyncAvatar();
onToggleMenu();
}}
disabled={isSyncingAvatar}
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors disabled:opacity-50"
>
<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">
<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>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</header>
);
};
export default InboxHeader;
@@ -0,0 +1,722 @@
'use client';
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import {
Smile,
Paperclip,
Mic,
Send,
X,
StopCircle,
Pause,
Play,
LayoutTemplate,
BookOpen,
Plus,
Sticker,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
// Satélite: emoji picker simples (evita a dep @emoji-mart, incompatível com react 19).
const EMOJI_SET = ['😀','😁','😂','🤣','😊','😍','😘','😎','🤔','😐','😴','😭','😡','👍','👎','🙏','👏','🙌','💪','🔥','🎉','❤️','💔','✅','❌','⚠️','📌','📎','📞','💬','⏰','🥳','😇','🤝','👀','💯'];
import { groupApi, type GroupParticipant, type MessageTemplate, type TemplateType } from '../services/chatApiService';
import RichMessageComposer from './RichMessageComposer';
import TemplateLibrary from './TemplateLibrary';
import ScheduleMessageModal from './ScheduleMessageModal';
import FilePreviewModal from './FilePreviewModal';
import StickerPanel from './StickerPanel';
import { mediaApi } from '../services/chatApiService';
interface Message {
id: number;
chat_id: number;
message_id: string;
text: string;
direction: 'in' | 'out';
}
interface InputBarProps {
newMessage: string;
onNewMessageChange: (val: string) => void;
onSend: (e?: React.FormEvent) => void;
onPresence: () => void;
onSendMedia: (file: File, caption?: string) => Promise<void>;
onSendAudio: (blob: Blob) => Promise<void>;
replyingTo: Message | null;
onCancelReply: () => void;
isMobile: boolean;
selectedChat: any;
instanceId?: string | null;
mentions?: string[];
onMentionsChange?: (mentions: string[]) => void;
}
const InputBar: React.FC<InputBarProps> = ({
newMessage,
onNewMessageChange,
onSend,
onPresence,
onSendMedia,
onSendAudio,
replyingTo,
onCancelReply,
isMobile,
selectedChat,
instanceId,
mentions = [],
onMentionsChange,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [showStickerPanel, setShowStickerPanel] = useState(false);
const [showAttachMenu, setShowAttachMenu] = useState(false);
const stickerPanelRef = useRef<HTMLDivElement>(null);
const [previewFile, setPreviewFile] = useState<File | null>(null);
const attachMenuRef = useRef<HTMLDivElement>(null);
const [showRichComposer, setShowRichComposer] = useState(false);
const [showTemplateLibrary, setShowTemplateLibrary] = useState(false);
const [richInitialPayload, setRichInitialPayload] = useState<Record<string, unknown> | undefined>();
const [richInitialType, setRichInitialType] = useState<TemplateType | undefined>();
const [showScheduleModal, setShowScheduleModal] = useState(false);
const [schedulingPayload, setSchedulingPayload] = useState<Record<string, unknown>>({});
const [schedulingType, setSchedulingType] = useState<TemplateType>('TEXT');
const emojiPickerRef = useRef<HTMLDivElement>(null);
// ── Mention autocomplete state ──────────────────────────────────────
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const [participants, setParticipants] = useState<GroupParticipant[]>([]);
const participantsCache = useRef<Record<string, GroupParticipant[]>>({});
const mentionDropdownRef = useRef<HTMLDivElement>(null);
const isGroupChat = selectedChat?.remote_jid?.endsWith('@g.us') || selectedChat?.jid?.endsWith('@g.us');
const groupJid = selectedChat?.remote_jid || selectedChat?.jid;
// Fetch group participants when group chat is selected
useEffect(() => {
if (!isGroupChat || !instanceId || !groupJid) {
setParticipants([]);
return;
}
if (participantsCache.current[groupJid]) {
setParticipants(participantsCache.current[groupJid]);
return;
}
groupApi.getParticipants(instanceId, groupJid)
.then((data) => {
participantsCache.current[groupJid] = data.participants;
setParticipants(data.participants);
})
.catch(() => setParticipants([]));
}, [isGroupChat, instanceId, groupJid]);
// Filter participants by mention query
const filteredParticipants = useMemo(() => {
if (mentionQuery === null || !isGroupChat) return [];
const q = mentionQuery.toLowerCase();
// Only show participants that have a displayable name or phone
return participants.filter((p) => {
const label = p.name || p.phone || '';
if (!label) return false;
return q === '' || label.toLowerCase().includes(q);
}).slice(0, 8);
}, [mentionQuery, participants, isGroupChat]);
// Detect @ trigger in text
const detectMentionQuery = useCallback((text: string, cursorPos: number) => {
if (!isGroupChat) { setMentionQuery(null); return; }
const before = text.slice(0, cursorPos);
const atMatch = before.match(/@([^\s@]*)$/);
if (atMatch) {
setMentionQuery(atMatch[1]);
setMentionIndex(0);
} else {
setMentionQuery(null);
}
}, [isGroupChat]);
const selectMention = useCallback((participant: GroupParticipant) => {
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const before = newMessage.slice(0, cursorPos);
const after = newMessage.slice(cursorPos);
const atIdx = before.lastIndexOf('@');
if (atIdx === -1) return;
const displayName = participant.name || participant.phone;
const replacement = `@${displayName} `;
const newText = before.slice(0, atIdx) + replacement + after;
onNewMessageChange(newText);
setMentionQuery(null);
// Add JID to mentions list
if (onMentionsChange && !mentions.includes(participant.jid)) {
onMentionsChange([...mentions, participant.jid]);
}
// Restore cursor
requestAnimationFrame(() => {
if (textareaRef.current) {
const newCursorPos = atIdx + replacement.length;
textareaRef.current.selectionStart = newCursorPos;
textareaRef.current.selectionEnd = newCursorPos;
textareaRef.current.focus();
}
});
}, [newMessage, onNewMessageChange, mentions, onMentionsChange]);
// Audio recording states
const [isRecording, setIsRecording] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [recordingDuration, setRecordingDuration] = useState(0);
const [micError, setMicError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const timerRef = useRef<NodeJS.Timeout | null>(null);
// navigator.mediaDevices só existe em contextos seguros (HTTPS ou localhost)
const micAvailable = typeof window !== 'undefined' && !!window.isSecureContext && !!navigator.mediaDevices?.getUserMedia;
// Auto-expand textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 150)}px`;
}
}, [newMessage]);
// Close emoji picker, sticker panel and attach menu on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
setShowEmojiPicker(false);
}
if (stickerPanelRef.current && !stickerPanelRef.current.contains(event.target as Node)) {
setShowStickerPanel(false);
}
if (attachMenuRef.current && !attachMenuRef.current.contains(event.target as Node)) {
setShowAttachMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSendSticker = useCallback(async (blob: Blob) => {
if (!instanceId || !selectedChat?.remote_jid) return;
setShowStickerPanel(false);
await mediaApi.sendSticker(instanceId, selectedChat.remote_jid, blob);
}, [instanceId, selectedChat]);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Mention dropdown navigation
if (mentionQuery !== null && filteredParticipants.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setMentionIndex((prev) => (prev + 1) % filteredParticipants.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setMentionIndex((prev) => (prev - 1 + filteredParticipants.length) % filteredParticipants.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
selectMention(filteredParticipants[mentionIndex]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setMentionQuery(null);
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
};
const onEmojiSelect = (emoji: any) => {
onNewMessageChange(newMessage + (emoji.native ?? ''));
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setPreviewFile(file);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handlePreviewConfirm = async (file: File, caption: string) => {
await onSendMedia(file, caption);
setPreviewFile(null);
};
const handlePreviewCancel = () => setPreviewFile(null);
const startRecording = async () => {
if (!micAvailable) {
setMicError('Áudio requer HTTPS. Acesse via https:// para gravar.');
setTimeout(() => setMicError(null), 4000);
return;
}
setMicError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
const mimeType = mediaRecorder.mimeType || 'audio/webm'
const audioBlob = new Blob(audioChunksRef.current, { type: mimeType });
await onSendAudio(audioBlob);
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start();
setIsRecording(true);
setIsPaused(false);
setRecordingDuration(0);
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err: any) {
const denied = err?.name === 'NotAllowedError' || err?.name === 'PermissionDeniedError';
const msg = denied
? 'Permissão de microfone negada.'
: 'Não foi possível acessar o microfone.';
setMicError(msg);
setTimeout(() => setMicError(null), 4000);
}
};
const togglePause = () => {
const mr = mediaRecorderRef.current
if (!mr) return
if (isPaused) {
mr.resume()
setIsPaused(false)
timerRef.current = setInterval(() => setRecordingDuration(prev => prev + 1), 1000)
} else {
mr.pause()
setIsPaused(true)
if (timerRef.current) clearInterval(timerRef.current)
}
}
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
// Se estava pausado, retoma antes de parar para capturar o último chunk
if (isPaused && mediaRecorderRef.current.state === 'paused') {
mediaRecorderRef.current.resume()
}
mediaRecorderRef.current.stop();
setIsRecording(false);
setIsPaused(false);
if (timerRef.current) clearInterval(timerRef.current);
}
};
const handleTemplateSelect = (template: MessageTemplate) => {
setRichInitialPayload(template.payload)
setRichInitialType(template.type)
setShowRichComposer(true)
}
const handleScheduleFromComposer = (type: TemplateType, payload: Record<string, unknown>) => {
setSchedulingType(type)
setSchedulingPayload(payload)
setShowRichComposer(false)
setShowScheduleModal(true)
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<>
<div className="relative z-20">
{/* Hidden File Input */}
<input
type="file"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
{/* Emoji Picker Popover */}
<AnimatePresence>
{showEmojiPicker && (
<motion.div
ref={emojiPickerRef}
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute bottom-20 left-4 z-50 shadow-2xl rounded-2xl overflow-hidden border border-gray-100"
>
<div className="bg-white w-[280px] p-2 grid grid-cols-8 gap-1">
{EMOJI_SET.map((e) => (
<button
key={e}
type="button"
onClick={() => onEmojiSelect({ native: e })}
className="text-xl leading-none p-1 rounded-lg hover:bg-gray-100"
>
{e}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Sticker Panel Popover */}
<AnimatePresence>
{showStickerPanel && instanceId && (
<motion.div
ref={stickerPanelRef}
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute bottom-20 left-16 z-50"
>
<StickerPanel
instanceId={instanceId}
jid={selectedChat?.remote_jid ?? ''}
onSend={handleSendSticker}
/>
</motion.div>
)}
</AnimatePresence>
{/* Reply Preview */}
<AnimatePresence>
{replyingTo && (
<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-[#00a884] 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-[#00a884] mb-1">
{replyingTo.direction === 'out' ? 'Respondendo a você' : 'Respondendo ao contato'}
</div>
<div className="text-sm text-gray-500 font-medium truncate italic">
{replyingTo.text || 'Anexo/Mídia'}
</div>
</div>
<button
type="button"
className="bg-gray-100 hover:bg-gray-200 text-gray-500 p-1 rounded-full transition-colors"
onClick={onCancelReply}
>
<X className="w-3.5 h-3.5 stroke-[3px]" />
</button>
</motion.div>
)}
</AnimatePresence>
{/* Mic error banner */}
<AnimatePresence>
{micError && (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.18 }}
className="mx-4 mb-1 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2"
>
<Mic className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
<span className="text-xs text-amber-700">{micError}</span>
</motion.div>
)}
</AnimatePresence>
{/* Main Input Bar */}
<div className="min-h-[62px] px-4 py-2 bg-transparent flex items-center gap-2 shrink-0">
{!isRecording ? (
<>
<div className="flex items-center gap-1">
{/* Grupo flutuante: + expande Anexar / Templates / Mensagem Rica */}
<div className="relative" ref={attachMenuRef}>
<button
onClick={() => setShowAttachMenu(v => !v)}
className={`p-2 rounded-full transition-all active:scale-95 ${showAttachMenu ? 'bg-[#00a884] text-white' : 'hover:bg-black/10 text-[#54656f]'}`}
title="Mais opções"
>
<motion.div
animate={{ rotate: showAttachMenu ? 45 : 0 }}
transition={{ duration: 0.18 }}
>
<Plus className="w-[26px] h-[26px]" />
</motion.div>
</button>
<AnimatePresence>
{showAttachMenu && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.92 }}
transition={{ duration: 0.15 }}
className="absolute bottom-full left-0 mb-2 flex flex-col items-center gap-1 bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 px-1.5"
>
{/* Anexar arquivo */}
<button
onClick={() => { fileInputRef.current?.click(); setShowAttachMenu(false); }}
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
title="Anexar arquivo"
>
<Paperclip className="w-[22px] h-[22px] rotate-45" />
<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">
Anexar
</span>
</button>
{/* Biblioteca de templates */}
<button
onClick={() => { setShowTemplateLibrary(true); setShowAttachMenu(false); }}
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
title="Templates salvos"
>
<BookOpen 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">
Templates
</span>
</button>
{/* Mensagem rica */}
<button
onClick={() => { setRichInitialPayload(undefined); setRichInitialType(undefined); setShowRichComposer(true); setShowAttachMenu(false); }}
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
title="Mensagem rica"
>
<LayoutTemplate 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">
Mensagem Rica
</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">
{/* Mention Autocomplete Dropdown */}
<AnimatePresence>
{mentionQuery !== null && filteredParticipants.length > 0 && (
<motion.div
ref={mentionDropdownRef}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.12 }}
className="absolute bottom-full left-0 right-0 mb-1 bg-white rounded-lg shadow-xl border border-gray-200 max-h-[240px] overflow-y-auto z-50"
>
{filteredParticipants.map((p, i) => (
<button
key={p.jid}
type="button"
className={`w-full px-3 py-2 flex items-center gap-2 text-left text-sm hover:bg-[#f0f2f5] transition-colors ${
i === mentionIndex ? 'bg-[#e9edef]' : ''
}`}
onMouseDown={(e) => {
e.preventDefault();
selectMention(p);
}}
onMouseEnter={() => setMentionIndex(i)}
>
<div className="w-7 h-7 rounded-full bg-[#dfe5e7] flex items-center justify-center text-xs font-bold text-[#54656f] shrink-0">
{(p.name || p.phone || '?')[0].toUpperCase()}
</div>
<div className="min-w-0">
<div className="font-medium text-[#111b21] truncate">{p.name || p.phone}</div>
{p.name && <div className="text-[11px] text-[#667781] truncate">+{p.phone}</div>}
</div>
{p.admin && (
<span className="ml-auto text-[10px] bg-[#e7f8f2] text-[#00a884] px-1.5 py-0.5 rounded font-medium shrink-0">
{p.admin === 'superadmin' ? 'Admin' : 'Admin'}
</span>
)}
</button>
))}
</motion.div>
)}
</AnimatePresence>
<label htmlFor="message-textarea" className="sr-only">Digite sua mensagem</label>
<textarea
id="message-textarea"
name="message-textarea"
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]"
value={newMessage}
onChange={(e) => {
onNewMessageChange(e.target.value);
onPresence();
detectMentionQuery(e.target.value, e.target.selectionStart);
}}
onKeyDown={handleKeyDown}
/>
</div>
<div className="transition-all flex items-center">
{newMessage.trim() ? (
<motion.button
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
onClick={() => onSend()}
className="p-2 text-[#54656f] hover:bg-black/10 rounded-full transition-all active:scale-90"
>
<Send className="w-[26px] h-[26px]" />
</motion.button>
) : (
<button
onClick={startRecording}
title={!micAvailable ? 'Requer HTTPS para gravar áudio' : 'Gravar áudio'}
className={`p-2 rounded-full transition-all active:scale-90 ${
!micAvailable
? 'text-[#aab7bf] cursor-not-allowed opacity-50'
: 'hover:bg-black/10 text-[#54656f]'
}`}
>
<Mic className="w-[26px] h-[26px]" />
</button>
)}
</div>
</>
) : (
<div className={`flex-1 flex items-center justify-between rounded-2xl px-4 py-2 border shadow-inner ${isPaused ? 'bg-amber-50/70 border-amber-200' : 'bg-red-50/50 border-red-100'}`}>
{/* Indicador + tempo */}
<div className="flex items-center gap-3">
{isPaused ? (
<div className="w-3 h-3 bg-amber-400 rounded-full" />
) : (
<div className="w-3 h-3 bg-red-500 rounded-full animate-ping" />
)}
<span className={`text-sm font-bold tracking-wider ${isPaused ? 'text-amber-600' : 'text-red-600'}`}>
{isPaused ? 'PAUSADO' : 'GRAVANDO'} {formatDuration(recordingDuration)}
</span>
</div>
{/* Controles */}
<div className="flex items-center gap-1.5">
{/* Cancelar */}
<button
onClick={() => {
const mr = mediaRecorderRef.current
if (mr) {
mr.onstop = null;
mr.stream?.getTracks().forEach(t => t.stop());
if (mr.state !== 'inactive') mr.stop();
setIsRecording(false);
setIsPaused(false);
if (timerRef.current) clearInterval(timerRef.current);
}
}}
className="p-2 hover:bg-red-100 rounded-full text-red-400 transition-colors"
title="Cancelar"
>
<X className="w-5 h-5" />
</button>
{/* Pause / Resume */}
<button
onClick={togglePause}
className="p-2 hover:bg-black/10 rounded-full text-[#54656f] transition-colors"
title={isPaused ? 'Continuar gravação' : 'Pausar gravação'}
>
{isPaused
? <Play className="w-5 h-5 fill-current text-amber-500" />
: <Pause className="w-5 h-5 fill-current" />
}
</button>
{/* Parar e Enviar */}
<button
onClick={stopRecording}
className="p-2 bg-red-500 hover:bg-red-600 text-white rounded-full transition-all shadow-md active:scale-90"
title="Parar e Enviar"
>
<StopCircle className="w-5 h-5 fill-current" />
</button>
</div>
</div>
)}
</div>
</div>
{/* ── Modal de preview de arquivo ─────────────────────── */}
<FilePreviewModal
file={previewFile}
onConfirm={handlePreviewConfirm}
onCancel={handlePreviewCancel}
/>
{/* ── Modais de mensagem rica ────────────────────────────── */}
<RichMessageComposer
isOpen={showRichComposer}
onClose={() => setShowRichComposer(false)}
instanceId={instanceId ?? null}
jid={selectedChat?.remote_jid ?? selectedChat?.jid ?? ''}
onSent={() => setShowRichComposer(false)}
onSchedule={handleScheduleFromComposer}
initialPayload={richInitialPayload}
initialType={richInitialType}
/>
<TemplateLibrary
isOpen={showTemplateLibrary}
onClose={() => setShowTemplateLibrary(false)}
onSelect={handleTemplateSelect}
/>
<ScheduleMessageModal
isOpen={showScheduleModal}
onClose={() => setShowScheduleModal(false)}
payload={schedulingPayload}
messageType={schedulingType}
onSaved={() => setShowScheduleModal(false)}
/>
</>
);
};
export default InputBar;
@@ -0,0 +1,143 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Globe } from 'lucide-react';
// Satélite: a ext API não expõe /link-preview — stub que rejeita (o componente
// cai para "sem preview" no catch, sem quebrar a renderização).
const api = { get: (_url: string): Promise<any> => Promise.reject(new Error('no link-preview')) };
interface PreviewData {
title: string;
description: string;
image: string;
url: string;
domain: string;
}
// Simple in-memory cache so the same URL is only fetched once per page load
const cache = new Map<string, PreviewData | null>();
interface LinkPreviewProps {
url: string;
isOut: boolean;
}
const LinkPreview: React.FC<LinkPreviewProps> = ({ url, isOut }) => {
const [data, setData] = useState<PreviewData | null>(cache.has(url) ? cache.get(url)! : undefined as any);
const [loading, setLoading] = useState(!cache.has(url));
useEffect(() => {
if (cache.has(url)) {
setData(cache.get(url) ?? null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
api.get(`/link-preview?url=${encodeURIComponent(url)}`)
.then((d: PreviewData) => {
if (cancelled) return;
const value = d?.title ? d : null;
cache.set(url, value);
setData(value);
})
.catch(() => {
if (!cancelled) {
cache.set(url, null);
setData(null);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [url]);
if (loading) {
return (
<div className={`mt-2 rounded-xl overflow-hidden border animate-pulse ${isOut ? 'border-green-200' : 'border-gray-100'}`}>
<div className={`h-24 ${isOut ? 'bg-green-100' : 'bg-gray-100'}`} />
<div className="p-2.5 space-y-1.5">
<div className={`h-2 rounded w-1/3 ${isOut ? 'bg-green-200' : 'bg-gray-200'}`} />
<div className={`h-3 rounded w-4/5 ${isOut ? 'bg-green-200' : 'bg-gray-200'}`} />
<div className={`h-2 rounded w-3/5 ${isOut ? 'bg-green-100' : 'bg-gray-100'}`} />
</div>
</div>
);
}
if (!data) return null;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className={`mt-2 block rounded-xl overflow-hidden border transition-all hover:shadow-md active:scale-[0.98] no-underline ${isOut
? 'border-green-200 bg-white/70 hover:bg-white/90'
: 'border-gray-200 bg-gray-50 hover:bg-white'
}`}
>
{data.image && (
<img
src={data.image}
alt={data.title}
className="w-full max-h-[160px] object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div className="px-3 py-2.5">
<div className="flex items-center gap-1 text-[10px] text-gray-400 font-bold uppercase tracking-wider mb-1">
<Globe className="w-3 h-3 shrink-0" />
<span className="truncate">{data.domain}</span>
</div>
{data.title && (
<p className="text-[13px] font-bold text-gray-800 leading-tight line-clamp-2">
{data.title}
</p>
)}
{data.description && (
<p className="text-[11px] text-gray-500 mt-0.5 leading-snug line-clamp-2">
{data.description}
</p>
)}
</div>
</a>
);
};
// URL extraction helper (exported for use in MessageBubble)
const URL_RE = /https?:\/\/[^\s<>"{}|\\^`[\]]{4,}/;
export const extractFirstUrl = (text: string | null | undefined): string | null => {
if (!text) return null;
const m = text.match(URL_RE);
return m ? m[0] : null;
};
// Renders plain text with URLs converted to clickable <a> tags
export const renderTextWithLinks = (text: string): React.ReactNode[] => {
const parts = text.split(/(https?:\/\/[^\s<>"{}|\\^`[\]]{4,})/g);
return parts.map((part, i) => {
if (/^https?:\/\//.test(part)) {
return (
<a
key={i}
href={part}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-blue-600 underline break-all hover:text-blue-700"
>
{part}
</a>
);
}
return <span key={i}>{part}</span>;
});
};
export default LinkPreview;
@@ -0,0 +1,260 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
X,
Download,
Reply,
Star,
ZoomIn,
ZoomOut,
ChevronLeft,
ChevronRight,
Video,
} from 'lucide-react';
import { Message } from '../types/inboxTypes';
import { getMediaUrl } from '../utils/inboxUtils';
import { format } from 'date-fns';
import { parseSafeDate } from '../utils/inboxUtils';
interface MediaViewerProps {
msgs: Message[]
currentMsgId: string
onClose: () => void
onReply?: (msg: Message) => void
onStar?: (msg: Message) => void
}
const MediaViewer: React.FC<MediaViewerProps> = ({ msgs, currentMsgId, onClose, onReply, onStar }) => {
const [currentIdx, setCurrentIdx] = useState(() => {
const idx = msgs.findIndex(m => m.message_id === currentMsgId)
return idx >= 0 ? idx : 0
})
const [zoom, setZoom] = useState(1)
const filmstripRef = useRef<HTMLDivElement>(null)
const currentMsg = msgs[currentIdx]
const currentUrl = getMediaUrl(currentMsg?.media_url)
const goNext = useCallback(() => {
setCurrentIdx(i => Math.min(i + 1, msgs.length - 1))
setZoom(1)
}, [msgs.length])
const goPrev = useCallback(() => {
setCurrentIdx(i => Math.max(i - 1, 0))
setZoom(1)
}, [])
// Keyboard: ← → Esc
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowRight') goNext()
if (e.key === 'ArrowLeft') goPrev()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose, goNext, goPrev])
// Scroll filmstrip to keep current thumbnail visible
useEffect(() => {
const el = filmstripRef.current
if (!el) return
const thumb = el.children[currentIdx] as HTMLElement
if (thumb) thumb.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' })
}, [currentIdx])
if (!currentMsg) return null
const isVideo = currentMsg.type === 'video'
const isImage = currentMsg.type === 'image'
const caption = currentMsg.text?.trim() || null
const timeStr = currentMsg.timestamp ? format(parseSafeDate(currentMsg.timestamp), 'dd/MM/yyyy HH:mm') : ''
const senderLabel = currentMsg.direction === 'out' ? 'Você' : 'Contato'
const viewer = (
<div className="fixed inset-0 z-[9999] flex flex-col bg-[#111b21]">
{/* ── Top bar ─────────────────────────────────────────── */}
<div className="flex items-center justify-between px-2 py-1.5 bg-[#202c33] shrink-0">
{/* Left: back + sender info */}
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Fechar"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="leading-tight">
<div className="text-white text-[13.5px] font-medium">{senderLabel}</div>
<div className="text-[#aebac1] text-[11px]">{timeStr}</div>
</div>
</div>
{/* Right: actions */}
<div className="flex items-center">
{isImage && (
<>
<button
onClick={() => setZoom(z => Math.min(z + 0.3, 4))}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Aumentar zoom"
>
<ZoomIn className="w-5 h-5" />
</button>
<button
onClick={() => setZoom(z => Math.max(z - 0.3, 0.4))}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Diminuir zoom"
>
<ZoomOut className="w-5 h-5" />
</button>
</>
)}
<button
onClick={() => { onReply?.(currentMsg); onClose(); }}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Responder"
>
<Reply className="w-5 h-5" />
</button>
<button
onClick={() => onStar?.(currentMsg)}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Favoritar"
>
<Star className="w-5 h-5" />
</button>
{currentUrl && (
<a
href={currentUrl}
download
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Baixar"
>
<Download className="w-5 h-5" />
</a>
)}
<button
onClick={onClose}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Fechar"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* ── Main content ─────────────────────────────────────── */}
<div
className="flex-1 flex items-center justify-center relative overflow-hidden"
onClick={onClose}
>
{/* Left arrow */}
{currentIdx > 0 && (
<button
onClick={e => { e.stopPropagation(); goPrev() }}
className="absolute left-4 z-10 w-11 h-11 bg-black/50 hover:bg-black/80 text-white rounded-full flex items-center justify-center transition-colors shadow-lg"
>
<ChevronLeft className="w-6 h-6" />
</button>
)}
{/* Media */}
<div className="max-w-full max-h-full flex items-center justify-center" onClick={e => e.stopPropagation()}>
{!currentUrl ? (
<div className="text-[#aebac1] text-sm">Mídia não disponível</div>
) : isVideo ? (
<video
src={currentUrl}
controls
autoPlay
className="max-w-full rounded"
style={{ maxHeight: 'calc(100vh - 160px)' }}
/>
) : (
<img
src={currentUrl}
alt={caption || 'Imagem'}
draggable={false}
className="object-contain select-none rounded"
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 160px)',
transform: `scale(${zoom})`,
transition: 'transform 0.18s ease',
cursor: zoom > 1 ? 'zoom-out' : 'default',
}}
onClick={e => { e.stopPropagation(); if (zoom > 1) setZoom(1) }}
/>
)}
</div>
{/* Right arrow */}
{currentIdx < msgs.length - 1 && (
<button
onClick={e => { e.stopPropagation(); goNext() }}
className="absolute right-4 z-10 w-11 h-11 bg-black/50 hover:bg-black/80 text-white rounded-full flex items-center justify-center transition-colors shadow-lg"
>
<ChevronRight className="w-6 h-6" />
</button>
)}
{/* Caption */}
{caption && (
<div className="absolute bottom-2 inset-x-0 flex justify-center pointer-events-none">
<span className="bg-black/60 text-white text-[13px] px-4 py-1.5 rounded-lg max-w-[70%] text-center leading-snug">
{caption}
</span>
</div>
)}
</div>
{/* ── Bottom filmstrip ─────────────────────────────────── */}
{msgs.length > 1 && (
<div className="shrink-0 bg-[#202c33] py-2 px-3">
<div
ref={filmstripRef}
className="flex gap-1.5 overflow-x-auto items-center"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{msgs.map((m, i) => {
const url = getMediaUrl(m.media_url)
const isVid = m.type === 'video'
const isActive = i === currentIdx
return (
<button
key={m.message_id}
onClick={() => { setCurrentIdx(i); setZoom(1) }}
className={`relative shrink-0 w-[58px] h-[58px] rounded overflow-hidden border-2 transition-all ${
isActive
? 'border-white opacity-100'
: 'border-transparent opacity-50 hover:opacity-80'
}`}
>
{url ? (
isVid ? (
<div className="w-full h-full bg-black flex items-center justify-center">
<Video className="w-5 h-5 text-white fill-white" />
</div>
) : (
<img src={url} alt="" className="w-full h-full object-cover" />
)
) : (
<div className="w-full h-full bg-[#2a3942]" />
)}
</button>
)
})}
</div>
</div>
)}
</div>
)
return typeof document !== 'undefined' ? createPortal(viewer, document.body) : null
}
export default MediaViewer
@@ -0,0 +1,277 @@
'use client';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { ChevronDown, ArrowDown } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import MessageBubble from './MessageBubble';
import MediaViewer from './MediaViewer';
import { isSameDay, format, isToday, isYesterday } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { normalizeJid, parseSafeDate } from '../utils/inboxUtils';
import { Message } from '../types/inboxTypes';
import { mediaApi } from '../services/chatApiService';
import { useChatStore } from '../store/chatStore';
// Message interface removed and moved to types.ts
interface MessageAreaProps {
messages: Message[];
selectedChat: any;
scrollRef: React.RefObject<HTMLDivElement>;
onScroll: () => void;
msgLoading: boolean;
historyLoadingMore: boolean;
hasMoreHistory: boolean;
reactionPickerFor: string | null;
onReply: (msg: Message) => void;
onToggleReactionPicker: (msgId: string) => void;
onReact: (msg: Message, emoji: string) => void;
onForward?: (msg: Message) => void;
onDelete?: (msg: Message) => void;
onStar?: (msg: Message) => void;
onEdit?: (msg: Message) => void;
quickReactions: string[];
getGroupSenderLabel: (msg: Message) => string;
canDelete?: boolean;
}
const MessageArea: React.FC<MessageAreaProps> = ({
messages,
selectedChat,
scrollRef,
onScroll,
msgLoading,
historyLoadingMore,
hasMoreHistory,
reactionPickerFor,
onReply,
onToggleReactionPicker,
onReact,
onForward,
onDelete,
onStar,
onEdit,
quickReactions,
getGroupSenderLabel,
canDelete = true,
}) => {
const [showScrollBottom, setShowScrollBottom] = useState(false);
const lastScrollTop = useRef(0);
const needsInitialScroll = useRef(false);
// ── Media viewer state ────────────────────────────────────────────────────
const [viewerMsgId, setViewerMsgId] = useState<string | null>(null)
// ── Re-download de mídia não baixada ─────────────────────────────────────
// ~95% das mídias vêm com mediaUrl NULL (lazy). O motor re-baixa do WhatsApp
// e devolve { mediaUrl }; atualizamos o store direto pelo retorno (o WS
// message:media_ready não trafega pela ponte ext do satélite).
const updateMediaReady = useChatStore((s) => s.updateMediaReady)
const handleRedownloadMedia = useCallback(async (msg: Message) => {
// selectedChat no formato legado usa instance_name (não instanceId)
const instanceId = selectedChat?.instance_name ?? selectedChat?.instanceId
if (!instanceId || !msg.id) return
const { mediaUrl } = await mediaApi.redownload(instanceId, String(msg.id))
if (mediaUrl) updateMediaReady(String(msg.id), mediaUrl)
}, [selectedChat?.instance_name, selectedChat?.instanceId, updateMediaReady])
const mediaMessages = messages.filter(m => m.type === 'image' || m.type === 'video')
const handleInternalScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
setShowScrollBottom(!isAtBottom);
// Trigger parent onScroll (for infinite loading etc)
onScroll();
lastScrollTop.current = el.scrollTop;
}, [onScroll, scrollRef]);
const scrollToBottom = (smooth = true) => {
const el = scrollRef.current;
if (el) {
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'instant' });
}
};
const formatDateSeparator = (date: Date) => {
try {
if (isToday(date)) return 'Hoje';
if (isYesterday(date)) return 'Ontem';
return format(date, "d 'de' MMMM 'de' yyyy", { locale: ptBR });
} catch (e) {
return 'Histórico';
}
};
const isGroupChat = Boolean(selectedChat?.remote_jid?.endsWith('@g.us'));
const chatId = selectedChat?.remote_jid ?? null;
// Marca que precisa scroll ao fundo quando trocar de chat
useEffect(() => {
needsInitialScroll.current = true;
}, [chatId]);
// Auto-scroll: roda sempre que messages muda
useEffect(() => {
const el = scrollRef.current;
if (!el || messages.length === 0) return;
if (needsInitialScroll.current) {
// Carga inicial após selectChat → scroll instantâneo ao fundo
needsInitialScroll.current = false;
// Triplo rAF: aguarda React render + layout + paint
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
});
});
} else {
// Nova mensagem em tempo real → só rola se já estiver perto do fundo
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 250;
if (isNearBottom) {
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
});
}
}
}, [messages, scrollRef]);
return (
<>
<div className="flex-1 relative flex flex-col min-h-0 bg-transparent overflow-hidden">
<div
className="flex-1 overflow-y-auto px-4 md:px-14 py-6 flex flex-col custom-scrollbar relative z-10"
ref={scrollRef}
onScroll={handleInternalScroll}
>
{/* Loader for history */}
{historyLoadingMore && (
<div className="flex justify-center py-4">
<div className="w-5 h-5 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" />
</div>
)}
{(() => {
const seen = new Set<string>();
return messages
.filter(m => {
// Reactions não são mensagens visuais — são metadados
if (m.type === 'reaction') return false;
const key = m.message_id || m.id?.toString();
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
})
.map((msg, idx) => {
const msgDate = parseSafeDate(msg.timestamp);
const prevMsg = messages[idx - 1];
const prevDate = prevMsg ? parseSafeDate(prevMsg.timestamp) : null;
const showDateSeparator = !prevDate || !isSameDay(prevDate, msgDate);
// Authentic WA Tail Logic: Show tail on the first message of a sequence from the same sender
const isSameSenderAsPrev = prevMsg && (prevMsg.direction === msg.direction);
const showTail = showDateSeparator || !isSameSenderAsPrev;
const senderLabel = isGroupChat ? getGroupSenderLabel(msg) : '';
// Resolve Quoted Sender
let quotedSenderLabel = 'Mensagem';
if (msg.quoted_participant) {
const qp = normalizeJid(msg.quoted_participant);
const chatJid = normalizeJid(selectedChat?.remote_jid || '');
if (qp === chatJid) quotedSenderLabel = selectedChat?.subject || 'Contato';
else quotedSenderLabel = 'Você';
}
return (
<React.Fragment key={msg.message_id || msg.id}>
{showDateSeparator && (
<div className="flex justify-center my-6">
<span className="px-3 py-1 bg-white text-[#54656f] text-[12.5px] font-normal uppercase tracking-tight rounded-lg shadow-sm border border-[#d1d7db]">
{formatDateSeparator(msgDate)}
</span>
</div>
)}
<MessageBubble
msg={msg}
isGroupChat={isGroupChat}
senderLabel={senderLabel}
showTail={showTail}
quotedSenderLabel={quotedSenderLabel}
quotedPreviewText={msg.quoted_message_text || 'Mídia'}
reactionPickerFor={reactionPickerFor}
onReply={onReply}
onToggleReactionPicker={onToggleReactionPicker}
onReact={onReact}
onForward={onForward}
onDelete={onDelete}
canDelete={canDelete}
onStar={onStar}
onEdit={onEdit}
quickReactions={quickReactions}
selectedChat={selectedChat}
onOpenMedia={(m) => setViewerMsgId(m.message_id)}
onRedownloadMedia={handleRedownloadMedia}
/>
</React.Fragment>
);
});
})()}
{/* Bottom Anchor */}
<div className="h-2 w-full shrink-0" />
</div>
{/* Scroll to Bottom Button */}
<AnimatePresence>
{showScrollBottom && (
<motion.button
initial={{ opacity: 0, scale: 0.8, y: 10 }}
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"
>
<ArrowDown className="w-5 h-5" />
{selectedChat?.unread_count > 0 && (
<span className="absolute -top-1 -right-1 bg-[#00a884] text-white text-[10px] min-w-[20px] h-5 px-1 flex items-center justify-center rounded-full font-bold border-2 border-white shadow-sm">
{selectedChat.unread_count}
</span>
)}
</motion.button>
)}
</AnimatePresence>
{/* Initial Loading Overlay */}
{msgLoading && messages.length === 0 && (
<div className="absolute inset-0 bg-white/50 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-[3px] border-[#00a884]/20 border-t-[#00a884] rounded-full animate-spin" />
<span className="text-[12px] font-medium text-[#667781]">Carregando mensagens...</span>
</div>
</div>
)}
</div>
{/* ── Media viewer portal ───────────────────────────────────────────── */}
{viewerMsgId && mediaMessages.length > 0 && (
<MediaViewer
msgs={mediaMessages}
currentMsgId={viewerMsgId}
onClose={() => setViewerMsgId(null)}
onReply={onReply}
onStar={onStar}
/>
)}
</>
);
};
export default MessageArea;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,281 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { X, Phone, Send, ChevronDown, Check } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
interface Instance {
id: string;
nome: string;
avatar: string | null;
phone: string | null;
}
interface NewConversationModalProps {
isOpen: boolean;
onClose: () => void;
onSent?: (phone: string, instanceId: string) => void;
activeInstance: Instance | null;
connectedInstances: Instance[];
sendMessage: (instanceId: string, phone: string, text: string) => Promise<{ ok: boolean; messageId?: string }>;
}
function normalizePhoneForSend(input: string): string {
const digits = input.replace(/\D/g, '');
// Already has Brazil country code: 55 + DDD (2) + number (8-9) = 12-13 digits
if (digits.startsWith('55') && (digits.length === 12 || digits.length === 13)) return digits;
// Local Brazilian number: DDD (2) + number (8-9) = 10-11 digits
if (digits.length === 10 || digits.length === 11) return '55' + digits;
// Return as-is (international or unusual)
return digits;
}
export default function NewConversationModal({
isOpen,
onClose,
onSent,
activeInstance,
connectedInstances,
sendMessage,
}: NewConversationModalProps) {
const [phone, setPhone] = useState('');
const [message, setMessage] = useState('');
const [selectedInstanceId, setSelectedInstanceId] = useState('');
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
const phoneInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setPhone('');
setMessage('');
setError('');
setSent(false);
setSending(false);
setSelectedInstanceId(activeInstance?.id || connectedInstances[0]?.id || '');
setTimeout(() => phoneInputRef.current?.focus(), 120);
}
}, [isOpen, activeInstance?.id, connectedInstances]);
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPhone(e.target.value.replace(/[^\d\s\-()+]/g, ''));
setError('');
};
const handleSubmit = async () => {
const normalized = normalizePhoneForSend(phone);
const digits = normalized.replace(/\D/g, '');
if (digits.length < 10) {
setError('Número inválido. Informe DDD + número (ex: 67 99922-2377)');
phoneInputRef.current?.focus();
return;
}
if (!message.trim()) {
setError('Escreva uma mensagem para iniciar a conversa');
textareaRef.current?.focus();
return;
}
if (!selectedInstanceId) {
setError('Nenhuma instância conectada');
return;
}
setSending(true);
setError('');
try {
const result = await sendMessage(selectedInstanceId, normalized, message.trim());
if (!result.ok) throw new Error('Envio falhou');
setSent(true);
// Brief success state, then close and notify parent
setTimeout(() => {
onSent?.(normalized, selectedInstanceId);
onClose();
}, 1300);
} catch (err: any) {
setError(err?.message || 'Erro ao enviar. Verifique o número e tente novamente.');
setSending(false);
}
};
const handleTextareaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmit();
}
};
const handlePhoneKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') textareaRef.current?.focus();
};
const canSend = !!phone.trim() && !!message.trim() && !!selectedInstanceId && !sending && !sent;
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={!sending ? onClose : undefined}
/>
{/* Modal */}
<motion.div
initial={{ scale: 0.92, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.92, opacity: 0, y: 16 }}
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
className="relative w-full max-w-md bg-white rounded-2xl shadow-2xl flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 bg-[#008069] text-white">
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-white/20 rounded-full flex items-center justify-center">
<Phone className="w-5 h-5" />
</div>
<div>
<h2 className="text-base font-semibold leading-tight">Nova Conversa</h2>
<p className="text-[11px] text-white/70 leading-tight">Enviar primeira mensagem</p>
</div>
</div>
<button
onClick={onClose}
disabled={sending}
className="p-1.5 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-5">
{/* Instance selector — only if multiple instances connected */}
{connectedInstances.length > 1 && (
<div>
<label className="block text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
Enviar como
</label>
<div className="relative">
<select
value={selectedInstanceId}
onChange={(e) => setSelectedInstanceId(e.target.value)}
disabled={sending || sent}
className="w-full appearance-none bg-[#f0f2f5] rounded-xl px-4 py-2.5 pr-10 text-sm text-[#111b21] font-medium focus:outline-none focus:ring-2 focus:ring-[#008069]/40 disabled:opacity-60"
>
{connectedInstances.map(inst => (
<option key={inst.id} value={inst.id}>
{inst.nome}{inst.phone ? ` (+${inst.phone})` : ''}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
</div>
)}
{/* Phone input */}
<div>
<label className="block text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
Número do WhatsApp
</label>
<div className="flex items-center gap-2 bg-[#f0f2f5] rounded-xl px-4 py-2.5 focus-within:ring-2 focus-within:ring-[#008069]/40 transition-all">
<span className="text-gray-400 text-sm select-none shrink-0">🇧🇷 +55</span>
<div className="w-px h-5 bg-gray-300 shrink-0" />
<input
ref={phoneInputRef}
type="tel"
placeholder="(67) 99922-2377"
value={phone}
onChange={handlePhoneChange}
onKeyDown={handlePhoneKeyDown}
disabled={sending || sent}
className="flex-1 bg-transparent text-sm text-[#111b21] placeholder:text-[#8696a0] focus:outline-none disabled:opacity-60 min-w-0"
/>
</div>
</div>
{/* Message textarea */}
<div>
<label className="block text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
Mensagem
</label>
<textarea
ref={textareaRef}
rows={3}
placeholder="Olá! Tudo bem?"
value={message}
onChange={(e) => { setMessage(e.target.value); setError(''); }}
onKeyDown={handleTextareaKeyDown}
disabled={sending || sent}
className="w-full bg-[#f0f2f5] rounded-xl px-4 py-3 text-sm text-[#111b21] placeholder:text-[#8696a0] focus:outline-none focus:ring-2 focus:ring-[#008069]/40 resize-none transition-all disabled:opacity-60"
/>
<p className="text-[11px] text-gray-400 mt-1 text-right">Ctrl+Enter para enviar</p>
</div>
{/* Error */}
<AnimatePresence>
{error && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="bg-red-50 border border-red-200 text-red-600 text-sm rounded-xl px-4 py-2.5"
>
{error}
</motion.p>
)}
</AnimatePresence>
</div>
{/* Footer */}
<div className="px-6 pb-6 flex items-center justify-between">
<button
onClick={onClose}
disabled={sending}
className="px-5 py-2.5 text-sm font-semibold text-gray-500 hover:bg-gray-100 rounded-xl transition-colors disabled:opacity-50"
>
Cancelar
</button>
<button
onClick={handleSubmit}
disabled={!canSend}
className={`flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed ${
sent
? 'bg-green-500'
: sending
? 'bg-[#008069]/80'
: 'bg-[#008069] hover:bg-[#017259]'
}`}
>
{sent ? (
<>
<Check className="w-4 h-4" />
Enviado!
</>
) : sending ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Enviando...
</>
) : (
<>
<Send className="w-4 h-4" />
Enviar
</>
)}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,380 @@
'use client';
import React from 'react';
import {
X, Plus, ClipboardList, ChevronDown, Loader2,
CheckCircle2, Clock, AlertCircle, Ban, Users, Layers,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import type { Protocol, ProtocolStatus } from '../hooks/useProtocolPanel';
import Toast from './ui/Toast';
// ─── Status config ────────────────────────────────────────────────────────────
type StatusConfig = {
label: string
color: string
bg: string
icon: React.ReactNode
}
const STATUS: Record<ProtocolStatus, StatusConfig> = {
OPEN: { label: 'Aberto', color: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20', icon: <Clock className="w-3 h-3" /> },
IN_PROGRESS: { label: 'Em Andamento', color: 'text-brand-400', bg: 'bg-brand-500/10 border-brand-500/20', icon: <AlertCircle className="w-3 h-3" /> },
WAITING_CLIENT: { label: 'Aguardando Cliente', color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/20', icon: <Clock className="w-3 h-3" /> },
WAITING_AGENT: { label: 'Aguardando Agente', color: 'text-orange-400', bg: 'bg-orange-500/10 border-orange-500/20', icon: <Clock className="w-3 h-3" /> },
RESOLVED: { label: 'Resolvido', color: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20', icon: <CheckCircle2 className="w-3 h-3" /> },
CANCELLED: { label: 'Cancelado', color: 'text-slate-400', bg: 'bg-slate-500/10 border-slate-500/20', icon: <Ban className="w-3 h-3" /> },
}
const TRANSITIONS: Record<ProtocolStatus, ProtocolStatus[]> = {
OPEN: ['IN_PROGRESS', 'CANCELLED'],
IN_PROGRESS: ['WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED'],
WAITING_CLIENT: ['IN_PROGRESS', 'RESOLVED', 'CANCELLED'],
WAITING_AGENT: ['IN_PROGRESS', 'RESOLVED', 'CANCELLED'],
RESOLVED: [],
CANCELLED: [],
}
function StatusBadge({ status }: { status: ProtocolStatus }) {
const cfg = STATUS[status]
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-bold border ${cfg.color} ${cfg.bg}`}>
{cfg.icon}
{cfg.label}
</span>
)
}
// ─── Protocol card ────────────────────────────────────────────────────────────
interface ProtocolCardProps {
protocol: Protocol
isActive: boolean
updatingId: string | null
onStatusChange: (protocol: Protocol, status: ProtocolStatus) => void
}
function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: ProtocolCardProps) {
const [showActions, setShowActions] = React.useState(false)
const transitions = TRANSITIONS[protocol.status]
const isUpdating = updatingId === protocol.id
return (
<div className={`rounded-xl border p-4 space-y-3 ${isActive ? 'border-brand-500/30 bg-brand-500/5' : 'border-white/5 bg-white/[0.02]'}`}>
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2">
<span className="text-white font-bold text-sm">{protocol.number}</span>
{isActive && (
<span className="text-[10px] font-bold text-brand-400 bg-brand-500/10 px-1.5 py-0.5 rounded-full border border-brand-500/20">
ATIVO
</span>
)}
</div>
<p className="text-xs text-slate-500 mt-0.5">
{format(new Date(protocol.createdAt), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}
</p>
</div>
<StatusBadge status={protocol.status} />
</div>
{/* Sector / Team */}
{(protocol.sector || protocol.team) && (
<div className="flex items-center gap-3 text-xs text-slate-400">
{protocol.sector && (
<span className="flex items-center gap-1">
<Layers className="w-3 h-3" />
{protocol.sector.name}
</span>
)}
{protocol.team && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{protocol.team.name}
</span>
)}
</div>
)}
{/* Summary */}
{protocol.summary && (
<p className="text-xs text-slate-400 italic border-l-2 border-white/10 pl-3">{protocol.summary}</p>
)}
{/* Status actions */}
{transitions.length > 0 && (
<div className="relative">
<button
onClick={() => setShowActions(!showActions)}
disabled={isUpdating}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-white transition-colors disabled:opacity-50"
>
{isUpdating
? <Loader2 className="w-3 h-3 animate-spin" />
: <ChevronDown className={`w-3 h-3 transition-transform ${showActions ? 'rotate-180' : ''}`} />}
Alterar status
</button>
<AnimatePresence>
{showActions && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="absolute left-0 top-full mt-1 bg-[#1a1f2e] border border-white/10 rounded-xl shadow-2xl z-10 py-1 min-w-[180px]"
>
{transitions.map((s) => {
const cfg = STATUS[s]
return (
<button
key={s}
onClick={() => { onStatusChange(protocol, s); setShowActions(false) }}
className={`w-full text-left px-3 py-2 text-xs flex items-center gap-2 hover:bg-white/5 transition-colors ${cfg.color}`}
>
{cfg.icon}
{cfg.label}
</button>
)
})}
</motion.div>
)}
</AnimatePresence>
</div>
)}
{/* Resolved at */}
{protocol.resolvedAt && (
<p className="text-xs text-emerald-400 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" />
Resolvido em {format(new Date(protocol.resolvedAt), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}
</p>
)}
</div>
)
}
// ─── Panel ────────────────────────────────────────────────────────────────────
interface ProtocolPanelProps {
isOpen: boolean
chatId: string | null
onClose: () => void
// injected from useProtocolPanel
protocols: Protocol[]
loading: boolean
activeProtocol: Protocol | null
hasActive: boolean
showOpenForm: boolean
openForm: () => void
setShowOpenForm: (v: boolean) => void
sectors: any[]
teams: any[]
selectedSectorId: string
setSelectedSectorId: (v: string) => void
selectedTeamId: string
setSelectedTeamId: (v: string) => void
loadingSectors: boolean
loadingTeams: boolean
isOpening: boolean
openProtocol: () => void
updatingId: string | null
updateStatus: (protocol: Protocol, status: ProtocolStatus) => void
toast: { message: string; type: 'success' | 'error' } | null
}
export default function ProtocolPanel({
isOpen, onClose,
protocols, loading,
activeProtocol, hasActive,
showOpenForm, openForm, setShowOpenForm,
sectors, teams,
selectedSectorId, setSelectedSectorId,
selectedTeamId, setSelectedTeamId,
loadingSectors, loadingTeams,
isOpening, openProtocol,
updatingId, updateStatus,
toast,
}: ProtocolPanelProps) {
const selectClass = "w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-500/50 transition-colors appearance-none"
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-[360px] h-full bg-[#111b21] border-l border-[#d1d7db] flex flex-col z-40 flex-shrink-0"
>
{/* Header */}
<div className="h-[60px] bg-[#f0f2f5] px-4 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<ClipboardList className="w-5 h-5 text-[#54656f]" />
<span className="font-bold text-[#111b21] text-sm">Protocolos de Atendimento</span>
</div>
<button onClick={onClose} className="p-1.5 hover:bg-black/5 rounded-full transition-colors">
<X className="w-5 h-5 text-[#54656f]" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Open protocol form */}
<AnimatePresence>
{showOpenForm ? (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="bg-white/[0.03] border border-white/10 rounded-xl p-4 space-y-3">
<p className="text-white text-sm font-semibold">Novo Protocolo</p>
{/* Sector */}
<div>
<label className="block text-xs text-slate-400 mb-1">Setor (opcional)</label>
<div className="relative">
<select
value={selectedSectorId}
onChange={(e) => setSelectedSectorId(e.target.value)}
className={selectClass}
disabled={loadingSectors}
>
<option value="">Sem setor</option>
{sectors.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
{loadingSectors && (
<Loader2 className="absolute right-3 top-2.5 w-3.5 h-3.5 text-slate-400 animate-spin" />
)}
</div>
</div>
{/* Team */}
{selectedSectorId && (
<div>
<label className="block text-xs text-slate-400 mb-1">Equipe (opcional)</label>
<div className="relative">
<select
value={selectedTeamId}
onChange={(e) => setSelectedTeamId(e.target.value)}
className={selectClass}
disabled={loadingTeams}
>
<option value="">Sem equipe</option>
{teams.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{loadingTeams && (
<Loader2 className="absolute right-3 top-2.5 w-3.5 h-3.5 text-slate-400 animate-spin" />
)}
</div>
</div>
)}
<div className="flex gap-2">
<button
onClick={() => setShowOpenForm(false)}
className="flex-1 py-2 text-xs font-semibold text-slate-400 hover:text-white bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
>
Cancelar
</button>
<button
onClick={openProtocol}
disabled={isOpening}
className="flex-1 py-2 text-xs font-bold text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors disabled:opacity-50 flex items-center justify-center gap-1.5"
>
{isOpening
? <><Loader2 className="w-3 h-3 animate-spin" /> Abrindo</>
: 'Abrir Protocolo'
}
</button>
</div>
</div>
</motion.div>
) : (
!hasActive && (
<button
onClick={openForm}
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-white/10 rounded-xl text-sm text-slate-400 hover:text-brand-400 hover:border-brand-500/40 transition-colors"
>
<Plus className="w-4 h-4" />
Abrir Protocolo
</button>
)
)}
</AnimatePresence>
{/* Active protocol */}
{activeProtocol && !showOpenForm && (
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest font-bold mb-2">Protocolo Ativo</p>
<ProtocolCard
protocol={activeProtocol}
isActive
updatingId={updatingId}
onStatusChange={updateStatus}
/>
{!hasActive && null}
</div>
)}
{/* Open new when active exists */}
{hasActive && !showOpenForm && (
<button
onClick={openForm}
className="w-full flex items-center justify-center gap-2 py-2.5 border border-dashed border-white/10 rounded-xl text-xs text-slate-500 hover:text-brand-400 hover:border-brand-500/30 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Abrir novo protocolo
</button>
)}
{/* History */}
{loading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 text-brand-400 animate-spin" />
</div>
) : protocols.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2">
<ClipboardList className="w-8 h-8 text-slate-700" />
<p className="text-slate-500 text-xs text-center">Nenhum protocolo para este chat.</p>
</div>
) : (
(() => {
const history = protocols.filter(
(p) => p.status === 'RESOLVED' || p.status === 'CANCELLED'
)
return history.length > 0 ? (
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest font-bold mb-2">Histórico</p>
<div className="space-y-2">
{history.map((p) => (
<ProtocolCard
key={p.id}
protocol={p}
isActive={false}
updatingId={updatingId}
onStatusChange={updateStatus}
/>
))}
</div>
</div>
) : null
})()
)}
</div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,719 @@
'use client';
/**
* RichMessageComposer modal completo para compor e enviar mensagens ricas.
*
* Suporta 6 tipos de mensagem WhatsApp via InfiniteAPI/Baileys:
* TEXT texto simples (padrão atual)
* BUTTONS até 3 botões de resposta rápida (nativeButtons reply)
* INTERACTIVE botões CTA: URL / Copiar / Ligar (nativeButtons url/copy/call)
* LIST lista dropdown com seções e itens (nativeList)
* POLL enquete nativa do WhatsApp (poll)
* CAROUSEL cards deslizáveis com imagem e botões (nativeCarousel)
*
* Props:
* isOpen controla visibilidade
* onClose callback ao fechar
* instanceId instância WhatsApp ativa
* jid JID destino (pré-preenchido quando aberto do chat)
* onSent callback após envio bem-sucedido
* initialPayload pré-preenche o form (ao usar um template)
* initialType tipo inicial selecionado
*/
import React, { useState, useCallback, useEffect, useId } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Send, Plus, Trash2, ChevronDown, ChevronUp,
Type, MousePointerClick, List, BarChart2, LayoutGrid, Link,
BookmarkPlus, Calendar,
} from 'lucide-react'
import { richMessageApi, templateApi, type TemplateType, type RichPayload } from '../services/chatApiService'
// ─── Tipos locais ────────────────────────────────────────────────────────────
type MessageType = TemplateType
interface BtnItem { id: string; text: string }
interface CtaItem { type: 'url' | 'copy' | 'call'; text: string; extra: string }
interface ListRow { id: string; title: string; description: string }
interface ListSection { title: string; rows: ListRow[] }
interface CarouselBtn { id: string; text: string }
interface CarouselCard { title: string; body: string; footer: string; imageUrl: string; buttons: CarouselBtn[] }
interface FormState {
// TEXT
text: string
// BUTTONS
btnText: string
btnFooter: string
buttons: BtnItem[]
// INTERACTIVE (CTA)
ctaText: string
ctaFooter: string
ctaButtons: CtaItem[]
// LIST
listText: string
listButtonText: string
listFooter: string
listSections: ListSection[]
// POLL
pollName: string
pollSelectable: number
pollOptions: string[]
// CAROUSEL
carouselText: string
carouselFooter: string
cards: CarouselCard[]
}
const defaultForm: FormState = {
text: '',
btnText: '', btnFooter: '', buttons: [{ id: 'btn1', text: '' }],
ctaText: '', ctaFooter: '', ctaButtons: [{ type: 'url', text: '', extra: '' }],
listText: '', listButtonText: 'Ver opções', listFooter: '',
listSections: [{ title: 'Seção 1', rows: [{ id: 'r1', title: '', description: '' }] }],
pollName: '', pollSelectable: 1, pollOptions: ['', ''],
carouselText: '', carouselFooter: '',
cards: [{ title: '', body: '', footer: '', imageUrl: '', buttons: [{ id: 'cb1', text: '' }] }],
}
// ─── Tipo selector ────────────────────────────────────────────────────────────
const TYPE_TABS: Array<{ type: MessageType; label: string; icon: React.ReactNode; color: string }> = [
{ type: 'TEXT', label: 'Texto', icon: <Type className="w-4 h-4" />, color: '#667781' },
{ type: 'BUTTONS', label: 'Botões', icon: <MousePointerClick className="w-4 h-4" />, color: '#0ea5e9' },
{ type: 'INTERACTIVE', label: 'CTA', icon: <Link className="w-4 h-4" />, color: '#8b5cf6' },
{ type: 'LIST', label: 'Lista', icon: <List className="w-4 h-4" />, color: '#f59e0b' },
{ type: 'POLL', label: 'Enquete', icon: <BarChart2 className="w-4 h-4" />, color: '#10b981' },
{ type: 'CAROUSEL', label: 'Carrossel', icon: <LayoutGrid className="w-4 h-4" />, color: '#ef4444' },
]
// ─── Helpers ──────────────────────────────────────────────────────────────────
function uid() { return `id_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` }
function buildPayload(type: MessageType, form: FormState): RichPayload {
switch (type) {
case 'TEXT':
return { text: form.text }
case 'BUTTONS':
return {
text: form.btnText || undefined,
footer: form.btnFooter || undefined,
nativeButtons: form.buttons.filter(b => b.id && b.text).map(b => ({
type: 'reply' as const, id: b.id, text: b.text,
})),
}
case 'INTERACTIVE':
return {
text: form.ctaText || undefined,
nativeButtons: form.ctaButtons.filter(b => b.text && b.extra).map(b => {
if (b.type === 'url') return { type: 'url' as const, text: b.text, url: b.extra }
if (b.type === 'copy') return { type: 'copy' as const, text: b.text, copyText: b.extra }
return { type: 'call' as const, text: b.text, phoneNumber: b.extra }
}),
}
case 'LIST': {
const sections = form.listSections
.map(s => ({ title: s.title, rows: s.rows.filter(r => r.id && r.title) }))
.filter(s => s.rows.length > 0)
return {
text: form.listText || undefined,
nativeList: {
buttonText: form.listButtonText || 'Ver opções',
sections,
},
}
}
case 'POLL':
return {
poll: {
name: form.pollName || 'Enquete',
values: form.pollOptions.filter(Boolean),
selectableCount: form.pollSelectable,
},
}
case 'CAROUSEL': {
const cards = form.cards.map(c => ({
title: c.title || undefined,
body: c.body || undefined,
footer: c.footer || undefined,
image: c.imageUrl ? { url: c.imageUrl } : undefined,
buttons: c.buttons.filter(b => b.id && b.text).map(b => ({ type: 'reply' as const, id: b.id, text: b.text })),
}))
return {
text: form.carouselText || undefined,
nativeCarousel: { cards },
}
}
}
}
// ─── Sub-forms ────────────────────────────────────────────────────────────────
function TextForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
return (
<div className="space-y-3">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Mensagem</label>
<textarea
rows={5}
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30 resize-none"
placeholder="Digite o texto da mensagem..."
value={form.text}
onChange={e => set({ text: e.target.value })}
/>
</div>
)
}
function ButtonsForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const add = () => {
if (form.buttons.length >= 3) return
set({ buttons: [...form.buttons, { id: uid(), text: '' }] })
}
const remove = (i: number) => set({ buttons: form.buttons.filter((_, idx) => idx !== i) })
const update = (i: number, field: keyof BtnItem, val: string) => {
const btns = [...form.buttons]; btns[i] = { ...btns[i], [field]: val }; set({ buttons: btns })
}
return (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30 resize-none"
placeholder="Corpo da mensagem" value={form.btnText} onChange={e => set({ btnText: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Rodapé</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
placeholder="Texto de rodapé (opcional)" value={form.btnFooter} onChange={e => set({ btnFooter: e.target.value })} />
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Botões (máx. 3)</label>
{form.buttons.map((btn, i) => (
<div key={btn.id} className="flex gap-2">
<input className="flex-1 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
placeholder="ID do botão" value={btn.id} onChange={e => update(i, 'id', e.target.value)} />
<input className="flex-[2] rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
placeholder="Texto visível" value={btn.text} onChange={e => update(i, 'text', e.target.value)} />
<button onClick={() => remove(i)} disabled={form.buttons.length <= 1}
className="p-2 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{form.buttons.length < 3 && (
<button onClick={add} className="flex items-center gap-1 text-xs text-[#0ea5e9] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar botão
</button>
)}
</div>
</div>
)
}
function InteractiveForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const add = () => {
if (form.ctaButtons.length >= 3) return
set({ ctaButtons: [...form.ctaButtons, { type: 'url', text: '', extra: '' }] })
}
const remove = (i: number) => set({ ctaButtons: form.ctaButtons.filter((_, idx) => idx !== i) })
const update = (i: number, field: keyof CtaItem, val: string) => {
const btns = [...form.ctaButtons]; btns[i] = { ...btns[i], [field]: val as any }; set({ ctaButtons: btns })
}
const placeholders: Record<string, string> = { url: 'https://...', copy: 'Código do cupom', call: '5511999999999' }
const labels: Record<string, string> = { url: 'URL', copy: 'Código a copiar', call: 'Número de telefone' }
return (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30 resize-none"
placeholder="Corpo da mensagem" value={form.ctaText} onChange={e => set({ ctaText: e.target.value })} />
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Botões CTA (máx. 3)</label>
{form.ctaButtons.map((btn, i) => (
<div key={i} className="rounded-xl border border-gray-200 bg-[#f8f9fa] p-3 space-y-2">
<div className="flex gap-2 items-start">
<select value={btn.type} onChange={e => update(i, 'type', e.target.value)}
className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30">
<option value="url">🌐 URL</option>
<option value="copy">📋 Copiar</option>
<option value="call">📞 Ligar</option>
</select>
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"
placeholder="Texto do botão" value={btn.text} onChange={e => update(i, 'text', e.target.value)} />
<button onClick={() => remove(i)} disabled={form.ctaButtons.length <= 1}
className="p-1.5 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<input className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"
placeholder={placeholders[btn.type]} value={btn.extra} onChange={e => update(i, 'extra', e.target.value)} />
<span className="text-[10px] text-gray-400">{labels[btn.type]}</span>
</div>
))}
{form.ctaButtons.length < 3 && (
<button onClick={add} className="flex items-center gap-1 text-xs text-[#8b5cf6] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar botão CTA
</button>
)}
</div>
</div>
)
}
function ListForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const addSection = () => {
set({ listSections: [...form.listSections, { title: `Seção ${form.listSections.length + 1}`, rows: [{ id: uid(), title: '', description: '' }] }] })
}
const removeSection = (si: number) => set({ listSections: form.listSections.filter((_, i) => i !== si) })
const updateSection = (si: number, title: string) => {
const ss = [...form.listSections]; ss[si] = { ...ss[si], title }; set({ listSections: ss })
}
const addRow = (si: number) => {
const ss = [...form.listSections]
ss[si] = { ...ss[si], rows: [...ss[si].rows, { id: uid(), title: '', description: '' }] }
set({ listSections: ss })
}
const removeRow = (si: number, ri: number) => {
const ss = [...form.listSections]
ss[si] = { ...ss[si], rows: ss[si].rows.filter((_, i) => i !== ri) }
set({ listSections: ss })
}
const updateRow = (si: number, ri: number, field: keyof ListRow, val: string) => {
const ss = [...form.listSections]
const rows = [...ss[si].rows]; rows[ri] = { ...rows[ri], [field]: val }
ss[si] = { ...ss[si], rows }; set({ listSections: ss })
}
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none resize-none"
placeholder="Corpo da mensagem" value={form.listText} onChange={e => set({ listText: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto do Botão</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
placeholder="Ver opções" value={form.listButtonText} onChange={e => set({ listButtonText: e.target.value })} />
</div>
</div>
<div className="space-y-3">
{form.listSections.map((sec, si) => (
<div key={si} className="rounded-xl border border-amber-100 bg-amber-50/50 p-3 space-y-2">
<div className="flex items-center gap-2">
<input className="flex-1 rounded-lg border border-amber-200 bg-white px-2 py-1.5 text-xs font-medium focus:outline-none"
placeholder="Título da seção" value={sec.title} onChange={e => updateSection(si, e.target.value)} />
<button onClick={() => removeSection(si)} disabled={form.listSections.length <= 1}
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{sec.rows.map((row, ri) => (
<div key={ri} className="flex gap-2">
<input className="w-20 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="ID" value={row.id} onChange={e => updateRow(si, ri, 'id', e.target.value)} />
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Título" value={row.title} onChange={e => updateRow(si, ri, 'title', e.target.value)} />
<input className="flex-[2] rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Descrição" value={row.description} onChange={e => updateRow(si, ri, 'description', e.target.value)} />
<button onClick={() => removeRow(si, ri)} disabled={sec.rows.length <= 1}
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
<button onClick={() => addRow(si)} className="flex items-center gap-1 text-xs text-amber-600 hover:underline font-medium">
<Plus className="w-3 h-3" /> Adicionar item
</button>
</div>
))}
<button onClick={addSection} className="flex items-center gap-1 text-xs text-amber-600 hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar seção
</button>
</div>
</div>
)
}
function PollForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const addOption = () => set({ pollOptions: [...form.pollOptions, ''] })
const removeOption = (i: number) => {
if (form.pollOptions.length <= 2) return
set({ pollOptions: form.pollOptions.filter((_, idx) => idx !== i) })
}
const updateOption = (i: number, val: string) => {
const opts = [...form.pollOptions]; opts[i] = val; set({ pollOptions: opts })
}
return (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Pergunta</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
placeholder="Qual a sua preferência?" value={form.pollName} onChange={e => set({ pollName: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Máx. seleções</label>
<input type="number" min={1} max={form.pollOptions.filter(Boolean).length || 1}
className="w-24 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
value={form.pollSelectable} onChange={e => set({ pollSelectable: parseInt(e.target.value) || 1 })} />
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Opções (mín. 2)</label>
{form.pollOptions.map((opt, i) => (
<div key={i} className="flex gap-2">
<input className="flex-1 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
placeholder={`Opção ${i + 1}`} value={opt} onChange={e => updateOption(i, e.target.value)} />
<button onClick={() => removeOption(i)} disabled={form.pollOptions.length <= 2}
className="p-2 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{form.pollOptions.length < 12 && (
<button onClick={addOption} className="flex items-center gap-1 text-xs text-[#10b981] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar opção
</button>
)}
</div>
</div>
)
}
function CarouselForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const addCard = () => {
if (form.cards.length >= 10) return
set({ cards: [...form.cards, { title: '', body: '', footer: '', imageUrl: '', buttons: [{ id: uid(), text: '' }] }] })
}
const removeCard = (ci: number) => set({ cards: form.cards.filter((_, i) => i !== ci) })
const updateCard = (ci: number, field: keyof CarouselCard, val: any) => {
const cards = [...form.cards]; cards[ci] = { ...cards[ci], [field]: val }; set({ cards })
}
const addCardBtn = (ci: number) => {
const cards = [...form.cards]; cards[ci].buttons.push({ id: uid(), text: '' }); set({ cards })
}
const removeCardBtn = (ci: number, bi: number) => {
const cards = [...form.cards]; cards[ci].buttons = cards[ci].buttons.filter((_, i) => i !== bi); set({ cards })
}
const updateCardBtn = (ci: number, bi: number, field: keyof CarouselBtn, val: string) => {
const cards = [...form.cards]; cards[ci].buttons[bi] = { ...cards[ci].buttons[bi], [field]: val }; set({ cards })
}
const [expanded, setExpanded] = useState<number[]>([0])
const toggle = (i: number) => setExpanded(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i])
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto acima</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
placeholder="Ofertas especiais" value={form.carouselText} onChange={e => set({ carouselText: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Rodapé</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
placeholder="Rodapé geral" value={form.carouselFooter} onChange={e => set({ carouselFooter: e.target.value })} />
</div>
</div>
<div className="space-y-2 max-h-[320px] overflow-y-auto pr-1">
{form.cards.map((card, ci) => (
<div key={ci} className="rounded-xl border border-gray-200 bg-[#f8f9fa] overflow-hidden">
<button onClick={() => toggle(ci)}
className="w-full flex items-center justify-between px-3 py-2.5 text-sm font-medium text-[#111b21] hover:bg-gray-100 transition-colors">
<span>Card {ci + 1}{card.title ? `${card.title}` : ''}</span>
<div className="flex items-center gap-2">
{form.cards.length > 1 && (
<button onClick={(e) => { e.stopPropagation(); removeCard(ci) }}
className="p-1 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{expanded.includes(ci) ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
</div>
</button>
{expanded.includes(ci) && (
<div className="px-3 pb-3 space-y-2 border-t border-gray-100 pt-2">
<div className="grid grid-cols-2 gap-2">
<input className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Título" value={card.title} onChange={e => updateCard(ci, 'title', e.target.value)} />
<input className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Rodapé" value={card.footer} onChange={e => updateCard(ci, 'footer', e.target.value)} />
</div>
<textarea rows={2} className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none resize-none"
placeholder="Descrição/corpo" value={card.body} onChange={e => updateCard(ci, 'body', e.target.value)} />
<input className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="URL da imagem (opcional)" value={card.imageUrl} onChange={e => updateCard(ci, 'imageUrl', e.target.value)} />
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-gray-400 uppercase">Botões do card</span>
{card.buttons.map((btn, bi) => (
<div key={bi} className="flex gap-1.5">
<input className="w-20 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="ID" value={btn.id} onChange={e => updateCardBtn(ci, bi, 'id', e.target.value)} />
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="Texto" value={btn.text} onChange={e => updateCardBtn(ci, bi, 'text', e.target.value)} />
<button onClick={() => removeCardBtn(ci, bi)} disabled={card.buttons.length <= 1}
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
<button onClick={() => addCardBtn(ci)} className="flex items-center gap-1 text-[11px] text-red-500 hover:underline font-medium">
<Plus className="w-3 h-3" /> Botão no card
</button>
</div>
</div>
)}
</div>
))}
</div>
{form.cards.length < 10 && (
<button onClick={addCard} className="flex items-center gap-1 text-xs text-[#ef4444] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar card
</button>
)}
</div>
)
}
// ─── Modal principal ──────────────────────────────────────────────────────────
interface Props {
isOpen: boolean
onClose: () => void
instanceId: string | null
jid: string
onSent?: () => void
onSaveTemplate?: (type: TemplateType, payload: Record<string, unknown>, name?: string) => void
onSchedule?: (type: TemplateType, payload: Record<string, unknown>) => void
initialPayload?: Record<string, unknown>
initialType?: TemplateType
}
export default function RichMessageComposer({
isOpen, onClose, instanceId, jid, onSent, onSaveTemplate, onSchedule,
initialPayload, initialType = 'TEXT',
}: Props) {
const [type, setType] = useState<MessageType>(initialType)
const [form, setFormState] = useState<FormState>(defaultForm)
const [sending, setSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [savedName, setSavedName] = useState('')
const [showSaveName, setShowSaveName] = useState(false)
const set = useCallback((partial: Partial<FormState>) => {
setFormState(prev => ({ ...prev, ...partial }))
}, [])
const handleSend = async () => {
if (!instanceId || !jid) return
// Validações de frontend antes de enviar
if (type === 'CAROUSEL' && form.cards.length < 2) {
setError('Carrossel requer no mínimo 2 cards')
return
}
if (type === 'CAROUSEL' && form.cards.some(c => c.buttons.filter(b => b.id && b.text).length === 0)) {
setError('Cada card do carrossel precisa ter pelo menos 1 botão')
return
}
setError(null)
setSending(true)
try {
const payload = buildPayload(type, form)
await richMessageApi.send(instanceId, jid, payload)
onSent?.()
onClose()
} catch (e: any) {
setError(e?.response?.data?.error ?? e?.message ?? 'Erro ao enviar')
} finally {
setSending(false)
}
}
const handleSave = async () => {
if (!savedName.trim()) { setShowSaveName(true); return }
setSaving(true)
try {
const payload = buildPayload(type, form) as Record<string, unknown>
await templateApi.create({ name: savedName.trim(), type, payload, tags: [] })
setShowSaveName(false)
setSavedName('')
} finally {
setSaving(false)
}
}
const handleSchedule = () => {
const payload = buildPayload(type, form) as Record<string, unknown>
onSchedule?.(type, payload)
}
const activeTab = TYPE_TABS.find(t => t.type === type)!
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
if (!mounted) return null
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 40, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-xl max-h-[92vh] flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
<div>
<h2 className="text-base font-semibold text-[#111b21]">Mensagem Rica</h2>
<p className="text-xs text-[#667781] mt-0.5">Compose um tipo avançado de mensagem WhatsApp</p>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Type tabs */}
<div className="px-5 pt-3 pb-2 shrink-0">
<div className="flex gap-1 bg-[#f0f2f5] rounded-xl p-1">
{TYPE_TABS.map(tab => (
<button
key={tab.type}
onClick={() => setType(tab.type)}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-[11px] font-semibold transition-all ${
type === tab.type
? 'bg-white shadow-sm text-[#111b21]'
: 'text-[#667781] hover:text-[#111b21]'
}`}
style={type === tab.type ? { color: tab.color } : {}}
>
{tab.icon}
<span className="hidden sm:block">{tab.label}</span>
</button>
))}
</div>
</div>
{/* Form area */}
<div className="flex-1 overflow-y-auto px-5 py-3">
{type === 'TEXT' && <TextForm form={form} set={set} />}
{type === 'BUTTONS' && <ButtonsForm form={form} set={set} />}
{type === 'INTERACTIVE' && <InteractiveForm form={form} set={set} />}
{type === 'LIST' && <ListForm form={form} set={set} />}
{type === 'POLL' && <PollForm form={form} set={set} />}
{type === 'CAROUSEL' && <CarouselForm form={form} set={set} />}
</div>
{/* Save name input */}
<AnimatePresence>
{showSaveName && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="px-5 py-2 border-t border-gray-100 bg-[#f8f9fa] overflow-hidden shrink-0"
>
<div className="flex gap-2 items-center">
<input
autoFocus
className="flex-1 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30"
placeholder="Nome do template..."
value={savedName}
onChange={e => setSavedName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') setShowSaveName(false) }}
/>
<button onClick={handleSave} disabled={!savedName.trim() || saving}
className="px-3 py-2 bg-[#00a884] text-white rounded-xl text-sm font-medium disabled:opacity-50 hover:bg-[#008f72] transition-colors">
{saving ? '...' : 'Salvar'}
</button>
<button onClick={() => setShowSaveName(false)} className="p-2 rounded-full hover:bg-gray-200 text-gray-400">
<X className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Error */}
{error && (
<div className="mx-5 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-xl text-xs text-red-600">
{error}
</div>
)}
{/* Footer actions */}
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-between shrink-0">
<div className="flex items-center gap-1">
<button
onClick={() => setShowSaveName(!showSaveName)}
title="Salvar como template"
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium text-[#667781] hover:bg-gray-100 transition-colors"
>
<BookmarkPlus className="w-4 h-4" />
<span className="hidden sm:block">Salvar template</span>
</button>
{onSchedule && (
<button
onClick={handleSchedule}
title="Agendar envio"
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium text-[#667781] hover:bg-gray-100 transition-colors"
>
<Calendar className="w-4 h-4" />
<span className="hidden sm:block">Agendar</span>
</button>
)}
</div>
<div className="flex items-center gap-2">
<button onClick={onClose}
className="px-4 py-2 rounded-xl text-sm font-medium text-[#667781] hover:bg-gray-100 transition-colors">
Cancelar
</button>
<button
onClick={handleSend}
disabled={sending || !instanceId || !jid}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold text-white disabled:opacity-50 transition-all active:scale-95"
style={{ backgroundColor: activeTab.color }}
>
{sending ? (
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Enviar
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
)
}
@@ -0,0 +1,506 @@
'use client';
/**
* ScheduleMessageModal modal completo de agendamentos de mensagem.
*
* Modos:
* ONCE data/hora específica (datetime-local)
* RECURRING expressão cron com presets visuais
* EVENT BIRTHDAY / ANNIVERSARY / SIGNUP_DAYS
* vinculado a dados de recipients (birthday, anniversary, signupDate)
*
* Suporte a variáveis de personalização: {{name}}, {{birthday}}, etc.
* Destinatários: lista manual de JIDs com nome e dados de evento.
* Variáveis substituídas em runtime pelo SchedulerService.
*/
import React, { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Calendar, RotateCw, Gift, Plus, Trash2, Clock,
CheckCircle2, AlertTriangle, ChevronRight, Users,
} from 'lucide-react'
import {
scheduleApi, instanceApi,
type ScheduleType, type EventType, type ScheduleRecipient, type TemplateType,
} from '../services/chatApiService'
// ─── CRON presets ─────────────────────────────────────────────────────────────
const CRON_PRESETS = [
{ label: 'Diariamente às 8h', expr: '0 8 * * *' },
{ label: 'Seg a Sex às 9h', expr: '0 9 * * 1-5' },
{ label: 'Toda segunda às 10h', expr: '0 10 * * 1' },
{ label: 'Quinzenal (dia 1/15)', expr: '0 9 1,15 * *' },
{ label: 'Mensal (dia 1)', expr: '0 9 1 * *' },
{ label: 'Semanal domingo 11h', expr: '0 11 * * 0' },
]
// ─── Tipos locais ─────────────────────────────────────────────────────────────
interface RecipientRow extends ScheduleRecipient {
_key: string
}
interface Props {
isOpen: boolean
onClose: () => void
payload: Record<string, unknown>
messageType: TemplateType
onSaved?: () => void
}
function uid() { return `k_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` }
// ─── Component ────────────────────────────────────────────────────────────────
export default function ScheduleMessageModal({ isOpen, onClose, payload, messageType, onSaved }: Props) {
const [step, setStep] = useState<1 | 2 | 3>(1) // 1=tipo, 2=destinatários, 3=confirmação
// Step 1 — configuração de schedule
const [scheduleType, setScheduleType] = useState<ScheduleType>('ONCE')
const [scheduledAt, setScheduledAt] = useState('')
const [cronExpr, setCronExpr] = useState('0 9 * * 1-5')
const [eventType, setEventType] = useState<EventType>('BIRTHDAY')
const [daysAfter, setDaysAfter] = useState('30')
const [name, setName] = useState('')
const [timezone] = useState('America/Sao_Paulo')
// Step 2 — destinatários
const [recipients, setRecipients] = useState<RecipientRow[]>([
{ _key: uid(), jid: '', name: '', birthday: '', anniversary: '', signupDate: '' },
])
// Step 3 — resultado
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [instanceId, setInstanceId] = useState('')
const [instances, setInstances] = useState<Array<{ id: string; name: string; status: string }>>([])
useEffect(() => {
if (isOpen) {
instanceApi.list().then((list: any[]) => setInstances(list))
}
}, [isOpen])
const addRecipient = () => {
setRecipients(prev => [...prev, { _key: uid(), jid: '', name: '', birthday: '', anniversary: '', signupDate: '' }])
}
const removeRecipient = (key: string) => {
setRecipients(prev => prev.filter(r => r._key !== key))
}
const updateRecipient = (key: string, field: keyof ScheduleRecipient, val: string) => {
setRecipients(prev => prev.map(r => r._key === key ? { ...r, [field]: val } : r))
}
const isStep1Valid = () => {
if (!name.trim() || !instanceId) return false
if (scheduleType === 'ONCE' && !scheduledAt) return false
if (scheduleType === 'RECURRING' && !cronExpr.trim()) return false
return true
}
const validRecipients = recipients.filter(r => r.jid.trim())
const buildScheduleData = () => ({
instanceId,
name: name.trim(),
payload,
recipients: validRecipients.map(({ _key, ...r }) => ({
...r,
jid: r.jid.includes('@') ? r.jid : `${r.jid.replace(/\D/g, '')}@s.whatsapp.net`,
})),
scheduleType,
scheduledAt: scheduleType === 'ONCE' ? scheduledAt : undefined,
cronExpr: scheduleType === 'RECURRING' ? cronExpr
: scheduleType === 'EVENT' && eventType === 'SIGNUP_DAYS' ? daysAfter
: undefined,
eventType: scheduleType === 'EVENT' ? eventType : undefined,
timezone,
})
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await scheduleApi.create(buildScheduleData())
setSaved(true)
onSaved?.()
setTimeout(() => { setSaved(false); onClose(); setStep(1) }, 2000)
} catch (e: any) {
setError(e?.response?.data?.error ?? e?.message ?? 'Erro ao salvar')
} finally {
setSaving(false)
}
}
// Rótulo do tipo de mensagem
const typeLabels: Record<TemplateType, string> = {
TEXT: 'Texto', BUTTONS: 'Botões', INTERACTIVE: 'CTA', LIST: 'Lista', POLL: 'Enquete', CAROUSEL: 'Carrossel',
}
const minDateTime = new Date(Date.now() + 60_000).toISOString().slice(0, 16)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
if (!mounted) return null
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 30, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-[#fef3c7] flex items-center justify-center">
<Calendar className="w-5 h-5 text-[#f59e0b]" />
</div>
<div>
<h2 className="text-base font-semibold text-[#111b21]">Agendar Mensagem</h2>
<p className="text-xs text-[#667781]">Tipo: {typeLabels[messageType]}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Step indicator */}
<div className="px-5 py-3 flex items-center gap-2 shrink-0 border-b border-gray-100">
{[1, 2, 3].map(s => (
<React.Fragment key={s}>
<button
onClick={() => s < step ? setStep(s as any) : undefined}
className={`w-7 h-7 rounded-full text-xs font-bold flex items-center justify-center transition-all ${
step === s ? 'bg-[#f59e0b] text-white shadow-md scale-105' :
step > s ? 'bg-[#10b981] text-white' : 'bg-gray-100 text-gray-400'
}`}
>
{step > s ? <CheckCircle2 className="w-4 h-4" /> : s}
</button>
{s < 3 && <ChevronRight className="w-4 h-4 text-gray-300" />}
</React.Fragment>
))}
<span className="ml-2 text-xs text-[#667781]">
{step === 1 ? 'Configurar disparo' : step === 2 ? 'Destinatários' : 'Confirmar'}
</span>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{/* ── STEP 1: schedule config ─────────────────────────────── */}
{step === 1 && (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Nome do agendamento</label>
<input
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#f59e0b]/30"
placeholder="Ex: Aniversários de Abril"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Instância WhatsApp</label>
<select
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#f59e0b]/30"
value={instanceId}
onChange={e => setInstanceId(e.target.value)}
>
<option value="">Selecionar instância...</option>
{instances.map((inst: any) => (
<option key={inst.id} value={inst.id} disabled={inst.status !== 'CONNECTED'}>
{inst.name} {inst.status !== 'CONNECTED' ? '(desconectada)' : ''}
</option>
))}
</select>
</div>
{/* Schedule type selector */}
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Tipo de agendamento</label>
<div className="grid grid-cols-3 gap-2">
{[
{ type: 'ONCE' as ScheduleType, label: 'Uma vez', icon: <Clock className="w-4 h-4" />, color: '#0ea5e9' },
{ type: 'RECURRING' as ScheduleType, label: 'Recorrente', icon: <RotateCw className="w-4 h-4" />, color: '#8b5cf6' },
{ type: 'EVENT' as ScheduleType, label: 'Evento', icon: <Gift className="w-4 h-4" />, color: '#ef4444' },
].map(opt => (
<button
key={opt.type}
onClick={() => setScheduleType(opt.type)}
className={`flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 transition-all text-sm font-semibold ${
scheduleType === opt.type
? 'border-current shadow-sm'
: 'border-gray-200 text-gray-400 hover:border-gray-300'
}`}
style={scheduleType === opt.type ? { borderColor: opt.color, color: opt.color, backgroundColor: opt.color + '10' } : {}}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
{/* ONCE — datetime */}
{scheduleType === 'ONCE' && (
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Data e hora</label>
<input
type="datetime-local"
min={minDateTime}
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
value={scheduledAt}
onChange={e => setScheduledAt(e.target.value)}
/>
</div>
)}
{/* RECURRING — cron */}
{scheduleType === 'RECURRING' && (
<div className="space-y-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Expressão Cron</label>
<input
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"
placeholder="0 9 * * 1-5"
value={cronExpr}
onChange={e => setCronExpr(e.target.value)}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-[#667781] font-medium">Presets:</p>
<div className="flex flex-wrap gap-1.5">
{CRON_PRESETS.map(p => (
<button
key={p.expr}
onClick={() => setCronExpr(p.expr)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${
cronExpr === p.expr
? 'bg-[#8b5cf6] text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{p.label}
</button>
))}
</div>
</div>
</div>
)}
{/* EVENT — tipo */}
{scheduleType === 'EVENT' && (
<div className="space-y-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Tipo de evento</label>
<div className="grid grid-cols-3 gap-2">
{[
{ type: 'BIRTHDAY' as EventType, label: '🎂 Aniversário' },
{ type: 'ANNIVERSARY' as EventType, label: '💍 Aniversariante' },
{ type: 'SIGNUP_DAYS' as EventType, label: '📅 X dias após cadastro' },
].map(opt => (
<button
key={opt.type}
onClick={() => setEventType(opt.type)}
className={`py-2.5 px-2 rounded-xl border-2 text-xs font-semibold transition-all ${
eventType === opt.type
? 'border-[#ef4444] bg-[#fee2e2] text-[#ef4444]'
: 'border-gray-200 text-gray-500 hover:border-gray-300'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{eventType === 'SIGNUP_DAYS' && (
<div className="flex items-center gap-3">
<label className="text-xs font-semibold text-[#667781] uppercase tracking-wider whitespace-nowrap">Dias após cadastro</label>
<input
type="number" min="1" max="365"
className="w-24 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#ef4444]/30"
value={daysAfter}
onChange={e => setDaysAfter(e.target.value)}
/>
</div>
)}
<div className="rounded-xl bg-amber-50 border border-amber-200 px-3 py-2.5 text-xs text-amber-700">
<strong>Variáveis disponíveis:</strong> {'{{name}}, {{birthday}}, {{anniversary}}'}<br />
São substituídas automaticamente por contato no envio.
</div>
</div>
)}
</div>
)}
{/* ── STEP 2: recipients ─────────────────────────────────── */}
{step === 2 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-[#667781]" />
<span className="text-sm font-semibold text-[#111b21]">Destinatários</span>
<span className="text-xs text-[#667781]">({validRecipients.length} válido{validRecipients.length !== 1 ? 's' : ''})</span>
</div>
<button onClick={addRecipient} className="flex items-center gap-1 text-xs text-[#00a884] font-medium hover:underline">
<Plus className="w-3.5 h-3.5" /> Adicionar
</button>
</div>
<div className="space-y-3 max-h-[380px] overflow-y-auto pr-1">
{recipients.map((r) => (
<div key={r._key} className="rounded-xl border border-gray-200 bg-[#f8f9fa] p-3 space-y-2">
<div className="flex gap-2 items-start">
<input
className="flex-[2] rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-[#00a884]"
placeholder="Número ou JID ex: 5511999999999"
value={r.jid}
onChange={e => updateRecipient(r._key, 'jid', e.target.value)}
/>
<input
className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-[#00a884]"
placeholder="Nome ({{name}})"
value={r.name ?? ''}
onChange={e => updateRecipient(r._key, 'name', e.target.value)}
/>
<button onClick={() => removeRecipient(r._key)} disabled={recipients.length <= 1}
className="p-1.5 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{scheduleType === 'EVENT' && (
<div className="grid grid-cols-3 gap-1.5">
{(eventType === 'BIRTHDAY' || eventType === 'ANNIVERSARY') && (
<>
<input
type="date"
className="rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="Aniversário"
value={eventType === 'BIRTHDAY' ? (r.birthday ?? '') : (r.anniversary ?? '')}
onChange={e => updateRecipient(r._key, eventType === 'BIRTHDAY' ? 'birthday' : 'anniversary', e.target.value)}
/>
</>
)}
{eventType === 'SIGNUP_DAYS' && (
<input
type="date"
className="col-span-2 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="Data de cadastro"
value={r.signupDate ?? ''}
onChange={e => updateRecipient(r._key, 'signupDate', e.target.value)}
/>
)}
</div>
)}
</div>
))}
</div>
{validRecipients.length === 0 && (
<div className="flex items-center gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
Adicione ao menos um destinatário com número válido.
</div>
)}
</div>
)}
{/* ── STEP 3: confirm ─────────────────────────────────────── */}
{step === 3 && (
<div className="space-y-4">
{saved ? (
<div className="flex flex-col items-center justify-center py-10 gap-3">
<CheckCircle2 className="w-14 h-14 text-[#10b981]" />
<p className="text-base font-semibold text-[#111b21]">Agendamento criado!</p>
<p className="text-xs text-[#667781] text-center">O disparo será executado conforme configurado.</p>
</div>
) : (
<>
<div className="rounded-xl bg-[#f8f9fa] border border-gray-200 p-4 space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-[#667781]">Nome</span>
<span className="font-medium text-[#111b21]">{name}</span>
</div>
<div className="flex justify-between">
<span className="text-[#667781]">Tipo de mensagem</span>
<span className="font-medium text-[#111b21]">{typeLabels[messageType]}</span>
</div>
<div className="flex justify-between">
<span className="text-[#667781]">Disparo</span>
<span className="font-medium text-[#111b21]">
{scheduleType === 'ONCE' && `Uma vez — ${scheduledAt.replace('T', ' ')}`}
{scheduleType === 'RECURRING' && `Recorrente — ${cronExpr}`}
{scheduleType === 'EVENT' && `Evento: ${eventType}`}
</span>
</div>
<div className="flex justify-between">
<span className="text-[#667781]">Destinatários</span>
<span className="font-medium text-[#111b21]">{validRecipients.length} contato{validRecipients.length !== 1 ? 's' : ''}</span>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
</>
)}
</div>
)}
</div>
{/* Footer */}
{!saved && (
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-between shrink-0">
<button
onClick={() => step > 1 ? setStep(prev => (prev - 1) as any) : onClose()}
className="px-4 py-2 rounded-xl text-sm font-medium text-[#667781] hover:bg-gray-100 transition-colors"
>
{step > 1 ? 'Voltar' : 'Cancelar'}
</button>
<button
onClick={() => {
if (step === 1) setStep(2)
else if (step === 2) setStep(3)
else handleSave()
}}
disabled={
(step === 1 && !isStep1Valid()) ||
(step === 2 && validRecipients.length === 0) ||
saving
}
className="flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-semibold text-white bg-[#f59e0b] hover:bg-[#d97706] disabled:opacity-50 transition-all active:scale-95"
>
{saving && <span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />}
{step < 3 ? 'Continuar' : 'Confirmar e Salvar'}
</button>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
)
}
@@ -0,0 +1,134 @@
// 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 }> = ({ instanceId }) => {
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]);
if (!instanceId) return null;
const enabled = sp ? sp.enabled : true;
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={enabled ? 'Secretária IA ligada nesta sessão — clique para gerenciar' : 'Secretária IA DESLIGADA nesta sessão'}
onClick={() => { setErro(null); setOpen(true); }}
className={`w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors ${
enabled ? 'text-emerald-600 hover:bg-[#d9dbdf]/60' : 'text-white bg-red-600 hover:bg-red-700 animate-pulse'
}`}
>
<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>
)}
</>
);
};
@@ -0,0 +1,198 @@
import React, { useEffect, useState, useCallback } from 'react';
import { ShieldCheck, LayoutGrid, Check, Loader2, Tag, AlertTriangle, Briefcase } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
interface SettingsPanelProps {
instanceId: string | null;
}
// Engines oferecidas no painel. A 'official' (Baileys 6, que dá 401) fica de fora.
// baileys7 → Baileys 7 oficial (a que conecta) → rótulo "API estável"
// infinite → InfinityAPI (com botões)
// titulo = rótulo genérico (todos os usuários). tituloTec = rótulo técnico
// (Baileys/Infinity), exibido apenas para admin/ruibto@gmail.com.
const ENGINES: {
value: string; titulo: string; tituloTec?: string; desc: string; icon: any;
iconCls: string; activeBorder: string; activeBg: string; checkCls: string; btnCls: string;
}[] = [
{
value: 'baileys7', titulo: 'API estável', tituloTec: 'API estável (Baileys 7)',
desc: 'Conexão recomendada do WhatsApp',
icon: ShieldCheck, iconCls: 'text-emerald-500',
activeBorder: 'border-emerald-400', activeBg: 'bg-emerald-50', checkCls: 'text-emerald-600',
btnCls: 'bg-emerald-500 hover:bg-emerald-600',
},
{
value: 'infinite', titulo: 'API com botões', tituloTec: 'InfinityAPI',
desc: 'Com botões interativos (experimental)',
icon: LayoutGrid, iconCls: 'text-amber-500',
activeBorder: 'border-amber-400', activeBg: 'bg-amber-50', checkCls: 'text-amber-600',
btnCls: 'bg-amber-500 hover:bg-amber-600',
},
];
export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
const user = useAuthStore((s) => s.user);
// Termos técnicos (Baileys/Infinity) só para admin ou ruibto@gmail.com.
const canSeeTech = user?.role === 'ADMIN' || user?.email === 'ruibto@gmail.com';
const [engine, setEngine] = useState<string | null>(null);
const [isBusiness, setIsBusiness] = useState<boolean | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState<string | null>(null);
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';
const authHeader = (): Record<string, string> => {
const t = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
return t ? { Authorization: `Bearer ${t}` } : {};
};
// Versão do sistema (servida em /version.json)
useEffect(() => {
fetch('/version.json')
.then((r) => (r.ok ? r.json() : null))
.then(setVersion)
.catch(() => {});
}, []);
// Engine atual da instância
useEffect(() => {
if (!instanceId) return;
setLoading(true);
fetch(`${API_URL}/api/instances`, { headers: authHeader() })
.then((r) => (r.ok ? r.json() : []))
.then((list) => {
const inst = Array.isArray(list) ? list.find((i: any) => i.id === instanceId) : null;
setEngine(inst?.engine ?? null);
setIsBusiness(inst ? !!inst.isBusiness : null);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [instanceId]);
const selectEngine = useCallback(async (value: string) => {
if (!instanceId || saving || value === engine) return;
// Trocar de engine invalida a sessão (credenciais não migram entre APIs):
// confirma porque exige escanear o QR Code novamente.
if (typeof window !== 'undefined' && !window.confirm(
'Trocar de API vai DESCONECTAR a sessão atual.\n\n' +
'Você precisará escanear o QR Code novamente para reconectar. Continuar?'
)) return;
setSaving(value);
setError(null);
try {
const res = await fetch(`${API_URL}/api/instances/${instanceId}/engine`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({ engine: value }),
});
if (res.status === 429) {
const d = await res.json().catch(() => ({}));
throw new Error(d.error ?? 'Aguarde o intervalo entre trocas.');
}
if (!res.ok) throw new Error('Falha ao trocar a API.');
setEngine(value);
} catch (e: any) {
setError(e?.message ?? 'Erro ao trocar a API.');
} finally {
setSaving(null);
}
}, [instanceId, saving, engine]);
return (
<div className="relative z-[1] flex-1 h-full overflow-y-auto bg-[#f0f2f5] p-6 md:p-10">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-light text-[#41525d] mb-2">Configurações</h1>
<p className="text-base text-[#667781] mb-4">Escolha a API de conexão do WhatsApp para esta instância.</p>
<div className="flex items-start gap-3 rounded-xl bg-amber-50 border border-amber-200 p-4 mb-6">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<p className="text-sm text-[#5b4b1f]">
Trocar de API <strong>desconecta a sessão</strong> será necessário <strong>escanear o QR Code novamente</strong> para reconectar.
</p>
</div>
{!instanceId && (
<p className="text-base text-[#667781] mb-6">Selecione uma instância primeiro.</p>
)}
{/* ── Engines ─────────────────────────────────────────────── */}
<div className="flex flex-col gap-4 mb-6">
{ENGINES.map((opt) => {
const Icon = opt.icon;
const ativa = engine === opt.value;
const salvando = saving === opt.value;
return (
<div
key={opt.value}
className={`rounded-2xl border-2 bg-white p-8 transition-colors ${ativa ? `${opt.activeBorder} ${opt.activeBg}` : 'border-[#e9edef]'}`}
>
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${ativa ? 'bg-white' : 'bg-[#f0f2f5]'}`}>
<Icon className={`w-8 h-8 ${opt.iconCls}`} />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-semibold text-[#111b21]">{canSeeTech && opt.tituloTec ? opt.tituloTec : opt.titulo}</h2>
<p className="text-base text-[#667781]">{opt.desc}</p>
</div>
{ativa ? (
<span className={`flex items-center gap-1 font-medium text-lg ${opt.checkCls}`}>
<Check className="w-6 h-6" /> Ativa
</span>
) : (
<button
onClick={() => selectEngine(opt.value)}
disabled={!instanceId || !!saving}
className={`inline-flex items-center gap-2 rounded-xl text-white text-lg font-medium px-6 py-3 disabled:opacity-60 ${opt.btnCls}`}
>
{salvando ? <Loader2 className="w-5 h-5 animate-spin" /> : <Icon className="w-5 h-5" />}
Ativar
</button>
)}
</div>
</div>
);
})}
</div>
{loading && <p className="text-sm text-[#8696a0] mb-4">Carregando configuração atual</p>}
{error && <p className="text-sm text-red-500 mb-4">{error}</p>}
{/* ── Conta Business ──────────────────────────────────────── */}
<div className="rounded-2xl border-2 border-[#e9edef] bg-white p-8 mb-6">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-[#f0f2f5] flex items-center justify-center">
<Briefcase className="w-8 h-8 text-[#54656f]" />
</div>
<div className="flex-1">
<h2 className="text-2xl font-semibold text-[#111b21]">Conta Business</h2>
<p className="text-xl font-medium text-[#41525d] mt-1">
{isBusiness === null ? '—' : isBusiness ? 'Sim (WhatsApp Business)' : 'Não (conta pessoal)'}
</p>
<p className="text-xs text-[#8696a0] mt-1">
Business App Business API botões são garantidos na Business API oficial.
</p>
</div>
</div>
</div>
{/* ── Versão ──────────────────────────────────────────────── */}
<div className="rounded-2xl border-2 border-[#e9edef] bg-white p-8">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-[#f0f2f5] flex items-center justify-center">
<Tag className="w-8 h-8 text-[#54656f]" />
</div>
<div className="flex-1">
<h2 className="text-2xl font-semibold text-[#111b21]">Versão</h2>
<p className="text-3xl font-light text-[#41525d] mt-1">{version?.version ?? '—'}</p>
{version?.buildCount != null && (
<p className="text-base text-[#667781]">build {version.buildCount}</p>
)}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,228 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
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 {
instanceId: string;
jid: string;
onSend: (blob: Blob) => void;
}
// Converte URL Wasabi → URL do proxy do backend
function stickerSrcUrl(wasabiPath: string): string {
const clean = wasabiPath.startsWith('/') ? wasabiPath.slice(1) : wasabiPath;
return `${API}/api/storage/view/${clean}`;
}
// Carrega sticker: tenta IndexedDB, depois proxy, depois persiste no cache
function useStickerBlob(sticker: StickerRecord) {
const [dataUrl, setDataUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getStickerFromCache(sticker.sha256).then((cached) => {
if (cancelled) return;
if (cached) { setDataUrl(cached); return; }
// Carrega do proxy e converte para dataURL via canvas
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
if (cancelled) return;
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || 96;
canvas.height = img.naturalHeight || 96;
const ctx = canvas.getContext('2d');
if (!ctx) { setDataUrl(img.src); return; }
ctx.drawImage(img, 0, 0);
const url = canvas.toDataURL('image/webp', 0.9);
setDataUrl(url);
setStickerInCache(sticker.sha256, url);
} catch {
setDataUrl(img.src);
}
};
img.onerror = () => { if (!cancelled) setDataUrl(null); };
img.src = stickerSrcUrl(sticker.wasabiPath);
});
return () => { cancelled = true; };
}, [sticker.sha256, sticker.wasabiPath]);
return dataUrl;
}
// Item individual de sticker
function StickerItem({
sticker,
onSend,
onToggleFavorite,
}: {
sticker: StickerRecord;
onSend: (s: StickerRecord) => void;
onToggleFavorite: (s: StickerRecord) => void;
}) {
const dataUrl = useStickerBlob(sticker);
const [hovering, setHovering] = useState(false);
return (
<div
className="relative group cursor-pointer"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={() => dataUrl && onSend(sticker)}
>
<div className="w-16 h-16 flex items-center justify-center rounded-xl hover:bg-black/5 transition-all active:scale-90 p-1">
{dataUrl ? (
<img
src={dataUrl}
alt=""
className="w-full h-full object-contain"
loading="lazy"
/>
) : (
<div className="w-10 h-10 bg-gray-100 rounded-lg animate-pulse" />
)}
</div>
{/* Botão de favoritar — aparece no hover */}
{hovering && (
<button
className={`absolute top-0.5 right-0.5 p-0.5 rounded-full transition-all z-10 ${
sticker.isFavorite
? 'text-red-400 bg-white/90'
: 'text-gray-300 bg-white/80 hover:text-red-400'
}`}
onClick={(e) => { e.stopPropagation(); onToggleFavorite(sticker); }}
title={sticker.isFavorite ? 'Remover dos favoritos' : 'Favoritar'}
>
<Heart className="w-3 h-3" fill={sticker.isFavorite ? 'currentColor' : 'none'} />
</button>
)}
</div>
);
}
export default function StickerPanel({ instanceId, jid, onSend }: StickerPanelProps) {
const [tab, setTab] = useState<'recent' | 'favorites'>('recent');
const [stickers, setStickers] = useState<StickerRecord[]>([]);
const [loading, setLoading] = useState(true);
const fetchedRef = useRef(false);
const load = useCallback(async (force = false) => {
setLoading(true);
try {
const { listVersion, stickers: list } = await stickerApi.list();
if (!force) {
const cached = localStorage.getItem(LIST_VERSION_KEY);
if (cached === listVersion) {
// versão igual — sem novidades, mas ainda mostra o que temos
}
localStorage.setItem(LIST_VERSION_KEY, listVersion);
}
setStickers(list);
} catch {
// silencioso — sem stickers ainda
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
load();
}, [load]);
const handleSend = useCallback(async (sticker: StickerRecord) => {
const dataUrl = await getStickerFromCache(sticker.sha256);
if (!dataUrl) return;
// Converte dataURL → Blob → envia
const res = await fetch(dataUrl);
const blob = await res.blob();
onSend(blob);
}, [onSend]);
const handleToggleFavorite = useCallback(async (sticker: StickerRecord) => {
const result = await stickerApi.toggleFavorite(sticker.id);
setStickers((prev) =>
prev.map((s) => s.id === sticker.id ? { ...s, isFavorite: result.isFavorite } : s)
);
}, []);
const displayed =
tab === 'favorites'
? stickers.filter((s) => s.isFavorite)
: stickers; // recent = all sorted by lastSeenAt (backend already sorts)
return (
<div className="w-72 bg-white rounded-2xl shadow-2xl border border-gray-100 overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-gray-100">
<button
onClick={() => setTab('recent')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
tab === 'recent' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
}`}
>
<Clock className="w-3.5 h-3.5" />
Recentes
</button>
<button
onClick={() => setTab('favorites')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
tab === 'favorites' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
}`}
>
<Heart className="w-3.5 h-3.5" />
Favoritos
</button>
</div>
{/* Content */}
<div className="h-[220px] overflow-y-auto p-2">
{loading ? (
<div className="flex items-center justify-center h-full text-gray-300">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : displayed.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-gray-300">
{tab === 'favorites' ? (
<>
<Heart className="w-8 h-8" />
<span className="text-xs">Nenhum favorito ainda</span>
</>
) : (
<>
<span className="text-3xl">🙂</span>
<span className="text-xs text-center">
As figurinhas que você receber<br />aparecem aqui automaticamente
</span>
</>
)}
</div>
) : (
<div className="grid grid-cols-4 gap-0.5">
{displayed.map((s) => (
<StickerItem
key={s.id}
sticker={s}
onSend={handleSend}
onToggleFavorite={handleToggleFavorite}
/>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,271 @@
'use client';
/**
* TemplateLibrary modal de biblioteca de templates de mensagens.
*
* Permite:
* - Navegar templates por tipo
* - Pesquisar por nome
* - Selecionar um template para usar (retorna o payload ao pai)
* - Deletar templates
* - Ver contagem de uso
*/
import React, { useEffect, useState, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Search, Trash2, Clock, Type, MousePointerClick,
Link, List, BarChart2, LayoutGrid, BookOpen, CheckCircle2,
} from 'lucide-react'
import { templateApi, type MessageTemplate, type TemplateType } from '../services/chatApiService'
// ─── Helpers ─────────────────────────────────────────────────────────────────
const TYPE_META: Record<TemplateType, { label: string; icon: React.ReactNode; color: string; bg: string }> = {
TEXT: { label: 'Texto', icon: <Type className="w-3.5 h-3.5" />, color: '#667781', bg: '#f0f2f5' },
BUTTONS: { label: 'Botões', icon: <MousePointerClick className="w-3.5 h-3.5" />, color: '#0ea5e9', bg: '#e0f2fe' },
INTERACTIVE: { label: 'CTA', icon: <Link className="w-3.5 h-3.5" />, color: '#8b5cf6', bg: '#ede9fe' },
LIST: { label: 'Lista', icon: <List className="w-3.5 h-3.5" />, color: '#f59e0b', bg: '#fef3c7' },
POLL: { label: 'Enquete', icon: <BarChart2 className="w-3.5 h-3.5" />, color: '#10b981', bg: '#d1fae5' },
CAROUSEL: { label: 'Carrossel', icon: <LayoutGrid className="w-3.5 h-3.5" />, color: '#ef4444', bg: '#fee2e2' },
}
function TypeBadge({ type }: { type: TemplateType }) {
const meta = TYPE_META[type]
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold"
style={{ color: meta.color, backgroundColor: meta.bg }}>
{meta.icon}
{meta.label}
</span>
)
}
function PayloadPreview({ template }: { template: MessageTemplate }) {
const p = template.payload
if (template.type === 'TEXT') return <span className="line-clamp-2">{(p as any).text || '—'}</span>
if (template.type === 'BUTTONS') return <span className="line-clamp-1">{(p as any).text || ''} [{((p as any).nativeButtons ?? []).length} botões]</span>
if (template.type === 'INTERACTIVE') return <span className="line-clamp-1">{(p as any).text || ''} [{((p as any).nativeButtons ?? []).length} CTAs]</span>
if (template.type === 'LIST') return <span className="line-clamp-1">{(p as any).text || ''} {(p as any).nativeList?.buttonText}</span>
if (template.type === 'POLL') return <span className="line-clamp-1">📊 {(p as any).poll?.name} ({((p as any).poll?.values ?? []).length} opções)</span>
if (template.type === 'CAROUSEL') return <span className="line-clamp-1">🎠 {((p as any).nativeCarousel?.cards ?? []).length} cards</span>
return null
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface Props {
isOpen: boolean
onClose: () => void
onSelect: (template: MessageTemplate) => void
}
// ─── Component ────────────────────────────────────────────────────────────────
const ALL = 'ALL'
type FilterType = TemplateType | typeof ALL
export default function TemplateLibrary({ isOpen, onClose, onSelect }: Props) {
const [templates, setTemplates] = useState<MessageTemplate[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<FilterType>(ALL)
const [deleting, setDeleting] = useState<string | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
const load = useCallback(async () => {
setLoading(true)
try {
const data = await templateApi.list({
...(filter !== ALL ? { type: filter } : {}),
...(search ? { search } : {}),
})
setTemplates(data)
} finally {
setLoading(false)
}
}, [filter, search])
useEffect(() => {
if (isOpen) load()
}, [isOpen, load])
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
if (!confirm('Remover este template?')) return
setDeleting(id)
try {
await templateApi.delete(id)
setTemplates(prev => prev.filter(t => t.id !== id))
} finally {
setDeleting(null)
}
}
const handleSelect = (template: MessageTemplate) => {
setSelected(template.id)
setTimeout(() => {
onSelect(template)
setSelected(null)
onClose()
}, 200)
}
const filterTabs: Array<{ key: FilterType; label: string }> = [
{ key: ALL, label: 'Todos' },
...Object.entries(TYPE_META).map(([k, v]) => ({ key: k as TemplateType, label: v.label })),
]
if (!mounted) return null
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 30, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-[#e0f2fe] flex items-center justify-center">
<BookOpen className="w-5 h-5 text-[#0ea5e9]" />
</div>
<div>
<h2 className="text-base font-semibold text-[#111b21]">Biblioteca de Templates</h2>
<p className="text-xs text-[#667781]">{templates.length} template{templates.length !== 1 ? 's' : ''} salvos</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Search */}
<div className="px-5 py-3 border-b border-gray-100 shrink-0">
<div className="relative">
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
<input
className="w-full pl-9 pr-4 py-2 rounded-xl bg-[#f0f2f5] border-none text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30"
placeholder="Pesquisar templates..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
</div>
{/* Filter tabs */}
<div className="px-5 py-2 flex gap-1.5 overflow-x-auto shrink-0 border-b border-gray-100 pb-2">
{filterTabs.map(tab => (
<button
key={tab.key}
onClick={() => setFilter(tab.key)}
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${
filter === tab.key
? 'bg-[#111b21] text-white'
: 'bg-[#f0f2f5] text-[#667781] hover:bg-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Template list */}
<div className="flex-1 overflow-y-auto p-5">
{loading ? (
<div className="flex items-center justify-center py-16">
<span className="w-6 h-6 border-2 border-gray-200 border-t-[#00a884] rounded-full animate-spin" />
</div>
) : templates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<BookOpen className="w-12 h-12 text-gray-200 mb-3" />
<p className="text-sm font-medium text-[#667781]">Nenhum template encontrado</p>
<p className="text-xs text-gray-400 mt-1">Crie um template no compositor de mensagens</p>
</div>
) : (
<div className="space-y-2">
{templates.map(tmpl => (
<motion.button
key={tmpl.id}
layout
onClick={() => handleSelect(tmpl)}
className={`w-full text-left rounded-xl border p-4 transition-all hover:shadow-md active:scale-[0.99] group ${
selected === tmpl.id
? 'border-[#00a884] bg-[#e7f8f2]'
: 'border-gray-200 hover:border-gray-300 bg-white'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<TypeBadge type={tmpl.type} />
{tmpl.usageCount > 0 && (
<span className="flex items-center gap-1 text-[10px] text-gray-400">
<Clock className="w-3 h-3" />
{tmpl.usageCount}x usado
</span>
)}
</div>
<p className="text-sm font-semibold text-[#111b21] truncate">{tmpl.name}</p>
{tmpl.description && (
<p className="text-xs text-[#667781] mt-0.5 line-clamp-1">{tmpl.description}</p>
)}
<p className="text-xs text-gray-400 mt-1.5 line-clamp-2">
<PayloadPreview template={tmpl} />
</p>
{tmpl.tags.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{tmpl.tags.map(tag => (
<span key={tag} className="px-1.5 py-0.5 bg-gray-100 text-[10px] text-gray-500 rounded">
#{tag}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{selected === tmpl.id ? (
<CheckCircle2 className="w-5 h-5 text-[#00a884]" />
) : (
<>
<button
onClick={(e) => handleDelete(e, tmpl.id)}
disabled={deleting === tmpl.id}
className="p-1.5 rounded-full opacity-0 group-hover:opacity-100 hover:bg-red-50 text-gray-400 hover:text-red-500 transition-all"
>
{deleting === tmpl.id
? <span className="w-3.5 h-3.5 border border-gray-300 border-t-gray-500 rounded-full animate-spin block" />
: <Trash2 className="w-3.5 h-3.5" />
}
</button>
</>
)}
</div>
</div>
</motion.button>
))}
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
)
}
@@ -0,0 +1,110 @@
import React from 'react';
import {
MessageCircle,
Megaphone,
Users,
Settings,
BrainCircuit,
} from 'lucide-react';
import { getAvatarProxyUrl } from '../utils/inboxUtils';
import { SessaoSecretariaPower } from './SessaoSecretariaPower';
export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings';
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
}
export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, onNavigate }: ThinSidebarProps) {
const isConnected = activeInstance?.status === 'connected' || activeInstance?.status === 'open';
const renderIcon = (id: TabType, IconComponent: any, title: string) => {
const isActive = activeTab === id;
return (
<button
key={id}
title={title}
onClick={() => {
if (id === 'groups') {
onNavigate?.('wa-sessions');
} else {
setActiveTab(id);
}
}}
className={`w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors ${
isActive ? 'bg-[#d9dbdf]' : 'hover:bg-[#d9dbdf]/60'
}`}
>
<IconComponent
className={`w-6 h-6 ${isActive ? 'text-[#111b21]' : 'text-[#54656f]'}`}
fill={isActive ? 'currentColor' : 'none'}
/>
</button>
);
};
return (
<div className="w-[60px] h-full bg-[#f0f2f5] border-r border-[#d1d7db] flex flex-col items-center py-4 flex-shrink-0 z-30">
{/* Top Icons */}
<div className="flex-1 w-full flex flex-col items-center">
{renderIcon('chats', MessageCircle, 'Conversas')}
{renderIcon('broadcasts', Megaphone, 'Transmissões')}
{renderIcon('groups', Users, 'Sessões')}
{/* Secretária IA — abre a tela de configuração da secretária virtual */}
<button
title="Secretária IA"
onClick={() => onNavigate?.('wa-secretaria')}
className="w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors hover:bg-[#d9dbdf]/60"
>
<BrainCircuit className="w-6 h-6 text-[#54656f]" />
</button>
</div>
{/* 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} />
{/* Configurações — abre o painel na área central (aba 'settings') */}
{renderIcon('settings', Settings, 'Configurações')}
<div className="relative mt-2 p-1 cursor-pointer">
<div className="w-8 h-8 rounded-full overflow-hidden bg-gray-200 border border-gray-300">
{(() => {
// Avatar da instância SEMPRE via proxy do backend. Nunca usar a URL
// crua do CDN do WhatsApp (pps.whatsapp.net) como fallback: ela expira
// e o navegador recebe 403 (hotlink). Sem proxy resolvível → ícone.
const avatarSrc = activeInstance?.instance
? getAvatarProxyUrl({
instance_name: activeInstance.instance,
remote_jid: activeInstance.phone ? `${activeInstance.phone}@s.whatsapp.net` : null,
})
: null;
return avatarSrc ? (
<img
src={avatarSrc}
alt="Status"
className="w-full h-full object-cover"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-[#dfe5e7] text-[#54656f]">
<Users className="w-5 h-5" />
</div>
);
})()}
</div>
{/* Status indicator badge */}
<div
className={`absolute bottom-0.5 right-0.5 w-3 h-3 rounded-full border-2 border-[#f0f2f5] ${
isConnected ? 'bg-emerald-500' : 'bg-red-500'
}`}
title={isConnected ? 'Conectado' : 'Desconectado'}
></div>
</div>
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
// Button do motor NewWhats (de @/components/ui) — portado para o satélite.
import React from 'react';
import { Loader2 } from 'lucide-react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'premium';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
icon?: React.ReactNode;
children?: React.ReactNode;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-brand-500 text-white font-bold shadow-lg shadow-brand-500/20 hover:bg-brand-600 active:scale-[0.98]',
secondary: 'bg-white/5 border border-white/10 text-white font-semibold hover:bg-white/10 backdrop-blur-sm',
ghost: 'text-slate-400 hover:text-white hover:bg-white/5',
danger: 'bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20',
premium: 'bg-gradient-to-r from-brand-500 to-blue-500 text-white font-bold shadow-glow-brand hover:shadow-lg active:scale-[0.98] relative overflow-hidden',
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-xs rounded-lg gap-1.5',
md: 'px-4 py-2.5 text-sm rounded-xl gap-2',
lg: 'px-6 py-3.5 text-sm rounded-xl gap-2',
};
export function Button({
variant = 'primary',
size = 'md',
loading,
icon,
children,
className = '',
disabled,
...props
}: ButtonProps) {
return (
<button
disabled={disabled || loading}
className={`
inline-flex items-center justify-center transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
${variantClasses[variant]} ${sizeClasses[size]} ${className}
`}
{...props}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon ? icon : null}
{children}
{variant === 'premium' && (
<div className="absolute inset-0 -translate-x-full animate-shimmer bg-gradient-to-r from-transparent via-white/10 to-transparent pointer-events-none" />
)}
</button>
);
}
export default Button;
@@ -0,0 +1,147 @@
'use client';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { getAvatarInitials, getAvatarColor } from '../../utils/inboxUtils';
import { getAvatarFromCache, setAvatarInCache } from '../../hooks/useAvatarCache';
import { avatarApi } from '../../services/chatApiService';
interface AvatarProps {
chat: any;
size?: number;
className?: string;
}
export default function Avatar({ chat, size = 46, className = '' }: AvatarProps) {
const [displayUrl, setDisplayUrl] = useState<string | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
const [showInitials, setShowInitials] = useState(false);
const cacheAttempted = useRef(false);
const refreshTried = useRef(false);
const imageUrl = chat?.avatar_url || null;
const version = chat?.avatar_version || null;
const remoteJid = chat?.remote_jid || '';
const instanceName = chat?.instance_name || '';
// Quando jid ou version muda, tenta servir do cache IndexedDB antes de fazer request HTTP.
// Se não houver cache (primeira vez), usa a URL do proxy normalmente.
useEffect(() => {
cacheAttempted.current = false;
refreshTried.current = false;
setIsLoaded(false);
if (!imageUrl) {
setShowInitials(true);
setDisplayUrl(null);
return;
}
setShowInitials(false);
// Sem versão (ex.: satélite, cujo ext /inbox não envia avatar_version) →
// usa a URL do CDN direto, sem cache IndexedDB (o cache depende da versão
// para invalidar). A imagem ainda carrega normalmente.
if (!version) {
setDisplayUrl(imageUrl);
cacheAttempted.current = true;
return;
}
// Tenta cache IndexedDB — resultado síncrono do ponto de vista do usuário
getAvatarFromCache(remoteJid, version).then((cached) => {
if (cached) {
setDisplayUrl(cached);
setIsLoaded(true); // já temos os bytes — sem skeleton
} else {
setDisplayUrl(imageUrl); // busca pelo proxy normalmente
}
cacheAttempted.current = true;
}).catch(() => {
setDisplayUrl(imageUrl);
cacheAttempted.current = true;
});
}, [remoteJid, version, imageUrl]);
const handleLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
setIsLoaded(true);
// Salva no IndexedDB após primeira carga bem-sucedida do proxy.
// Só salva se a versão é conhecida e o displayUrl não é já um dataUrl (evita re-cache).
if (!version || !remoteJid || !displayUrl || displayUrl.startsWith('data:')) return;
try {
const img = e.currentTarget;
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || size;
canvas.height = img.naturalHeight || size;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
setAvatarInCache(remoteJid, version, dataUrl);
} catch { /* CORS ou canvas tainted — ignora, cache não é crítico */ }
};
const handleError = async () => {
// Self-heal: a URL do CDN do WhatsApp pode ter expirado (404). Tenta UMA
// vez re-buscar a foto atual pela conexão viva do motor antes de desistir.
if (!refreshTried.current && instanceName && remoteJid) {
refreshTried.current = true;
const fresh = await avatarApi.refresh(instanceName, remoteJid);
if (fresh && fresh !== displayUrl) {
setIsLoaded(false);
setDisplayUrl(fresh);
return;
}
}
setShowInitials(true);
setDisplayUrl(null);
};
const initials = useMemo(() => getAvatarInitials(chat), [chat]);
const bgColor = useMemo(
() => getAvatarColor(chat?.remote_jid || chat?.phone || chat?.displayName || 'anon'),
[chat]
);
return (
<div
className={`relative flex-shrink-0 rounded-full overflow-hidden bg-gray-100 flex items-center justify-center border border-black/5 ${className}`}
style={{ width: size, height: size }}
>
<AnimatePresence mode="wait">
{!showInitials && displayUrl ? (
<motion.img
key={displayUrl}
src={displayUrl}
alt=""
loading="lazy"
decoding="async"
crossOrigin="anonymous"
initial={{ opacity: 0 }}
animate={{ opacity: isLoaded ? 1 : 0 }}
transition={{ duration: 0.3 }}
onLoad={handleLoad}
onError={handleError}
className="w-full h-full object-cover"
/>
) : (
<motion.div
key="initials"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="w-full h-full flex items-center justify-center text-white font-semibold"
style={{ backgroundColor: bgColor, fontSize: size * 0.4 }}
>
{initials}
</motion.div>
)}
</AnimatePresence>
{/* Skeleton apenas quando buscando pelo proxy (não quando servindo do cache) */}
{!isLoaded && !showInitials && displayUrl && !displayUrl.startsWith('data:') && (
<div className="absolute inset-0 bg-gray-200 animate-pulse" />
)}
</div>
);
}
@@ -0,0 +1,35 @@
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, AlertCircle } from 'lucide-react';
interface ToastProps {
message: string;
type: 'success' | 'error';
visible: boolean;
}
export default function Toast({ message, type, visible }: ToastProps) {
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 40 }}
className={`
fixed top-6 right-6 z-[300] flex items-center gap-3 px-5 py-3.5 rounded-2xl
glass-elevated shadow-premium text-sm font-semibold text-white
${type === 'success' ? 'border-l-4 border-l-emerald-500' : 'border-l-4 border-l-red-500'}
`}
>
{type === 'success' ? (
<Check className="w-4 h-4 text-emerald-400" />
) : (
<AlertCircle className="w-4 h-4 text-red-400" />
)}
{message}
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,87 @@
/**
* useChatFilter gerencia toda a lógica de filtro/escopo de chats no inbox.
*
* Extrai de inbox/index.tsx:
* - searchTerm, activeFilter, chatScope, favoriteJids
* - derivados: favoriteSet, filterCounts, chatsByScope, isFavoriteChat
*/
import { useState, useMemo, useCallback } from 'react'
import { normalizeJid } from '../../utils/inboxUtils'
import type { NewWhatsChat } from '../../types/inboxTypes'
export type ChatScope = 'inbox' | 'favorites' | 'archived'
export interface CustomChatList { id: string; name: string; jids: string[] }
export function useChatFilter(chatsStore: NewWhatsChat[]) {
const [searchTerm, setSearchTerm] = useState('')
const [activeFilter, setActiveFilter] = useState('all')
const [chatScope, setChatScope] = useState<ChatScope>('inbox')
const [favoriteJids, setFavoriteJids] = useState<string[]>([])
const [customLists, setCustomLists] = useState<CustomChatList[]>([])
const [activeCustomListId, setActiveCustomListId] = useState<string | null>(null)
// ── Derivados ──────────────────────────────────────────────────────────────
const favoriteSet = useMemo(() => new Set(favoriteJids), [favoriteJids])
const filterCounts = useMemo(() => ({
all: chatsStore.length,
unread: chatsStore.filter(c => c.unread_count > 0).length,
groups: chatsStore.filter(c => c.remote_jid.endsWith('@g.us')).length,
pinned: chatsStore.filter(c => c.is_pinned).length,
archived: chatsStore.filter(c => c.is_archived).length,
}), [chatsStore])
const chatsByScope = useMemo(() => {
let base = chatsStore
if (chatScope === 'favorites') {
base = base.filter(c => favoriteSet.has(normalizeJid(c.remote_jid) || c.remote_jid))
} else if (chatScope === 'archived') {
base = base.filter(c => Boolean(c.is_archived))
} else {
base = base.filter(c => !Boolean(c.is_archived))
}
if (activeFilter === 'unread') return base.filter(c => c.unread_count > 0)
if (activeFilter === 'groups') return base.filter(c => c.remote_jid.endsWith('@g.us'))
if (activeFilter === 'pinned') return base.filter(c => c.is_pinned)
return base
}, [chatScope, chatsStore, favoriteSet, activeFilter])
const isFavoriteChat = useCallback(
(chat: NewWhatsChat) => favoriteSet.has(normalizeJid(chat.remote_jid) || chat.remote_jid),
[favoriteSet]
)
const toggleFavorite = useCallback((jid: string) => {
const normalized = normalizeJid(jid) || jid
setFavoriteJids(prev =>
prev.includes(normalized) ? prev.filter(j => j !== normalized) : [...prev, normalized]
)
}, [])
return {
// Estado
searchTerm,
activeFilter,
chatScope,
favoriteJids,
customLists,
activeCustomListId,
// Setters
setSearchTerm,
setActiveFilter,
setChatScope,
setCustomLists,
setActiveCustomListId,
// Derivados
favoriteSet,
filterCounts,
chatsByScope,
isFavoriteChat,
toggleFavorite,
}
}
@@ -0,0 +1,144 @@
/**
* useChatWindow gerencia o estado e handlers da área de chat ativa.
*
* Extrai de inbox/index.tsx:
* - scroll + carregamento de histórico
* - indicador de digitação
* - reply / reaction
* - envio de mídia e áudio
* - sync de avatar
* - abertura de perfil / menu de chat
*/
import { useState, useRef, useCallback } from 'react'
import { mediaApi } from '../../services/chatApiService'
import type { NewWhatsChat } from '../../types/inboxTypes'
import type { Message } from '../../types/inboxTypes'
interface UseChatWindowOptions {
selectedChat: NewWhatsChat | null
activeInstanceId: string | null
onLoadMoreHistory: () => Promise<{ hasMore: boolean }>
}
export function useChatWindow({
selectedChat,
activeInstanceId,
onLoadMoreHistory,
}: UseChatWindowOptions) {
// ── Refs ────────────────────────────────────────────────────────────────────
const scrollRef = useRef<HTMLDivElement>(null)
const isTypingRef = useRef(false)
const typingTimeout = useRef<NodeJS.Timeout | null>(null)
// ── Estado ──────────────────────────────────────────────────────────────────
const [isTyping, setIsTyping] = useState(false)
const [replyingTo, setReplyingTo] = useState<Message | null>(null)
const [reactionPickerFor, setReactionPickerFor] = useState<string | null>(null)
const [hasMoreHistory, setHasMoreHistory] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [isProfileOpen, setIsProfileOpen] = useState(false)
const [isSyncingAvatar, setIsSyncingAvatar] = useState(false)
const [isChatMenuOpen, setIsChatMenuOpen] = useState(false)
// ── Handlers ────────────────────────────────────────────────────────────────
const handleTyping = useCallback(() => {
if (!selectedChat) return
if (!isTypingRef.current) {
isTypingRef.current = true
setIsTyping(true)
}
if (typingTimeout.current) clearTimeout(typingTimeout.current)
typingTimeout.current = setTimeout(() => {
isTypingRef.current = false
setIsTyping(false)
}, 3000)
}, [selectedChat])
const handleScroll = useCallback(async () => {
const el = scrollRef.current
if (!el || !hasMoreHistory || loadingMore) return
if (el.scrollTop < 100) {
setLoadingMore(true)
const { hasMore } = await onLoadMoreHistory()
setHasMoreHistory(hasMore)
setLoadingMore(false)
}
}, [hasMoreHistory, loadingMore, onLoadMoreHistory])
const handleSelectChat = useCallback(() => {
setHasMoreHistory(true)
}, [])
const handleReactToMessage = useCallback((_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
try {
await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, caption || undefined, undefined)
} catch (err) {
console.error('Erro ao enviar mídia:', err)
}
}, [selectedChat, activeInstanceId])
const handleSendAudio = useCallback(async (blob: Blob) => {
if (!selectedChat || !activeInstanceId) return
try {
await mediaApi.sendAudio(activeInstanceId, selectedChat.remote_jid, blob)
} catch (err) {
console.error('Erro ao enviar áudio:', err)
}
}, [selectedChat, activeInstanceId])
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}` } }
)
} finally {
setIsSyncingAvatar(false)
}
}, [selectedChat, activeInstanceId])
const toggleReactionPicker = useCallback((id: string) => {
setReactionPickerFor(prev => prev === id ? null : id)
}, [])
return {
// Refs
scrollRef,
// Estado
isTyping,
replyingTo,
reactionPickerFor,
hasMoreHistory,
loadingMore,
isProfileOpen,
isSyncingAvatar,
isChatMenuOpen,
// Setters simples
setReplyingTo,
setIsProfileOpen,
setIsChatMenuOpen,
// Handlers
handleTyping,
handleScroll,
handleSelectChat,
handleReactToMessage,
handleSendMedia,
handleSendAudio,
handleSyncAvatar,
toggleReactionPicker,
}
}
@@ -0,0 +1,129 @@
/**
* useInstanceSelector gerencia seleção e troca de instâncias WhatsApp.
*
* Extrai toda a lógica de instâncias do inbox/index.tsx.
* O componente visual consome apenas as variáveis retornadas.
*/
import { useRef, useCallback, useMemo, useEffect } from 'react'
import { useInstanceStore } from '../../store/instanceStore'
import { useChatStore } from '../../store/chatStore'
import { socketService } from '../../services/socketService'
// ─── Tipos ────────────────────────────────────────────────────────────────────
export interface LegacyInstance {
instance: string
nome: string
status: string
phone: string | null
avatar: string | null
profilePictureUrl: string | null
}
export interface MappedInstance {
id: string
nome: string
avatar: string | null
phone: string | null
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useInstanceSelector() {
const instances = useInstanceStore((s) => s.instances)
const activeId = useInstanceStore((s) => s.activeInstanceId)
const setActiveId = useInstanceStore((s) => s.setActiveInstance)
const loadInstances = useInstanceStore((s) => s.loadInstances)
const connectInstance = useInstanceStore((s) => s.connectInstance)
const qrByInstance = useInstanceStore((s) => s.qrByInstance)
const resetChats = useChatStore((s) => s.setChats)
const dropdownRef = useRef<HTMLDivElement>(null)
// ── Formato legado esperado pelo JSX do inbox ──────────────────────────────
const instancesLegacy = useMemo<LegacyInstance[]>(() =>
instances.map((i) => ({
instance: i.id,
nome: i.name,
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
phone: i.phone,
avatar: i.avatar ?? null,
profilePictureUrl: i.avatar ?? null,
})),
[instances]
)
const activeInstance = useMemo<LegacyInstance | null>(
() => instancesLegacy.find((i) => i.instance === activeId) ?? null,
[instancesLegacy, activeId]
)
// Versão com `id` usada por alguns componentes internos
const mappedActiveInstance = useMemo<MappedInstance | null>(
() => activeInstance
? { id: activeInstance.instance, nome: activeInstance.nome, avatar: activeInstance.avatar ?? null, phone: activeInstance.phone }
: null,
[activeInstance]
)
// Apenas instâncias conectadas (para o dropdown de troca rápida)
const connectedInstances = useMemo<MappedInstance[]>(() =>
instances
.filter((i) => i.status === 'CONNECTED')
.map((i) => ({ id: i.id, nome: i.name, avatar: i.avatar ?? null, phone: i.phone })),
[instances]
)
const isConnected = activeInstance?.status === 'connected'
// ── Auto-select primeira instância disponível ──────────────────────────────
useEffect(() => {
if (!activeId && instances.length > 0) {
const first = instances[0]
setActiveId(first.id)
socketService.joinInstance(first.id)
}
}, [instances, activeId, setActiveId])
// ── Troca de instância ────────────────────────────────────────────────────
const selectInstance = useCallback((legacyInstance: LegacyInstance | { instance: string }) => {
const id = legacyInstance.instance
if (id === activeId) return
resetChats([])
setActiveId(id)
socketService.joinInstance(id)
}, [activeId, setActiveId, resetChats])
// ── Refresh ────────────────────────────────────────────────────────────────
const refreshInstances = useCallback(async () => {
await loadInstances()
}, [loadInstances])
const handleConnect = useCallback(async (instanceId: string) => {
await connectInstance(instanceId)
}, [connectInstance])
return {
// Estado
instances: instancesLegacy,
activeInstance,
mappedActiveInstance,
connectedInstances,
isConnected,
qrByInstance,
activeInstanceId: activeId,
// Refs (para click-outside no dropdown)
dropdownRef,
// Handlers
selectInstance,
refreshInstances,
handleConnect,
}
}
@@ -0,0 +1,41 @@
/**
* useMessageInput gerencia o estado e envio da barra de mensagem.
*
* Extrai de inbox/index.tsx:
* - newMessage + setNewMessage
* - handleSendMessage
*/
import { useState, useCallback } from 'react'
import type { NewWhatsChat } from '../../types/inboxTypes'
interface UseMessageInputOptions {
selectedChat: NewWhatsChat | null
replyingTo?: { message_id: string } | null
onClearReply?: () => void
onSendText: (text: string, mentions?: string[], replyToMessageId?: string) => Promise<void>
}
export function useMessageInput({ selectedChat, replyingTo, onClearReply, onSendText }: UseMessageInputOptions) {
const [newMessage, setNewMessage] = useState('')
const [mentions, setMentions] = useState<string[]>([])
const handleSendMessage = useCallback(async (e?: React.FormEvent) => {
e?.preventDefault()
if (!selectedChat || !newMessage.trim()) return
const text = newMessage
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])
return {
newMessage,
setNewMessage,
mentions,
setMentions,
handleSendMessage,
}
}
@@ -0,0 +1,287 @@
/**
* useNewInboxBridge adapta chatStore + instanceStore para o formato
* que os componentes de display legados consomem.
* Nenhum componente visual precisa ser alterado.
*/
import { useMemo, useCallback, useEffect, useRef } from 'react'
import { useChatStore } from '../../store/chatStore'
import { useInstanceStore } from '../../store/instanceStore'
import { chatApi, messageApi, reconcileApi } from '../../services/chatApiService'
import { socketService } from '../../services/socketService'
import { formatPhone } from '../../utils/inboxUtils'
import type { NewWhatsChat } from '../../types/inboxTypes'
// ─── Mapeamentos legado ───────────────────────────────────────────────────────
function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]): NewWhatsChat {
const isGroup = c.jid.endsWith('@g.us')
const jidUser = c.jid.split('@')[0]
// Telefone formatado — sempre passa por formatPhone para normalizar E.164 → exibição
// c.contactPhone é o número cru do DB (ex: "5511999887766"); formatPhone aplica máscara
const formattedPhone = isGroup ? jidUser : formatPhone(c.contactPhone ?? jidUser, c.jid)
// 1. Nome de Exibição
// Regra estrita: grupos usam SEMPRE o subject (c.name) — nunca o
// pushName/contactName do último participante que enviou mensagem.
let displayName = isGroup
? (c.name || 'Grupo')
: (c.contactName || c.name || formattedPhone || jidUser)
// Limpeza final: se por algum motivo ainda vier o JID completo, removemos o sufixo
if (displayName.includes('@')) {
displayName = displayName.split('@')[0]
}
// 2. Avatar — o motor (ext /inbox) já devolve a URL pública do CDN do
// WhatsApp (pps.whatsapp.net) em contactAvatarUrl; usamos direto (o <img>
// carrega cross-origin sem CORS). Quando é null, Avatar.tsx mostra iniciais.
const avatar_url: string | null = c.contactAvatarUrl ?? null
const avatar_version: string | null = c.contactAvatarVersion ?? null
return {
id: c.id as any,
instance_name: c.instanceId,
remote_jid: c.jid,
phone: formattedPhone,
lead_name: c.contactName,
avatar_url,
avatar_version,
bio: isGroup ? 'Detalhes do Grupo' : (formattedPhone || 'Clique para informações'),
displayName,
subject: c.protocolNumber ? `#${c.protocolNumber}` : (isGroup ? null : formattedPhone),
last_message_text: c.lastMessageBody || null,
last_message_time: c.lastMessageAt || c.createdAt,
last_message_from_me: c.lastMessageFromMe ? 1 : 0,
last_message_status: c.lastMessageStatus?.toLowerCase() ?? null,
last_message_type: c.lastMessageType?.toLowerCase() ?? null,
last_message_sender: c.lastMessageSender ?? null,
unread_count: c.unreadCount,
is_pinned: c.isPinned,
is_archived: c.isArchived,
bot_paused: c.botPaused ?? null,
labels: [],
}
}
function messagesToLegacy(msgs: ReturnType<typeof useChatStore.getState>['messages'][string]) {
return (msgs ?? []).map((m) => ({
id: m.id as any,
chat_id: m.chatId as any,
message_id: m.messageId,
text: m.body ?? '',
direction: (m.fromMe ? 'out' : 'in') as 'in' | 'out',
timestamp: new Date(m.timestamp).getTime(),
status: m.status.toLowerCase(),
type: m.type.toLowerCase(),
media_url: m.mediaUrl,
// false só para mídias legadas sem WAMessage salvo (fromMe irrecuperável); default
// true para não bloquear mensagens vindas por outros caminhos (socket, etc.).
media_recoverable: (m as any).mediaRecoverable ?? true,
transcription: (m as any).transcription ?? null,
mime_type: m.mimeType,
reply_to_id: m.replyToId,
// Quoted message — mapeia replyTo do Prisma para o formato do MessageBubble
quoted_id: m.replyTo?.id ?? null,
quoted_message_id: m.replyTo?.messageId ?? null,
quoted_message_text: m.replyTo?.body ?? null,
quoted_participant: m.replyTo ? (m.replyTo.fromMe ? 'me' : null) : null,
participant: m.pushName ?? null,
senderJid: m.senderJid ?? null,
sender_jid: m.senderJid ?? null,
remote_jid: m.chatId,
from_me: m.fromMe ? 1 : 0,
rich_payload: (m as any).richPayload ?? null,
}))
}
// ─── Hook principal ───────────────────────────────────────────────────────────
export function useNewInboxBridge() {
const initialized = useRef(false)
const chatStore = useChatStore()
const instanceStore = useInstanceStore()
// ── Boot: socket + instâncias ─────────────────────────────────────────────
useEffect(() => {
if (initialized.current) return
initialized.current = true
// Satélite: o socketService lê o token do scoreodonto internamente.
socketService.connect()
chatStore.initSocketListeners()
instanceStore.initSocketListeners()
instanceStore.loadInstances()
}, [])
// ── Carregar chats quando instância ativa muda ─────────────────────────────
useEffect(() => {
const { activeInstanceId } = instanceStore
if (!activeInstanceId) return
chatStore.loadChats(activeInstanceId)
socketService.joinInstance(activeInstanceId)
// Reconcilia nomes em background (cache 30min — leve em escala)
reconcileApi.reconcileNames(activeInstanceId).catch(() => {})
}, [instanceStore.activeInstanceId])
// ── Adaptação: instâncias → legado ────────────────────────────────────────
const instancesLegacy = useMemo(
() =>
instanceStore.instances.map((i) => ({
instance: i.id,
nome: i.name,
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
phone: i.phone,
avatar: null,
profilePictureUrl: null,
})),
[instanceStore.instances]
)
const activeInstanceLegacy = useMemo(
() => instancesLegacy.find((i) => i.instance === instanceStore.activeInstanceId) ?? null,
[instancesLegacy, instanceStore.activeInstanceId]
)
// ── Adaptação: chats → legado ─────────────────────────────────────────────
const chatsLegacy = useMemo(() => chatStore.chats.map(chatToLegacy), [chatStore.chats])
const selectedChatLegacy = useMemo(() => {
if (!chatStore.activeChatId) return null
const c = chatStore.chats.find((c) => c.id === chatStore.activeChatId)
return c ? chatToLegacy(c) : null
}, [chatStore.activeChatId, chatStore.chats])
const messagesLegacy = useMemo(() => {
if (!chatStore.activeChatId) return []
const raw = chatStore.messages[chatStore.activeChatId] ?? []
const mapped = messagesToLegacy(raw)
console.log('[bridge] messagesLegacy recomputed:', { activeChatId: chatStore.activeChatId, rawCount: raw.length, mappedCount: mapped.length })
return mapped
}, [chatStore.activeChatId, chatStore.messages])
// ── Handlers ──────────────────────────────────────────────────────────────
const handleSelectChat = useCallback(async (chat: NewWhatsChat | null) => {
if (!chat) { useChatStore.getState().setActiveChat(null); return }
await chatStore.selectChat(String(chat.id))
// Assina a presença do contato (composing/recording) — só assim o motor emite.
chatApi.subscribePresence(String(chat.id)).catch(() => {})
}, [chatStore])
const handleSendText = useCallback(async (text: string, mentions?: string[], replyToMessageId?: string) => {
if (!chatStore.activeChatId || !instanceStore.activeInstanceId) return
const chat = chatStore.chats.find((c) => c.id === chatStore.activeChatId)
if (!chat) return
await chatStore.sendMessage(instanceStore.activeInstanceId, chat.jid, text, replyToMessageId, mentions)
}, [chatStore, instanceStore.activeInstanceId])
const handleLoadMoreHistory = useCallback(async () => {
if (!chatStore.activeChatId) return { hasMore: false }
const oldest = chatStore.messages[chatStore.activeChatId]?.[0]
if (!oldest) return { hasMore: false }
return chatStore.loadMessages(chatStore.activeChatId, { before: oldest.timestamp })
}, [chatStore])
const handleSetActiveInstance = useCallback((legacyInstance: any) => {
instanceStore.setActiveInstance(legacyInstance?.instance ?? null)
}, [instanceStore])
const handleRefreshInstances = useCallback(async () => {
await instanceStore.loadInstances()
}, [instanceStore])
const handleConnectInstance = useCallback(async (instanceId: string) => {
await instanceStore.connectInstance(instanceId)
}, [instanceStore])
const handleArchiveChat = useCallback(async (chatId: string, archived: boolean) => {
await chatApi.archive(chatId, archived)
useChatStore.getState().patchChat(chatId, { isArchived: archived })
}, [])
const handlePinChat = useCallback(async (chatId: string, pinned: boolean) => {
await chatApi.pin(chatId, pinned)
useChatStore.getState().patchChat(chatId, { isPinned: pinned })
}, [])
const handleDeleteChat = useCallback(async (chatId: string) => {
await chatApi.delete(chatId)
useChatStore.getState().setChats(
useChatStore.getState().chats.filter((c) => c.id !== chatId)
)
if (useChatStore.getState().activeChatId === chatId) {
useChatStore.getState().setActiveChat(null)
}
}, [])
const handleMarkRead = useCallback(async (chatId: string) => {
await chatApi.markRead(chatId)
useChatStore.getState().patchChat(chatId, { unreadCount: 0 })
}, [])
// Apaga a mensagem PARA TODOS (revoke no WhatsApp). Confirma antes; em erro (ex.:
// mensagem não é sua / instância offline) mostra o motivo. messageId = id interno.
const handleDeleteMessage = useCallback(async (chatId: string, messageId: string) => {
if (!chatId || !messageId) return
if (typeof window !== 'undefined' && !window.confirm('Apagar esta mensagem para todos? Esta ação não pode ser desfeita.')) return
try {
await useChatStore.getState().deleteMessage(chatId, messageId)
} catch (e: any) {
const m = e?.response?.data?.error || e?.message || 'Não foi possível apagar a mensagem.'
if (typeof window !== 'undefined') window.alert(m)
}
}, [])
const handleSearchMessages = useCallback(async (q: string) => {
if (!instanceStore.activeInstanceId || q.length < 2) return []
return messageApi.search(instanceStore.activeInstanceId, q)
}, [instanceStore.activeInstanceId])
// action é a string-key emitida por ChatListItem (ex: 'archive', 'pin', ...)
// onFavoriteToggle é injetado pelo componente pai (inbox page) pois favoritos são estado local
const handleMenuAction = useCallback((
action: string,
chat: NewWhatsChat,
onFavoriteToggle?: (jid: string) => void
) => {
const chatId = String(chat.id)
switch (action) {
case 'archive': handleArchiveChat(chatId, !chat.is_archived); break
case 'pin': handlePinChat(chatId, !chat.is_pinned); break
case 'mark_read': handleMarkRead(chatId); break
case 'delete': handleDeleteChat(chatId); break
case 'favorite': onFavoriteToggle?.(chat.remote_jid); break
}
}, [handleArchiveChat, handlePinChat, handleMarkRead, handleDeleteChat])
return {
instances: instancesLegacy,
activeInstance: activeInstanceLegacy,
chats: chatsLegacy,
selectedChat: selectedChatLegacy,
messages: messagesLegacy,
loadingChats: chatStore.chatsLoading,
loadingMessages: chatStore.messagesLoading,
activeInstanceId: instanceStore.activeInstanceId,
qrByInstance: instanceStore.qrByInstance,
setActiveInstance: handleSetActiveInstance,
refreshInstances: handleRefreshInstances,
handleSelectChat,
handleSendText,
handleLoadMoreHistory,
handleConnectInstance,
handleArchiveChat,
handlePinChat,
handleDeleteChat,
handleDeleteMessage,
handleMarkRead,
handleSearchMessages,
handleMenuAction,
presenceByJid: chatStore.presenceByJid as Record<string, any>,
isFavoriteChat: (_: NewWhatsChat) => false,
}
}
@@ -0,0 +1,89 @@
'use client'
/**
* useAvatarCache cache de avatares em IndexedDB.
*
* Chave de cache: `${jid}:${version}` (version = hash curto do avatarUrl no banco).
* Quando a versão muda (novo avatar no banco), a chave muda e o cache invalida
* automaticamente sem TTL fixo, sem stale data visível ao usuário.
*
* Armazenamento: dataURL base64 do JPEG/WebP (compatível com <img src>).
* Limpeza: entradas com mais de 30 dias são removidas na abertura do DB.
*/
const DB_NAME = 'nw-avatars'
const STORE_NAME = 'blobs'
const DB_VERSION = 1
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 dias
interface CacheEntry {
key: string // `${jid}:${version}`
dataUrl: string
savedAt: number
}
let dbPromise: Promise<IDBDatabase> | null = null
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'key' })
}
}
req.onsuccess = () => {
const db = req.result
pruneOldEntries(db)
resolve(db)
}
req.onerror = () => { dbPromise = null; reject(req.error) }
})
return dbPromise
}
function pruneOldEntries(db: IDBDatabase): void {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const cutoff = Date.now() - MAX_AGE_MS
const req = store.openCursor()
req.onsuccess = () => {
const cursor = req.result
if (!cursor) return
if ((cursor.value as CacheEntry).savedAt < cutoff) cursor.delete()
cursor.continue()
}
}
export async function getAvatarFromCache(jid: string, version: string): Promise<string | null> {
if (typeof window === 'undefined') return null
try {
const db = await openDB()
const key = `${jid}:${version}`
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).get(key)
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
req.onerror = () => resolve(null)
})
} catch {
return null
}
}
export async function setAvatarInCache(jid: string, version: string, dataUrl: string): Promise<void> {
if (typeof window === 'undefined') return
try {
const db = await openDB()
const key = `${jid}:${version}`
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const entry: CacheEntry = { key, dataUrl, savedAt: Date.now() }
const req = tx.objectStore(STORE_NAME).put(entry)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
} catch { /* quota exceeded ou IDB indisponível — ignora silenciosamente */ }
}
@@ -0,0 +1,173 @@
import { useState, useCallback, useEffect } from 'react'
import { protocolApi, sectorApi, type Sector, type Team } from '../services/chatApiService'
// ─── Types ────────────────────────────────────────────────────────────────────
export type ProtocolStatus =
| 'OPEN'
| 'IN_PROGRESS'
| 'WAITING_CLIENT'
| 'WAITING_AGENT'
| 'RESOLVED'
| 'CANCELLED'
export interface Protocol {
id: string
number: string
chatId: string
contactId: string
sectorId: string | null
teamId: string | null
agentId: string | null
status: ProtocolStatus
summary: string | null
deadline: string | null
resolvedAt: string | null
createdAt: string
updatedAt: string
sector?: { name: string } | null
team?: { name: string } | null
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useProtocolPanel(chatId: string | null) {
const [protocols, setProtocols] = useState<Protocol[]>([])
const [loading, setLoading] = useState(false)
// Sectors & teams for the "open protocol" form
const [sectors, setSectors] = useState<Sector[]>([])
const [teams, setTeams] = useState<Team[]>([])
const [selectedSectorId, setSelectedSectorId] = useState<string>('')
const [selectedTeamId, setSelectedTeamId] = useState<string>('')
const [loadingSectors, setLoadingSectors] = useState(false)
const [loadingTeams, setLoadingTeams] = useState(false)
// Opening a new protocol
const [isOpening, setIsOpening] = useState(false)
const [showOpenForm, setShowOpenForm] = useState(false)
// Updating status
const [updatingId, setUpdatingId] = useState<string | null>(null)
// Toast
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
const showToast = useCallback((message: string, type: 'success' | 'error') => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}, [])
// ── Load protocols ────────────────────────────────────────────────────────
const load = useCallback(async () => {
if (!chatId) { setProtocols([]); return }
setLoading(true)
try {
const data = await protocolApi.list(chatId)
setProtocols(data as Protocol[])
} catch {
// silently ignore
} finally {
setLoading(false)
}
}, [chatId])
useEffect(() => { load() }, [chatId])
// ── Load sectors (lazy, once) ─────────────────────────────────────────────
const loadSectors = useCallback(async () => {
if (sectors.length > 0) return
setLoadingSectors(true)
try {
const data = await sectorApi.list()
setSectors(data.filter((s) => s.isActive))
} catch {
// ignore
} finally {
setLoadingSectors(false)
}
}, [sectors.length])
// ── Load teams when sector changes ────────────────────────────────────────
useEffect(() => {
if (!selectedSectorId) { setTeams([]); setSelectedTeamId(''); return }
setLoadingTeams(true)
sectorApi.listTeams(selectedSectorId)
.then((data) => { setTeams(data.filter((t) => t.isActive)); setSelectedTeamId('') })
.catch(() => {})
.finally(() => setLoadingTeams(false))
}, [selectedSectorId])
const openForm = useCallback(() => {
setShowOpenForm(true)
setSelectedSectorId('')
setSelectedTeamId('')
loadSectors()
}, [loadSectors])
// ── Open new protocol ─────────────────────────────────────────────────────
const openProtocol = useCallback(async () => {
if (!chatId) return
setIsOpening(true)
try {
const protocol = await protocolApi.open(chatId, {
sectorId: selectedSectorId || undefined,
teamId: selectedTeamId || undefined,
})
setProtocols((prev) => [protocol as Protocol, ...prev])
setShowOpenForm(false)
showToast('Protocolo aberto.', 'success')
} catch (err: any) {
showToast(err?.response?.data?.error ?? 'Erro ao abrir protocolo.', 'error')
} finally {
setIsOpening(false)
}
}, [chatId, selectedSectorId, selectedTeamId, showToast])
// ── Update status ─────────────────────────────────────────────────────────
const updateStatus = useCallback(async (protocol: Protocol, status: ProtocolStatus) => {
if (!chatId) return
setUpdatingId(protocol.id)
try {
await protocolApi.update(chatId, protocol.id, { status })
setProtocols((prev) =>
prev.map((p) =>
p.id === protocol.id
? { ...p, status, resolvedAt: status === 'RESOLVED' ? new Date().toISOString() : p.resolvedAt }
: p
)
)
showToast('Status atualizado.', 'success')
} catch {
showToast('Erro ao atualizar status.', 'error')
} finally {
setUpdatingId(null)
}
}, [chatId, showToast])
// ── Derived ───────────────────────────────────────────────────────────────
const activeProtocol = protocols.find(
(p) => p.status !== 'RESOLVED' && p.status !== 'CANCELLED'
) ?? null
const hasActive = activeProtocol !== null
return {
protocols, loading, load,
activeProtocol, hasActive,
// open form
showOpenForm, setShowOpenForm,
openForm,
sectors, teams,
selectedSectorId, setSelectedSectorId,
selectedTeamId, setSelectedTeamId,
loadingSectors, loadingTeams,
isOpening, openProtocol,
// update
updatingId, updateStatus,
toast,
}
}
@@ -0,0 +1,185 @@
/**
* useSessionsLogic toda a lógica da página /sessions. Portado do motor.
* Adaptações no satélite: imports apontam p/ os shims; o bootstrap chama
* socketService.connect() sem depender de localStorage['token'] (o shim o
* token do scoreodonto internamente).
*/
import { useState, useEffect, useCallback } from 'react'
import { useInstanceStore, type Instance } from '../store/instanceStore'
import { socketService } from '../services/socketService'
interface QrModalState {
instanceId: string
instanceName: string
qrBase64: string | null
}
interface Toast {
msg: string
ok: boolean
}
export function useSessionsLogic() {
const storeInstances = useInstanceStore((s) => s.instances)
const storeLoading = useInstanceStore((s) => s.loading)
const loadInstances = useInstanceStore((s) => s.loadInstances)
const createInstance = useInstanceStore((s) => s.createInstance)
const connectInst = useInstanceStore((s) => s.connectInstance)
const disconnectInst = useInstanceStore((s) => s.disconnectInstance)
const deleteInst = useInstanceStore((s) => s.deleteInstance)
const patchInstance = useInstanceStore((s) => s.patchInstance)
const setQr = useInstanceStore((s) => s.setQr)
const clearQr = useInstanceStore((s) => s.clearQr)
const qrByInstance = useInstanceStore((s) => s.qrByInstance)
const initListeners = useInstanceStore((s) => s.initSocketListeners)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [addOpen, setAddOpen] = useState(false)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [qrModal, setQrModal] = useState<QrModalState | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Instance | null>(null)
const [deleting, setDeleting] = useState(false)
const [toast, setToast] = useState<Toast | null>(null)
useEffect(() => {
socketService.connect() // shim lê o token do scoreodonto internamente
initListeners()
loadInstances()
}, [])
useEffect(() => {
const onStatus = ({ instanceId, status, qrBase64 }: { instanceId: string; status: Instance['status']; qrBase64?: string }) => {
patchInstance(instanceId, { status })
if (qrBase64) {
setQr(instanceId, qrBase64)
setQrModal((prev) => (prev?.instanceId === instanceId ? { ...prev, qrBase64 } : prev))
}
if (status === 'CONNECTED') {
clearQr(instanceId)
setQrModal((prev) => (prev?.instanceId === instanceId ? null : prev))
showToast('WhatsApp conectado!')
}
}
const onQr = ({ instanceId, qrBase64 }: { instanceId: string; qrBase64: string }) => {
setQr(instanceId, qrBase64)
patchInstance(instanceId, { status: 'QR_PENDING' })
setQrModal((prev) => (prev?.instanceId === instanceId ? { ...prev, qrBase64 } : prev))
}
socketService.on('instance:status', onStatus)
socketService.on('qr', onQr)
return () => {
socketService.off('instance:status', onStatus)
socketService.off('qr', onQr)
}
}, [])
const showToast = (msg: string, ok = true) => {
setToast({ msg, ok })
setTimeout(() => setToast(null), 3000)
}
const openAddModal = useCallback(() => setAddOpen(true), [])
const closeAddModal = useCallback(() => { setAddOpen(false); setNewName('') }, [])
const handleCreate = useCallback(async () => {
const name = newName.trim()
if (!name) return
setCreating(true)
try {
const created = await createInstance(name)
closeAddModal()
if (created?.id) {
socketService.joinInstance(created.id)
await connectInst(created.id)
setQrModal({ instanceId: created.id, instanceName: name, qrBase64: null })
}
} catch (err: any) {
showToast(err.response?.data?.error ?? 'Erro ao criar instância', false)
} finally {
setCreating(false)
}
}, [newName, createInstance, connectInst, closeAddModal])
const handleConnect = useCallback(async (inst: Instance) => {
setActionLoading(`connect-${inst.id}`)
try {
socketService.joinInstance(inst.id)
await connectInst(inst.id)
setQrModal({ instanceId: inst.id, instanceName: inst.name, qrBase64: null })
} catch (err: any) {
showToast(err.response?.data?.error ?? 'Erro ao conectar', false)
} finally {
setActionLoading(null)
}
}, [connectInst])
const handleDisconnect = useCallback(async (inst: Instance) => {
setActionLoading(`disconnect-${inst.id}`)
try {
await disconnectInst(inst.id)
showToast('Instância desconectada')
} catch (err: any) {
showToast(err.response?.data?.error ?? 'Erro ao desconectar', false)
} finally {
setActionLoading(null)
}
}, [disconnectInst])
const handleShowQr = useCallback(async (inst: Instance) => {
socketService.joinInstance(inst.id)
const cached = qrByInstance[inst.id]
if (cached) {
setQrModal({ instanceId: inst.id, instanceName: inst.name, qrBase64: cached })
return
}
setQrModal({ instanceId: inst.id, instanceName: inst.name, qrBase64: null })
}, [qrByInstance])
const handleDelete = useCallback(async () => {
if (!deleteTarget) return
setDeleting(true)
try {
await deleteInst(deleteTarget.id)
setDeleteTarget(null)
showToast('Instância removida')
} catch (err: any) {
showToast(err.response?.data?.error ?? 'Erro ao deletar', false)
} finally {
setDeleting(false)
}
}, [deleteTarget, deleteInst])
const handleNewQr = useCallback(async (instanceId: string) => {
socketService.joinInstance(instanceId)
await connectInst(instanceId)
}, [connectInst])
return {
instances: storeInstances,
loading: storeLoading,
actionLoading,
toast,
addOpen,
newName,
creating,
setNewName,
openAddModal,
closeAddModal,
handleCreate,
qrModal,
closeQrModal: () => setQrModal(null),
handleNewQr,
deleteTarget,
deleting,
setDeleteTarget,
closeDeleteModal: () => setDeleteTarget(null),
handleDelete,
handleConnect,
handleDisconnect,
handleShowQr,
refreshInstances: loadInstances,
}
}
@@ -0,0 +1,82 @@
'use client'
/**
* useStickerCache cache de figurinhas em IndexedDB.
*
* Chave de cache: sha256 hex do arquivo (conteúdo permanente nunca muda).
* Sem TTL: stickers são imutáveis, então o cache é eterno.
* O único motivo para uma entrada sumir é o usuário limpar dados do navegador.
*
* Armazenamento: dataURL base64 do WebP (compatível com <img src>).
*/
const DB_NAME = 'nw-stickers'
const STORE_NAME = 'blobs'
const DB_VERSION = 1
interface CacheEntry {
sha256: string
dataUrl: string
}
let dbPromise: Promise<IDBDatabase> | null = null
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'sha256' })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => { dbPromise = null; reject(req.error) }
})
return dbPromise
}
export async function getStickerFromCache(sha256: string): Promise<string | null> {
if (typeof window === 'undefined') return null
try {
const db = await openDB()
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).get(sha256)
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
req.onerror = () => resolve(null)
})
} catch {
return null
}
}
export async function setStickerInCache(sha256: string, dataUrl: string): Promise<void> {
if (typeof window === 'undefined') return
try {
const db = await openDB()
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const entry: CacheEntry = { sha256, dataUrl }
const req = tx.objectStore(STORE_NAME).put(entry)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
} catch { /* quota exceeded — ignora */ }
}
export async function hasStickerInCache(sha256: string): Promise<boolean> {
if (typeof window === 'undefined') return false
try {
const db = await openDB()
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).count(sha256)
req.onsuccess = () => resolve(req.result > 0)
req.onerror = () => resolve(false)
})
} catch {
return false
}
}
@@ -0,0 +1,251 @@
// 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';
// ─── Instâncias ── (ext /sessions) ──────────────────────────────────────────
export const instanceApi = {
list: () => nw.get('/sessions'),
create: (name: string) => nw.post('/sessions', { name }),
connect: (id: string) => nw.post(`/sessions/${id}/connect`),
disconnect: (id: string) => nw.post(`/sessions/${id}/disconnect`),
getQr: (id: string) => nw.get(`/sessions/${id}/qr`),
delete: (id: string) => nw.delete(`/sessions/${id}`),
};
// ─── 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.
export const avatarApi = {
refresh: async (instanceId: string, jid: string): Promise<string | null> => {
try {
const r = await nw.get(`/contacts/${encodeURIComponent(jid)}/avatar/refresh?session=${encodeURIComponent(instanceId)}`);
return r?.avatar ?? null;
} catch { return null; }
},
};
// ─── Chats ── (ext /inbox → shape achatado; normaliza p/ mapBackendChat) ────
export const chatApi = {
list: async (instanceId: string, opts: { search?: string; archived?: boolean; limit?: number } = {}) => {
const q = new URLSearchParams({ session: instanceId });
if (opts.search) q.set('search', opts.search);
if (opts.limit) q.set('limit', String(opts.limit));
const raw = await nw.get(`/inbox?${q.toString()}`);
const arr = Array.isArray(raw) ? raw : [];
// ext: { id, jid, displayName, avatar, phone, unreadCount, lastMessageAt,
// isPinned, isArchived, lastMessage } → shape esperado pelo chatStore.
return arr.map((c: any) => ({
id: c.id,
jid: c.jid,
instanceId,
name: c.displayName ?? null,
contactName: c.displayName ?? null,
contactPhone: c.phone ?? null,
contactAvatarUrl: c.avatar ?? null,
unreadCount: c.unreadCount ?? 0,
lastMessageAt: c.lastMessageAt ?? null,
createdAt: c.lastMessageAt ?? null,
isPinned: c.isPinned ?? false,
isArchived: c.isArchived ?? false,
lastMessage: c.lastMessage ?? null,
}));
},
markRead: (chatId: string) => nw.post(`/inbox/${chatId}/read`),
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}`),
};
// ─── Mensagens ── (ext devolve array; envolve em {messages, hasMore}) ───────
export const messageApi = {
list: async (chatId: string, opts: { before?: string; after?: string; limit?: number } = {}) => {
const q = new URLSearchParams();
if (opts.before) q.set('before', opts.before);
if (opts.limit) q.set('limit', String(opts.limit));
const qs = q.toString();
const raw = await nw.get(`/inbox/${chatId}/messages${qs ? `?${qs}` : ''}`);
const arr = Array.isArray(raw) ? raw : (raw?.messages ?? []);
const limit = opts.limit ?? 50;
return {
messages: arr.map((m: any) => ({ ...m, chatId, replyToId: m.replyToId ?? null, replyTo: m.replyTo ?? null })),
hasMore: arr.length >= limit,
};
},
search: (instanceId: string, q: string, limit?: number) => {
const p = new URLSearchParams({ session: instanceId, q });
if (limit) p.set('limit', String(limit));
return nw.get(`/inbox/messages/search?${p.toString()}`);
},
// Envio por chatId (a ext resolve jid/instância). O motor chamava por
// (instanceId, jid, ...); o chatStore foi adaptado p/ passar (chatId, text, replyTo).
send: (chatId: string, text: string, replyToMessageId?: string) =>
nw.post(`/inbox/${chatId}/send`, { text, ...(replyToMessageId ? { replyToMessageId } : {}) }),
};
// ─── Mídia ── (ext /inbox/:chatId/send-media | send-audio) ──────────────────
export const mediaApi = {
sendMedia: (chatId: string, file: File, caption?: string, replyToMessageId?: 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);
},
// 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' }));
return nw.post(`/inbox/${chatId}/send-media`, fd);
},
// Re-download sob demanda: ~95% das mídias vêm com mediaUrl NULL (lazy). O motor
// re-baixa do WhatsApp a partir do WAMessage salvo e devolve { mediaUrl }.
redownload: (_instanceId: string, messageDbId: string): Promise<{ mediaUrl: string }> =>
nw.post(`/media/${encodeURIComponent(messageDbId)}/download`),
};
// ─── Grupos (ext não expõe participantes — stub gracioso) ───────────────────
export const groupApi = {
getParticipants: (_instanceId: string, groupJid: string) =>
Promise.resolve({ groupJid, subject: '', participants: [] as any[] }),
};
// ─── Protocolo (ext ainda não expõe CRUD por chat — stub; task follow-up) ───
export const protocolApi = {
list: (_chatId: string) => Promise.resolve([] as any[]),
open: (_chatId: string, _data: { sectorId?: string; teamId?: string }) => Promise.resolve(null as any),
update: (_chatId: string, _protocolId: string, _data: { status?: string; summary?: string }) => Promise.resolve(null as any),
};
// ─── Reconciliação (ext não expõe — no-op) ──────────────────────────────────
export const reconcileApi = {
reconcileNames: (_instanceId: string) => Promise.resolve({ updated: 0 }),
};
// ─── Tipos (compat com o motor; usados como type-only pelos componentes) ────
export interface GroupParticipant { jid: string; admin: string | null; name: string | null; phone: string }
export interface Sector { id: string; tenantId: string; name: string; isActive: boolean; createdAt: string; updatedAt: string; _count?: { teams: number; protocols: number } }
export interface Team { id: string; tenantId: string; sectorId: string; name: string; isActive: boolean; createdAt: string; updatedAt: string; _count?: { protocols: number } }
export type TemplateType = 'TEXT' | 'BUTTONS' | 'INTERACTIVE' | 'LIST' | 'POLL' | 'CAROUSEL'
export interface MessageTemplate { id: string; tenantId: string; name: string; description: string | null; type: TemplateType; payload: Record<string, unknown>; tags: string[]; usageCount: number; createdAt: string; updatedAt: string }
export interface StickerRecord { id: string; sha256: string; wasabiPath: string; isAnimated: boolean; isFavorite: boolean; useCount?: number; lastSeenAt?: string }
export interface RichPayload { text?: string; footer?: string; nativeButtons?: any[]; nativeList?: any; nativeCarousel?: any; poll?: any }
// ─── Setores/Equipes (ext não expõe protocolo/setores — stubs) ──────────────
export const sectorApi = {
list: () => Promise.resolve([] as Sector[]),
create: (_name: string) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
listTeams: (_sectorId: string) => Promise.resolve([] as Team[]),
createTeam: (_sectorId: string, _name: string) => Promise.resolve(null as any),
updateTeam: (_sectorId: string, _teamId: string, _d: any) => Promise.resolve(null as any),
deleteTeam: (_sectorId: string, _teamId: string) => Promise.resolve({} as any),
};
// ─── Templates (stub simples) ───────────────────────────────────────────────
export const templateApi = {
list: (_opts: any = {}) => Promise.resolve([] as MessageTemplate[]),
get: (_id: string) => Promise.resolve(null as any),
create: (_d: any) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
markUsed: (_id: string) => Promise.resolve({} as any),
};
// ─── Stickers (ext não expõe catálogo — stub) ───────────────────────────────
export const stickerApi = {
list: () => Promise.resolve({ listVersion: '', stickers: [] as StickerRecord[] }),
recent: () => Promise.resolve({ stickers: [] as StickerRecord[] }),
toggleFavorite: (id: string) => Promise.resolve({ id, isFavorite: false }),
};
// ─── Rich message (composer passa instanceId/jid; sem chatId não mapeia na
// ext → stub que rejeita graciosamente) ─────────────────────────────────────
export const richMessageApi = {
send: (_instanceId: string, _jid: string, _payload: RichPayload) =>
Promise.reject(new Error('Envio rico indisponível no satélite')),
};
// ─── Demais exports do chatApiService do motor (stubs — a ext não os expõe;
// mantidos para os named imports da árvore não quebrarem em runtime) ─────────
export interface ApiKeyRecord { id: string; name: string; keyPreview: string; isActive: boolean; createdAt: string; expiresAt: string | null }
export interface BackendContact { id: string; instanceId: string; jid: string; name: string | null; verifiedName: string | null; notify: string | null; phone: string | null; avatarUrl: string | null; scoreReputacao: number; flagRestricao: boolean; isBlocked: boolean; createdAt: string; updatedAt: string }
export type EventType = 'BIRTHDAY' | 'ANNIVERSARY' | 'SIGNUP_DAYS'
export type RecipientMode = 'MANUAL' | 'ALL_CONTACTS' | 'TAG_FILTER'
export type ScheduleType = 'ONCE' | 'RECURRING' | 'EVENT'
export type ScheduleStatus = 'ACTIVE' | 'PAUSED' | 'DONE' | 'CANCELLED'
export interface ScheduleRecipient { jid: string; name?: string; birthday?: string; anniversary?: string; signupDate?: string }
export interface ScheduledMessage { id: string; tenantId: string; instanceId: string; name: string; templateId: string | null; payload: Record<string, unknown>; recipientMode: RecipientMode; recipients: ScheduleRecipient[]; scheduleType: ScheduleType; cronExpr: string | null; scheduledAt: string | null; eventType: EventType | null; timezone: string; status: ScheduleStatus; lastRunAt: string | null; nextRunAt: string | null; runCount: number; createdAt: string; updatedAt: string; template?: any; _count?: { logs: number } }
export interface ScheduleLog { id: string; scheduleId: string; status: string; sentCount: number; failedCount: number; error: string | null; runAt: string }
// Cliente axios-like genérico sobre o nw (alguns arquivos importam `api` direto).
export const api = {
get: (u: string) => nw.get(u).then((data: any) => ({ data })),
post: (u: string, body?: any) => nw.post(u, body).then((data: any) => ({ data })),
put: (u: string, body?: any) => nw.put(u, body).then((data: any) => ({ data })),
patch: (u: string, body?: any) => nw.patch(u, body).then((data: any) => ({ data })),
delete: (u: string) => nw.delete(u).then((data: any) => ({ data })),
};
export const authApi = {
register: (_d: any) => Promise.resolve({} as any),
login: (_d: any) => Promise.resolve({} as any),
logout: () => Promise.resolve({} as any),
me: () => Promise.resolve({} as any),
changePassword: (_d: any) => Promise.resolve({} as any),
};
export const apiKeyApi = {
list: () => Promise.resolve([] as ApiKeyRecord[]),
create: (_name: string, _e?: string) => Promise.resolve({} as any),
toggle: (_id: string, _a: boolean) => Promise.resolve({} as any),
delete: (_id: string) => Promise.resolve({} as any),
};
export const contactApi = {
list: (_opts: any = {}) => Promise.resolve({ total: 0, contacts: [] as BackendContact[] }),
get: (_id: string) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
stats: (_instanceId?: string) => Promise.resolve({ total: 0, blocked: 0, flagged: 0, active: 0 }),
};
export const broadcastApi = {
create: (_instanceId: string, _d: any) => Promise.resolve({ jobId: '', total: 0 }),
list: (_instanceId: string, _limit?: number) => Promise.resolve([] as any[]),
get: (_instanceId: string, _broadcastId: string) => Promise.resolve(null as any),
};
export const planApi = {
status: () => Promise.resolve({ daysRemaining: null as number | null, planName: '' }),
};
export const chatbotApi = {
getCredentials: (_instanceId: string) => Promise.resolve([] as any[]),
createCredential: (_instanceId: string, _d: any) => Promise.resolve({} as any),
deleteCredential: (_instanceId: string, _credId: string) => Promise.resolve({} as any),
getBots: (_instanceId: string) => Promise.resolve([] as any[]),
createBot: (_instanceId: string, _d: any) => Promise.resolve({} as any),
updateBot: (_instanceId: string, _botId: string, _d: any) => Promise.resolve({} as any),
deleteBot: (_instanceId: string, _botId: string) => Promise.resolve({} as any),
pauseChat: (_instanceId: string, _chatId: string) => Promise.resolve({} as any),
resumeChat: (_instanceId: string, _chatId: string) => Promise.resolve({} as any),
};
export const scheduleApi = {
list: (_opts: any = {}) => Promise.resolve([] as ScheduledMessage[]),
get: (_id: string) => Promise.resolve(null as any),
create: (_d: any) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
pause: (_id: string) => Promise.resolve({} as any),
resume: (_id: string) => Promise.resolve({} as any),
runNow: (_id: string) => Promise.resolve({} as any),
logs: (_id: string) => Promise.resolve([] as ScheduleLog[]),
};
@@ -0,0 +1,53 @@
// Cliente base do satélite NewWhats → /api/nw/v1/* (proxy do backend → motor
// /api/ext/v1/*). Auth: Bearer do scoreodonto (localStorage SCOREODONTO_AUTH_TOKEN).
// Retorna JSON já parseado; em erro, lança Error com `.response = { status, data }`
// (mesmo shape do axios, que o código portado do motor espera).
const API: string = (import.meta as any).env?.VITE_API_URL || '/api';
const TOKEN_KEY = 'SCOREODONTO_AUTH_TOKEN';
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 {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_ACTIVE_WORKSPACE') || '{}')?.id || null; } catch { return null; }
}
function authHeaders(extra?: Record<string, string>): Record<string, string> {
const t = nwToken();
const c = activeClinicaId();
return {
...(t ? { Authorization: `Bearer ${t}` } : {}),
...(c ? { 'x-nw-clinica': c } : {}),
...(extra || {}),
};
}
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)),
body: body == null ? undefined : (isForm ? body : JSON.stringify(body)),
});
const text = await res.text();
let data: any = null;
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
if (!res.ok) {
const err: any = new Error((data && data.error) || `${res.status}`);
err.response = { status: res.status, data };
throw err;
}
return data;
}
export const nw = {
get: (p: string) => req('GET', p),
post: (p: string, body?: any) => req('POST', p, body),
put: (p: string, body?: any) => req('PUT', p, body),
patch: (p: string, body?: any) => req('PATCH', p, body),
delete: (p: string) => req('DELETE', p),
// URL base ('/api' relativo ou absoluta) — usada pelo socketService p/ o WS.
base: () => API,
};
@@ -0,0 +1,187 @@
// Satélite: `api` axios-like sobre o nwClient. As rotas internas da secretária
// foram montadas sob a ext em /api/ext/v1/secretaria (proxy /api/nw/v1/secretaria).
import { nw } from './nwClient'
const _qs = (params?: Record<string, any>) => {
if (!params) return ''
const p = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => { if (v != null) p.set(k, String(v)) })
const s = p.toString()
return s ? `?${s}` : ''
}
const api = {
get: <T = any>(u: string, cfg?: { params?: Record<string, any> }) => nw.get(u + _qs(cfg?.params)).then((data: any) => ({ data: data as T })),
post: <T = any>(u: string, body?: any) => nw.post(u, body).then((data: any) => ({ data: data as T })),
put: <T = any>(u: string, body?: any) => nw.put(u, body).then((data: any) => ({ data: data as T })),
patch: <T = any>(u: string, body?: any) => nw.patch(u, body).then((data: any) => ({ data: data as T })),
delete:<T = any>(u: string) => nw.delete(u).then((data: any) => ({ data: data as T })),
}
const BASE = '/secretaria'
// ─── Types ────────────────────────────────────────────────────────────────────
export interface SecAgent {
id: string
name: string
description: string | null
model: string
provider: string
temperature: number
context_window: number
active: boolean
created_at: string
updated_at: string
}
export type BrainNodeType = 'persona' | 'knowledge' | 'rules' | 'calendar' | 'escalation'
export interface BrainNode {
id: string
agent_id: string
type: BrainNodeType
title: string
content: string
active: boolean
sort_order: number
node_model: string | null // modelo específico para este nó (opcional)
created_at: string
updated_at: string
}
export interface SecConversation {
id: string
agent_id: string
contact_name: string
protocol_number: string
status: 'active' | 'closed' | 'escalated'
summary: string | null
created_at: string
updated_at: string
}
export interface SecMessage {
id: string
conversation_id: string
role: 'user' | 'assistant' | 'system'
content: string
created_at: string
}
export interface CalendarSlot {
id: string
title: string
date: string
time_start: string
time_end: string
instance_id?: string | null // número/sessão dona do slot (null = global)
attendee_name: string | null
attendee_phone: string | null
status: 'available' | 'booked' | 'cancelled'
notes: string | null
created_at: string
}
// ─── 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),
}
// 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),
}
// ─── Agents ───────────────────────────────────────────────────────────────────
export const secAgentApi = {
list: () => api.get<SecAgent[]>(`${BASE}/agents`).then((r) => r.data),
create: (d: Partial<SecAgent>) => api.post<SecAgent>(`${BASE}/agents`, d).then((r) => r.data),
update: (id: string, d: Partial<SecAgent>) => api.put<SecAgent>(`${BASE}/agents/${id}`, d).then((r) => r.data),
delete: (id: string) => api.delete(`${BASE}/agents/${id}`).then((r) => r.data),
}
// ─── Brain Nodes ──────────────────────────────────────────────────────────────
export const secNodeApi = {
list: (agentId: string) => api.get<BrainNode[]>(`${BASE}/agents/${agentId}/nodes`).then((r) => r.data),
create: (agentId: string, d: Partial<BrainNode>) =>
api.post<BrainNode>(`${BASE}/agents/${agentId}/nodes`, d).then((r) => r.data),
update: (id: string, d: Partial<BrainNode>) => api.put<BrainNode>(`${BASE}/nodes/${id}`, d).then((r) => r.data),
delete: (id: string) => api.delete(`${BASE}/nodes/${id}`).then((r) => r.data),
}
// ─── Conversations ────────────────────────────────────────────────────────────
export const secConvApi = {
list: (agentId?: string) =>
api.get<SecConversation[]>(`${BASE}/conversations`, { params: agentId ? { agent_id: agentId } : {} }).then((r) => r.data),
create: (agentId: string, contactName: string) =>
api.post<SecConversation>(`${BASE}/conversations`, { agent_id: agentId, contact_name: contactName }).then((r) => r.data),
patch: (id: string, d: Partial<SecConversation>) =>
api.patch<SecConversation>(`${BASE}/conversations/${id}`, d).then((r) => r.data),
delete: (id: string) => api.delete(`${BASE}/conversations/${id}`).then((r) => r.data),
messages: (id: string) => api.get<SecMessage[]>(`${BASE}/conversations/${id}/messages`).then((r) => r.data),
chat: (id: string, message: string) =>
api.post<{ reply: string }>(`${BASE}/conversations/${id}/chat`, { message }).then((r) => r.data),
finalize: (id: string) =>
api.post<{ summary: string; protocol_number: string }>(`${BASE}/conversations/${id}/finalize`).then((r) => r.data),
}
// ─── Numbers ──────────────────────────────────────────────────────────────────
export type NumberRole =
| 'secretary_virtual'
| 'clinic'
| 'doctor'
| 'specialist'
| 'manager'
| 'reserve'
| 'human_secretary'
export interface SecNumber {
id: string
instance_id: string
clinica_id?: string | null
agent_id: string | null // agente/cérebro deste número (separação por sessão)
label: string
role: NumberRole
area: string | null
priority: number
active: boolean
notes: string | null
created_at: string
updated_at: string
}
export const secNumberApi = {
list: () => api.get<SecNumber[]>(`${BASE}/numbers`).then((r) => r.data),
// upsert por instance_id — cria ou atualiza o papel da instância
assign: (d: Partial<SecNumber>) => api.post<SecNumber>(`${BASE}/numbers`, d).then((r) => r.data),
update: (id: string, d: Partial<SecNumber>) => api.put<SecNumber>(`${BASE}/numbers/${id}`, d).then((r) => r.data),
delete: (id: string) => api.delete(`${BASE}/numbers/${id}`).then((r) => r.data),
}
// ─── Calendar ─────────────────────────────────────────────────────────────────
export const secCalApi = {
list: (params?: { from?: string; to?: string; status?: string; instance_id?: string }) =>
api.get<CalendarSlot[]>(`${BASE}/calendar`, { params }).then((r) => r.data),
create: (d: Partial<CalendarSlot>) => api.post<CalendarSlot>(`${BASE}/calendar`, d).then((r) => r.data),
update: (id: string, d: Partial<CalendarSlot>) => api.put<CalendarSlot>(`${BASE}/calendar/${id}`, d).then((r) => r.data),
delete: (id: string) => api.delete(`${BASE}/calendar/${id}`).then((r) => r.data),
}
@@ -0,0 +1,111 @@
// Shim do socketService do motor sobre o túnel WS do satélite
// (/api/nw/v1/stream?token=). Traduz os eventos do ext stream para os nomes que
// as páginas do motor escutam. joinInstance/joinChat/leaveChat são no-ops: o
// stream já é do tenant inteiro; as páginas filtram por instanceId/chatId no
// cliente. Mantém a MESMA interface do services/socketService.ts do motor.
import { nw, nwToken } from './nwClient';
type Listener = (data: any) => void;
// ext status (minúsculo) → enum do motor (maiúsculo).
function normStatus(s: string): string {
const m: Record<string, string> = {
connected: 'CONNECTED', disconnected: 'DISCONNECTED',
connecting: 'CONNECTING', qr: 'QR_PENDING', banned: 'BANNED',
};
return m[String(s).toLowerCase()] ?? String(s).toUpperCase();
}
class SocketShim {
private ws: WebSocket | null = null;
private listeners = new Map<string, Set<Listener>>();
private retry: ReturnType<typeof setTimeout> | null = null;
private closedByUser = false;
connect(_token?: string) {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
this.closedByUser = false;
const token = nwToken();
if (!token) return;
const base = nw.base();
const abs = base.startsWith('http') ? base : `${window.location.origin}${base}`;
let u: URL;
try { u = new URL(`${abs}/nw/v1/stream`); } catch { return; }
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
u.searchParams.set('token', token);
let ws: WebSocket;
try { ws = new WebSocket(u.toString()); } catch { return; }
this.ws = ws;
ws.onopen = () => this._emit('connected', { id: 'nw' });
ws.onerror = () => { try { ws.close(); } catch { /* ignore */ } };
ws.onclose = () => {
this._emit('disconnected', {});
if (!this.closedByUser) this.retry = setTimeout(() => this.connect(), 3000);
};
ws.onmessage = (ev) => {
let env: any;
try { env = JSON.parse(ev.data); } catch { return; }
const { event, data } = env || {};
if (!event) return;
switch (event) {
case 'session.status':
this._emit('instance:status', { instanceId: data?.instanceId, status: normStatus(data?.status) });
break;
case 'session.qr':
this._emit('qr', { instanceId: data?.instanceId, qrBase64: data?.qrBase64 });
break;
case 'message.new': {
// ext envia { instanceId, chatId, message, timestamp }; o chatStore
// espera o objeto de mensagem direto (com chatId).
// OBS: o motor também emite ext:message.new numa variante SEM `message`
// (só { jid, text }) para acionar a Secretária IA — ignoramos aqui,
// senão o chatStore sobrescreve o preview do chat com undefined.
if (!data?.message) break;
const m = { ...data.message, chatId: data.message.chatId ?? data.chatId };
this._emit('message:new', m);
break;
}
case 'message.update': {
const m = data?.message ?? data;
if (m?.messageId && m?.status) this._emit('message:status', { messageId: m.messageId, status: m.status });
this._emit('message:update', m);
break;
}
case 'conversation.handoff': this._emit('handoff', data); break;
case 'conversation.escalated': this._emit('escalated', data); break;
default: break;
}
this._emit('*', { event, data });
};
}
disconnect() {
this.closedByUser = true;
if (this.retry) clearTimeout(this.retry);
try { this.ws?.close(); } catch { /* ignore */ }
this.ws = null;
}
// 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 */ }
joinChat(_chatId: string) { /* no-op */ }
leaveChat(_chatId: string) { /* no-op */ }
on(event: string, cb: Listener) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(cb);
}
off(event: string, cb: Listener) { this.listeners.get(event)?.delete(cb); }
get connected() { return this.ws?.readyState === WebSocket.OPEN; }
getSocket() { return this.ws; }
private _emit(event: string, data: any) {
this.listeners.get(event)?.forEach((cb) => { try { cb(data); } catch { /* ignore */ } });
}
}
export const socketService = new SocketShim();
@@ -0,0 +1,60 @@
// Stub do authStore do motor para o satélite. Os componentes portados leem
// `user`/`token` daqui. No satélite a auth é do scoreodonto (SCOREODONTO_AUTH_TOKEN);
// decodificamos o mínimo do JWT para exibição. As ações de auth são no-op (o
// scoreodonto cuida do login/logout).
import { create } from 'zustand';
export interface AuthUser {
id: string;
email: string;
name: string | null;
role: 'USER' | 'ADMIN';
planId: string | null;
daysRemaining: number | null;
}
interface AuthState {
user: AuthUser | null;
token: string | null;
refreshToken: string | null;
isHydrated: boolean;
setAuth: (token: string, refreshToken: string, user: AuthUser) => void;
clearAuth: () => void;
refreshSession: () => Promise<boolean>;
fetchMe: () => Promise<void>;
}
function readScoreToken(): string | null {
try { return localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); } catch { return null; }
}
function decodeUser(token: string | null): AuthUser | null {
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split('.')[1] || ''));
return {
id: payload.userId || payload.id || 'me',
email: payload.email || '',
name: payload.name || payload.nome || null,
role: 'USER',
planId: null,
daysRemaining: null,
};
} catch { return null; }
}
const token = readScoreToken();
export const useAuthStore = create<AuthState>()((set) => ({
user: decodeUser(token),
token,
refreshToken: null,
isHydrated: true,
setAuth: () => {},
clearAuth: () => {},
refreshSession: async () => false,
fetchMe: async () => {
const t = readScoreToken();
set({ token: t, user: decodeUser(t) });
},
}));
+458
View File
@@ -0,0 +1,458 @@
/**
* chatStore.ts Store de Inbox: chats e mensagens.
* Instâncias foram extraídas para instanceStore.ts.
* Usa Zustand + Immer para mutações diretas e legíveis.
*/
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { socketService } from '../services/socketService'
import { chatApi, messageApi } from '../services/chatApiService'
// ─── Mapper: resposta aninhada do backend → Chat plano do store ────────────────
function mapBackendChat(raw: any): Chat {
return {
id: raw.id,
tenantId: raw.tenantId ?? '',
instanceId: raw.instanceId,
jid: raw.jid,
name: raw.name ?? null,
unreadCount: raw.unreadCount ?? 0,
lastMessageAt: raw.lastMessageAt ?? null,
createdAt: raw.createdAt ?? null,
isPinned: raw.isPinned ?? false,
isArchived: raw.isArchived ?? false,
// Prioridade do nome: agenda (contact.name) → nome do WhatsApp
// (verifiedName de Business → notify/pushName) → pushName da última msg.
contactName: raw.contact?.name
?? raw.contact?.verifiedName
?? raw.contact?.notify
?? raw.contactName
?? raw.lastMessage?.pushName
?? null,
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
contactAvatarVersion: raw.contact?.avatarVersion ?? raw.contactAvatarVersion ?? null,
contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null,
scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0,
flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false,
lastMessageBody: raw.lastMessage?.body ?? raw.lastMessageBody ?? null,
lastMessageFromMe: raw.lastMessage?.fromMe ?? raw.lastMessageFromMe ?? false,
lastMessageStatus: raw.lastMessage?.status ?? raw.lastMessageStatus ?? null,
lastMessageType: raw.lastMessage?.type ?? raw.lastMessageType ?? null,
lastMessageSender: raw.lastMessage?.pushName ?? raw.lastMessageSender ?? null,
protocolNumber: raw.protocol?.number ?? raw.protocolNumber ?? null,
botPaused: raw.botPaused ?? false,
}
}
// ─── Tipos ────────────────────────────────────────────────────────────────────
export interface Chat {
id: string
tenantId: string
instanceId: string
jid: string
name: string | null // subject do grupo; null para individuais
unreadCount: number
lastMessageAt: string | null
createdAt: string | null // quando o chat foi registrado (fallback de ordenação)
isPinned: boolean
isArchived: boolean
contactName: string | null
contactAvatarUrl: string | null
contactAvatarVersion: string | null
contactPhone: string | null
scoreReputacao: number
flagRestricao: boolean
lastMessageBody: string | null
lastMessageFromMe: boolean
lastMessageStatus: string | null
lastMessageType: string | null
lastMessageSender: string | null // pushName do remetente (grupos: quem enviou a última msg)
protocolNumber: string | null
botPaused: boolean
}
export interface Message {
id: string
chatId: string
messageId: string
fromMe: boolean
type: string
body: string | null
mediaUrl: string | null
transcription?: string | null
mimeType: string | null
fileName: string | null
replyToId: string | null
replyTo?: {
id: string
messageId: string
body: string | null
fromMe: boolean
type: string
mediaUrl: string | null
} | null
status: 'PENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED'
timestamp: string
pushName?: string | null
senderJid?: string | null
}
// ─── Estado ───────────────────────────────────────────────────────────────────
interface ChatState {
chats: Chat[]
chatsLoading: boolean
activeChatId: string | null
messages: Record<string, Message[]>
messagesLoading: boolean
// Sync
setChats: (chats: Chat[]) => void
setChatsLoading: (v: boolean) => void
upsertChat: (chat: Chat) => void
patchChat: (chatId: string, patch: Partial<Chat>) => void
setActiveChat: (chatId: string | null) => void
clearUnread: (chatId: string) => void
setMessages: (chatId: string, msgs: Message[]) => void
setMessagesLoading: (v: boolean) => void
appendMessage: (msg: Message) => void
patchMessage: (msgId: string, patch: Partial<Message>) => void
updateMediaReady: (msgId: string, mediaUrl: 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>
// Socket
initSocketListeners: () => void
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function chatSortKey(c: Chat): number {
// WhatsApp ordena por: timestamp da última msg; se não houver msg, usa hora de criação do chat
if (c.lastMessageAt) return new Date(c.lastMessageAt).getTime()
if (c.createdAt) return new Date(c.createdAt).getTime()
return 0
}
function sortChats(chats: Chat[]): Chat[] {
return [...chats].sort((a, b) => {
// Fixados sempre primeiro (mesma lógica do WhatsApp oficial)
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1
return chatSortKey(b) - chatSortKey(a)
})
}
// ─── Store ────────────────────────────────────────────────────────────────────
// ─── Cache local (localStorage) — UX instantâneo no reload ─────────────────────
// Persiste os primeiros N chats por instância para hidratar a UI antes do fetch.
// Chave: `inboxCache:v1:${instanceId}:${archived ? '1' : '0'}`
// TTL implícito: validamos pela data salva e descartamos se > 7 dias.
const INBOX_CACHE_PREFIX = 'inboxCache:v1'
const INBOX_CACHE_LIMIT = 10
const INBOX_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
interface InboxCacheEntry {
savedAt: number
chats: Chat[]
}
function inboxCacheKey(instanceId: string, archived: boolean): string {
return `${INBOX_CACHE_PREFIX}:${instanceId}:${archived ? '1' : '0'}`
}
function readInboxCache(instanceId: string, archived: boolean): Chat[] | null {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem(inboxCacheKey(instanceId, archived))
if (!raw) return null
const parsed = JSON.parse(raw) as InboxCacheEntry
if (!parsed || !Array.isArray(parsed.chats)) return null
if (Date.now() - parsed.savedAt > INBOX_CACHE_MAX_AGE_MS) return null
return parsed.chats
} catch { return null }
}
function writeInboxCache(instanceId: string, archived: boolean, chats: Chat[]): void {
if (typeof window === 'undefined') return
try {
const slim = chats.slice(0, INBOX_CACHE_LIMIT)
const entry: InboxCacheEntry = { savedAt: Date.now(), chats: slim }
localStorage.setItem(inboxCacheKey(instanceId, archived), JSON.stringify(entry))
} catch { /* quota exceeded — ignora */ }
}
export const useChatStore = create<ChatState>()(
immer((set, get) => ({
chats: [],
chatsLoading: false,
activeChatId: null,
messages: {},
messagesLoading: false,
// ── Chats ─────────────────────────────────────────────────────────────────
setChats: (chats) =>
set((s) => { s.chats = sortChats(chats) }),
setChatsLoading: (v) =>
set((s) => { s.chatsLoading = v }),
upsertChat: (chat) =>
set((s) => {
const idx = s.chats.findIndex((c) => c.id === chat.id)
if (idx !== -1) s.chats[idx] = chat
else s.chats.unshift(chat)
s.chats = sortChats(s.chats as Chat[])
}),
patchChat: (chatId, patch) =>
set((s) => {
const idx = s.chats.findIndex((c) => c.id === chatId)
if (idx !== -1) {
Object.assign(s.chats[idx], patch)
s.chats = sortChats(s.chats as Chat[])
}
}),
setActiveChat: (chatId) =>
set((s) => { s.activeChatId = chatId }),
clearUnread: (chatId) =>
set((s) => {
const c = s.chats.find((c) => c.id === chatId)
if (c) c.unreadCount = 0
}),
// ── Mensagens ─────────────────────────────────────────────────────────────
setMessages: (chatId, msgs) =>
set((s) => { s.messages[chatId] = msgs }),
setMessagesLoading: (v) =>
set((s) => { s.messagesLoading = v }),
appendMessage: (msg) =>
set((s) => {
const list = s.messages[msg.chatId]
if (!list) { s.messages[msg.chatId] = [msg]; return }
const exists = list.some((m) => m.id === msg.id || m.messageId === msg.messageId)
if (!exists) list.push(msg)
}),
patchMessage: (msgId, patch) =>
set((s) => {
for (const list of Object.values(s.messages)) {
const idx = list.findIndex((m) => m.id === msgId || m.messageId === msgId)
if (idx !== -1) { Object.assign(list[idx], patch); break }
}
}),
updateMediaReady: (msgId, mediaUrl) =>
set((s) => {
for (const list of Object.values(s.messages)) {
const msg = list.find((m) => m.id === msgId)
if (msg) { msg.mediaUrl = mediaUrl; break }
}
}),
// ── Async ─────────────────────────────────────────────────────────────────
loadChats: async (instanceId, opts = {}) => {
// Hidratação instantânea: se temos cache local válido para esta instância,
// mostra os 10 chats salvos imediatamente enquanto a request real roda.
// Evita tela em branco ou skeleton de 15s+ em redes lentas.
const archivedFlag = opts.archived ?? false
const hasSearch = !!opts.search
const current = get().chats
if (!hasSearch) {
const sameInstance = current.length > 0 && current[0]?.instanceId === instanceId
if (!sameInstance) {
const cached = readInboxCache(instanceId, archivedFlag)
if (cached && cached.length > 0) {
set((s) => { s.chats = sortChats(cached); s.chatsLoading = true })
} else {
set((s) => { s.chatsLoading = true })
}
} else {
set((s) => { s.chatsLoading = true })
}
} else {
set((s) => { s.chatsLoading = true })
}
try {
const raw = await chatApi.list(instanceId, opts)
const data: Chat[] = (Array.isArray(raw) ? raw : []).map(mapBackendChat)
const sorted = sortChats(data)
set((s) => { s.chats = sorted; s.chatsLoading = false })
if (!hasSearch) writeInboxCache(instanceId, archivedFlag, sorted)
} catch {
set((s) => { s.chatsLoading = false })
}
},
loadMessages: async (chatId, opts = {}) => {
console.log('[chatStore] loadMessages chamado:', chatId, opts)
set((s) => { s.messagesLoading = true })
try {
const res = await messageApi.list(chatId, opts)
console.log('[chatStore] API retornou:', { hasMore: res.hasMore, count: res.messages?.length, raw: typeof res })
const { messages, hasMore } = res
if (!Array.isArray(messages)) {
console.error('[chatStore] messages NÃO é array!', messages)
set((s) => { s.messagesLoading = false })
return { hasMore: false }
}
set((s) => {
if (opts.before) {
const existing = s.messages[chatId] ?? []
const ids = new Set(existing.map((m) => m.id))
s.messages[chatId] = [...messages.filter((m: Message) => !ids.has(m.id)), ...existing]
} else {
s.messages[chatId] = messages
}
s.messagesLoading = false
})
console.log('[chatStore] messages armazenadas:', get().messages[chatId]?.length)
return { hasMore }
} catch (err) {
console.error('[chatStore] loadMessages ERRO:', err)
set((s) => { s.messagesLoading = false })
return { hasMore: false }
}
},
selectChat: async (chatId) => {
console.log('[chatStore] selectChat:', chatId)
const prev = get().activeChatId
if (prev && prev !== chatId) socketService.leaveChat(prev)
set((s) => { s.activeChatId = chatId })
socketService.joinChat(chatId)
get().clearUnread(chatId)
chatApi.markRead(chatId).catch(() => {})
await get().loadMessages(chatId)
console.log('[chatStore] selectChat completo. Messages:', get().messages[chatId]?.length)
},
sendMessage: async (instanceId, jid, text, replyToMessageId, mentions) => {
const chatId = get().chats.find((c) => c.jid === jid && c.instanceId === instanceId)?.id
if (!chatId) return
// Resolve replyTo a partir das mensagens já no store
const replyToMsg = replyToMessageId
? get().messages[chatId]?.find((m) => m.messageId === replyToMessageId) ?? null
: null
const optimistic: Message = {
id: `opt-${Date.now()}`,
chatId,
messageId: `opt-${Date.now()}`,
fromMe: true,
type: 'TEXT',
body: text,
mediaUrl: null,
mimeType: null,
fileName: null,
replyToId: replyToMsg?.id ?? null,
replyTo: replyToMsg
? { id: replyToMsg.id, messageId: replyToMsg.messageId, body: replyToMsg.body, fromMe: replyToMsg.fromMe, type: replyToMsg.type, mediaUrl: replyToMsg.mediaUrl }
: null,
status: 'PENDING',
timestamp: new Date().toISOString(),
}
get().appendMessage(optimistic)
try {
// Satélite: envia por chatId (a ext resolve jid/instância). A ext devolve
// { ok, messageId } — mantemos o id otimista (key React estável) e só
// fixamos o messageId real (dedup com o echo do WS message.new).
const saved = await messageApi.send(chatId, text, replyToMessageId)
get().patchMessage(optimistic.id, { ...(saved?.id ? { id: saved.id } : {}), messageId: saved?.messageId ?? optimistic.messageId, status: 'SENT' })
get().patchChat(chatId, {
lastMessageBody: text,
lastMessageFromMe: true,
lastMessageAt: saved.timestamp,
lastMessageStatus: 'SENT',
lastMessageType: 'TEXT',
})
} catch {
get().patchMessage(optimistic.id, { status: 'FAILED' })
}
},
// ── Socket ────────────────────────────────────────────────────────────────
initSocketListeners: () => {
// IMPORTANTE: sempre usar get() dentro dos callbacks para ler estado atual.
// Capturar `const store = get()` fora criaria uma closure stale.
socketService.on('message:new', (msg: Message) => {
console.log('[socket] message:new recebida:', { chatId: msg.chatId, body: msg.body?.slice(0, 40), fromMe: msg.fromMe })
get().appendMessage(msg)
const current = get()
const isActive = current.activeChatId === msg.chatId
current.patchChat(msg.chatId, {
lastMessageBody: msg.body,
lastMessageFromMe: msg.fromMe,
lastMessageAt: msg.timestamp,
lastMessageStatus: msg.status,
lastMessageType: msg.type,
lastMessageSender: msg.fromMe ? null : (msg.pushName ?? null),
unreadCount: msg.fromMe || isActive
? 0
: (current.chats.find((c) => c.id === msg.chatId)?.unreadCount ?? 0) + 1,
})
})
socketService.on('message:media_ready', ({ messageId, mediaUrl }: { messageId: string; mediaUrl: string }) => {
get().updateMediaReady(messageId, mediaUrl)
})
socketService.on('message:transcription', ({ messageId, transcription }: { messageId: string; transcription: string }) => {
get().patchMessage(messageId, { transcription })
})
socketService.on('message:status', ({ messageId, status }: { messageId: string; status: Message['status'] }) => {
get().patchMessage(messageId, { status })
})
socketService.on('chat:upsert', (raw: any) => {
get().upsertChat(mapBackendChat(raw))
})
socketService.on('bot:escalated', ({ chatId }: { chatId: string }) => {
get().patchChat(chatId, { botPaused: true })
})
socketService.on('contact:avatar', ({ instanceId, jid, avatarUrl }: { instanceId: string; jid: string; avatarUrl: string }) => {
set((s) => {
const chat = s.chats.find((c) => c.instanceId === instanceId && c.jid === jid)
if (chat) chat.contactAvatarUrl = avatarUrl
})
})
// Reconciliação de nomes concluída — re-busca lista de chats
socketService.on('chats:refresh', ({ instanceId }: { instanceId: string }) => {
const chats = get().chats
if (chats.length > 0 && chats[0]?.instanceId === instanceId) {
get().loadChats(instanceId)
}
})
// Reconciliação via ContactHandler (pós-sync)
socketService.on('contacts:reconciled', ({ instanceId }: { instanceId: string }) => {
const chats = get().chats
if (chats.length > 0 && chats[0]?.instanceId === instanceId) {
get().loadChats(instanceId)
}
})
},
}))
)
@@ -0,0 +1,186 @@
/**
* instanceStore.ts Gerencia instâncias WhatsApp e QR Codes.
* Separado do chatStore para responsabilidade única.
*/
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { instanceApi } from '../services/chatApiService'
import { socketService } from '../services/socketService'
import { notify } from './notificationStore'
export type InstanceStatus = 'DISCONNECTED' | 'CONNECTING' | 'QR_PENDING' | 'CONNECTED' | 'BANNED'
export interface Instance {
id: string
name: string
status: InstanceStatus
phone: string | null
avatar: string | null
}
interface InstanceState {
instances: Instance[]
activeInstanceId: string | null
qrByInstance: Record<string, string>
loading: boolean
// Sync actions
setInstances: (instances: Instance[]) => void
setActiveInstance: (id: string | null) => void
patchInstance: (id: string, patch: Partial<Instance>) => void
setQr: (instanceId: string, qrBase64: string) => void
clearQr: (instanceId: string) => void
removeInstance: (id: string) => void
// Async actions
loadInstances: () => Promise<void>
connectInstance: (id: string) => Promise<void>
disconnectInstance: (id: string) => Promise<void>
createInstance: (name: string) => Promise<Instance>
deleteInstance: (id: string) => Promise<void>
// Socket
initSocketListeners: () => void
}
export const useInstanceStore = create<InstanceState>()(
immer((set, get) => ({
instances: [],
// Persiste a seleção ativa no localStorage para sobreviver a reloads
activeInstanceId: typeof window !== 'undefined' ? localStorage.getItem('activeInstanceId') : null,
qrByInstance: {},
loading: false,
// ── Sync ─────────────────────────────────────────────────────────────────
setInstances: (instances) =>
set((s) => { s.instances = instances }),
setActiveInstance: (id) => {
if (typeof window !== 'undefined') {
if (id) localStorage.setItem('activeInstanceId', id)
else localStorage.removeItem('activeInstanceId')
}
set((s) => { s.activeInstanceId = id })
},
patchInstance: (id, patch) =>
set((s) => {
const idx = s.instances.findIndex((i) => i.id === id)
if (idx !== -1) Object.assign(s.instances[idx], patch)
else s.instances.push({ id, name: '', phone: null, status: 'CONNECTING', ...patch } as Instance)
}),
setQr: (instanceId, qrBase64) =>
set((s) => { s.qrByInstance[instanceId] = qrBase64 }),
clearQr: (instanceId) =>
set((s) => { delete s.qrByInstance[instanceId] }),
removeInstance: (id) =>
set((s) => { s.instances = s.instances.filter((i) => i.id !== id) }),
// ── Async ─────────────────────────────────────────────────────────────────
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,
}))
set((s) => {
s.loading = false
// Mescla em vez de substituir: preserva status CONNECTED que veio via socket
// (socket events são mais recentes que leituras REST — o banco pode estar
// desatualizado se a resposta da API chegou antes do DB ser gravado pelo backend)
const memMap = new Map(s.instances.map((i) => [i.id, i]))
s.instances = list.map((fromDb) => {
const inMem = memMap.get(fromDb.id)
if (inMem?.status === 'CONNECTED' && fromDb.status !== 'CONNECTED') {
// Socket já confirmou CONNECTED — mantém, não deixa REST stale sobrescrever
return { ...fromDb, status: 'CONNECTED' as InstanceStatus }
}
return fromDb
})
})
} catch {
set((s) => { s.loading = false })
}
},
connectInstance: async (id) => {
get().patchInstance(id, { status: 'CONNECTING' })
socketService.joinInstance(id)
await instanceApi.connect(id)
},
disconnectInstance: async (id) => {
await instanceApi.disconnect(id)
get().patchInstance(id, { status: 'DISCONNECTED' })
get().clearQr(id)
},
createInstance: async (name) => {
const created: Instance = await instanceApi.create(name)
set((s) => { s.instances.push(created) })
return created
},
deleteInstance: async (id) => {
await instanceApi.delete(id)
get().removeInstance(id)
if (get().activeInstanceId === id) {
set((s) => { s.activeInstanceId = null })
}
},
// ── Socket ────────────────────────────────────────────────────────────────
initSocketListeners: () => {
socketService.on('instance:status', ({
instanceId,
status,
qrBase64,
}: {
instanceId: string
status: InstanceStatus
qrBase64?: string
}) => {
get().patchInstance(instanceId, { status })
if (qrBase64) get().setQr(instanceId, qrBase64)
if (status === 'CONNECTED') get().clearQr(instanceId)
})
socketService.on('qr', ({ instanceId, qrBase64 }: { instanceId: string; qrBase64: string }) => {
get().setQr(instanceId, qrBase64)
get().patchInstance(instanceId, { status: 'QR_PENDING' })
})
socketService.on('instance:avatar', ({ instanceId, avatar }: { instanceId: string; avatar: string }) => {
get().patchInstance(instanceId, { avatar })
})
socketService.on('instance:syncing', ({ instanceId, gapMinutes }: { instanceId: string; gapMinutes: number }) => {
const inst = get().instances.find((i) => i.id === instanceId)
const label = inst?.name ?? instanceId.slice(0, 8)
if (gapMinutes >= 60) {
notify(
`📥 ${label}: sincronizando mensagens dos últimos ${Math.round(gapMinutes / 60)}h`,
'warning',
8000,
'syncing',
)
} else if (gapMinutes > 0) {
notify(
`📥 ${label}: buscando mensagens perdidas (${gapMinutes} min offline)`,
'info',
6000,
'syncing',
)
}
})
},
}))
)
@@ -0,0 +1,5 @@
// Stub do notificationStore do motor. As notificações "syncing" que o
// instanceStore dispara não são essenciais no satélite — no-op evita portar todo
// o sistema de toasts do motor. (As páginas têm toasts locais próprios.)
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
export const notify = (_msg: string, _type?: NotificationType, _durationMs?: number, _tag?: string) => {};
@@ -0,0 +1,279 @@
import { create } from 'zustand'
import {
secAgentApi, secNodeApi, secConvApi, secCalApi, secNumberApi,
type SecAgent, type BrainNode, type SecConversation, type SecMessage, type CalendarSlot, type SecNumber,
} from '../services/secretariaApi'
interface SecretariaState {
// Data
agents: SecAgent[]
selectedAgent: SecAgent | null
nodes: BrainNode[]
conversations: SecConversation[]
selectedConversation: SecConversation | null
messages: SecMessage[]
calendarSlots: CalendarSlot[]
// Loading flags
loadingAgents: boolean
loadingNodes: boolean
loadingConversations: boolean
loadingMessages: boolean
sendingMessage: boolean
loadingCalendar: boolean
// ── Agents ────────────────────────────────────────────────────────────────
loadAgents: () => Promise<void>
selectAgent: (a: SecAgent | null) => void
createAgent: (d: Partial<SecAgent>) => Promise<SecAgent>
updateAgent: (id: string, d: Partial<SecAgent>) => Promise<void>
deleteAgent: (id: string) => Promise<void>
// ── Brain Nodes ───────────────────────────────────────────────────────────
loadNodes: (agentId: string) => Promise<void>
createNode: (agentId: string, d: Partial<BrainNode>) => Promise<void>
updateNode: (id: string, d: Partial<BrainNode>) => Promise<void>
deleteNode: (id: string) => Promise<void>
// ── Conversations ─────────────────────────────────────────────────────────
loadConversations: (agentId?: string) => Promise<void>
selectConversation: (c: SecConversation | null) => void
createConversation: (agentId: string, contactName: string) => Promise<SecConversation>
deleteConversation: (id: string) => Promise<void>
updateConversationStatus: (id: string, status: SecConversation['status']) => Promise<void>
// ── Messages ──────────────────────────────────────────────────────────────
loadMessages: (convId: string) => Promise<void>
sendMessage: (convId: string, text: string) => Promise<void>
// ── Calendar ──────────────────────────────────────────────────────────────
loadCalendar: (params?: { from?: string; to?: string; status?: string }) => Promise<void>
createCalendarSlot: (d: Partial<CalendarSlot>) => Promise<void>
updateCalendarSlot: (id: string, d: Partial<CalendarSlot>) => Promise<void>
deleteCalendarSlot: (id: string) => Promise<void>
// ── Numbers ───────────────────────────────────────────────────────────────
numbers: SecNumber[]
loadingNumbers: boolean
loadNumbers: () => Promise<void>
createNumber: (d: Partial<SecNumber>) => Promise<void>
updateNumber: (id: string, d: Partial<SecNumber>) => Promise<void>
deleteNumber: (id: string) => Promise<void>
}
export const useSecretariaStore = create<SecretariaState>((set, get) => ({
agents: [], selectedAgent: null, nodes: [],
conversations: [], selectedConversation: null, messages: [],
calendarSlots: [], numbers: [],
loadingAgents: false, loadingNodes: false, loadingConversations: false,
loadingMessages: false, sendingMessage: false, loadingCalendar: false, loadingNumbers: false,
// ── Agents ────────────────────────────────────────────────────────────────
loadAgents: async () => {
set({ loadingAgents: true })
try {
const agents = await secAgentApi.list()
set({ agents })
// Auto-seleciona o primeiro agente se nenhum selecionado
if (!get().selectedAgent && agents.length > 0) {
get().selectAgent(agents[0])
}
} finally {
set({ loadingAgents: false })
}
},
selectAgent: (a) => {
set({ selectedAgent: a, nodes: [], conversations: [], selectedConversation: null, messages: [] })
if (a) {
get().loadNodes(a.id)
get().loadConversations(a.id)
}
},
createAgent: async (d) => {
const agent = await secAgentApi.create(d)
set((s) => ({ agents: [...s.agents, agent] }))
return agent
},
updateAgent: async (id, d) => {
const updated = await secAgentApi.update(id, d)
set((s) => ({
agents: s.agents.map((a) => (a.id === id ? updated : a)),
selectedAgent: s.selectedAgent?.id === id ? updated : s.selectedAgent,
}))
},
deleteAgent: async (id) => {
await secAgentApi.delete(id)
set((s) => ({
agents: s.agents.filter((a) => a.id !== id),
selectedAgent: s.selectedAgent?.id === id ? null : s.selectedAgent,
}))
},
// ── Brain Nodes ───────────────────────────────────────────────────────────
loadNodes: async (agentId) => {
set({ loadingNodes: true })
try {
const nodes = await secNodeApi.list(agentId)
set({ nodes })
} finally {
set({ loadingNodes: false })
}
},
createNode: async (agentId, d) => {
const node = await secNodeApi.create(agentId, d)
set((s) => ({ nodes: [...s.nodes, node] }))
},
updateNode: async (id, d) => {
const updated = await secNodeApi.update(id, d)
set((s) => ({ nodes: s.nodes.map((n) => (n.id === id ? updated : n)) }))
},
deleteNode: async (id) => {
await secNodeApi.delete(id)
set((s) => ({ nodes: s.nodes.filter((n) => n.id !== id) }))
},
// ── Conversations ─────────────────────────────────────────────────────────
loadConversations: async (agentId) => {
set({ loadingConversations: true })
try {
const conversations = await secConvApi.list(agentId)
set({ conversations })
} finally {
set({ loadingConversations: false })
}
},
selectConversation: (c) => {
set({ selectedConversation: c, messages: [] })
if (c) get().loadMessages(c.id)
},
createConversation: async (agentId, contactName) => {
const conv = await secConvApi.create(agentId, contactName)
set((s) => ({ conversations: [conv, ...s.conversations] }))
return conv
},
deleteConversation: async (id) => {
await secConvApi.delete(id)
set((s) => ({
conversations: s.conversations.filter((c) => c.id !== id),
selectedConversation: s.selectedConversation?.id === id ? null : s.selectedConversation,
}))
},
updateConversationStatus: async (id, status) => {
const updated = await secConvApi.patch(id, { status })
set((s) => ({
conversations: s.conversations.map((c) => (c.id === id ? updated : c)),
selectedConversation: s.selectedConversation?.id === id ? updated : s.selectedConversation,
}))
},
// ── Messages ──────────────────────────────────────────────────────────────
loadMessages: async (convId) => {
set({ loadingMessages: true })
try {
const messages = await secConvApi.messages(convId)
set({ messages })
} finally {
set({ loadingMessages: false })
}
},
sendMessage: async (convId, text) => {
// Adiciona mensagem do usuário imediatamente (optimistic)
const tempId = `temp-${Date.now()}`
const userMsg: SecMessage = {
id: tempId, conversation_id: convId, role: 'user', content: text, created_at: new Date().toISOString(),
}
set((s) => ({ messages: [...s.messages, userMsg], sendingMessage: true }))
try {
const { reply } = await secConvApi.chat(convId, text)
const aiMsg: SecMessage = {
id: `ai-${Date.now()}`, conversation_id: convId, role: 'assistant', content: reply,
created_at: new Date().toISOString(),
}
set((s) => ({ messages: [...s.messages, aiMsg] }))
// Atualiza updated_at da conversa na lista
set((s) => ({
conversations: s.conversations.map((c) =>
c.id === convId ? { ...c, updated_at: new Date().toISOString() } : c,
),
}))
} finally {
set({ sendingMessage: false })
}
},
// ── Calendar ──────────────────────────────────────────────────────────────
loadCalendar: async (params) => {
set({ loadingCalendar: true })
try {
const calendarSlots = await secCalApi.list(params)
set({ calendarSlots })
} finally {
set({ loadingCalendar: false })
}
},
createCalendarSlot: async (d) => {
const slot = await secCalApi.create(d)
set((s) => ({ calendarSlots: [...s.calendarSlots, slot].sort((a, b) => a.date.localeCompare(b.date)) }))
},
updateCalendarSlot: async (id, d) => {
const updated = await secCalApi.update(id, d)
set((s) => ({ calendarSlots: s.calendarSlots.map((sl) => (sl.id === id ? updated : sl)) }))
},
deleteCalendarSlot: async (id) => {
await secCalApi.delete(id)
set((s) => ({ calendarSlots: s.calendarSlots.filter((sl) => sl.id !== id) }))
},
// ── Numbers ───────────────────────────────────────────────────────────────
loadNumbers: async () => {
set({ loadingNumbers: true })
try {
const numbers = await secNumberApi.list()
set({ numbers })
} finally {
set({ loadingNumbers: false })
}
},
createNumber: async (d) => {
const num = await secNumberApi.assign(d)
set((s) => ({
// upsert: substitui se já existia o mesmo instance_id
numbers: [...s.numbers.filter((n) => n.instance_id !== num.instance_id), num]
.sort((a, b) => a.priority - b.priority),
}))
},
updateNumber: async (id, d) => {
const updated = await secNumberApi.update(id, d)
set((s) => ({ numbers: s.numbers.map((n) => (n.id === id ? updated : n)).sort((a, b) => a.priority - b.priority) }))
},
deleteNumber: async (id) => {
await secNumberApi.delete(id)
set((s) => ({ numbers: s.numbers.filter((n) => n.id !== id) }))
},
}))
+117
View File
@@ -0,0 +1,117 @@
// ─── Rich payload types ───────────────────────────────────────────────────────
export type NativeBtn =
| { type: 'reply'; id: string; text: string }
| { type: 'url'; text: string; url: string }
| { type: 'copy'; text: string; copyText: string }
| { type: 'call'; text: string; phoneNumber: string }
export interface RichPayload {
text?: string
footer?: string
nativeButtons?: NativeBtn[]
nativeList?: {
buttonText: string
sections: Array<{ title: string; rows: Array<{ id: string; title: string; description?: string }> }>
}
nativeCarousel?: {
cards: Array<{
title?: string; body?: string; footer?: string
image?: { url: string }
buttons: Array<{ type?: string; id?: string; text: string }>
}>
}
poll?: { name: string; values: string[]; selectableCount?: number }
}
export interface Message {
id: number;
chat_id: number;
message_id: string;
text: string;
type: string;
direction: 'in' | 'out';
status: 'sending' | 'pending' | 'sent' | 'delivered' | 'read' | 'deleted' | 'failed';
timestamp: string;
media_url?: string;
media_type?: string;
media_size?: number;
quoted_id?: string | null;
quoted_message_id?: string | null;
quoted_message_text?: string | null;
quoted_participant?: string | null;
reactions?: Record<string, string> | null;
participant?: string | null;
instance_name?: string;
sender_jid?: string;
rich_payload?: RichPayload | null;
transcription?: string | null;
}
export interface WhatsAppInstance {
instance: string;
/** New Whats API status: open, connected, disconnected, connecting, etc. */
status: 'connecting' | 'qr' | 'connected' | 'disconnected' | 'open' | 'close';
phone?: string;
hasQr: boolean;
createdAt: string;
profilePictureUrl?: string;
photoUpdatesRemaining?: number;
partner_id?: number | null;
partner_name?: string | null;
/** For UI display primarily from the Console/Admin View */
id?: string;
nome?: string;
avatar?: string | null;
webhook_url?: string;
metrics?: {
sent: number;
failed: number;
};
}
// ─── Inbox / Chat UI types ────────────────────────────────────────────────────
// Formato legado consumido por ChatList, ChatListItem, ChatSidebar e hooks de inbox.
// Produzido pelo mapper chatToLegacy() em useNewInboxBridge.
export interface NewWhatsChat {
id: any
instance_name: string
remote_jid: string
phone: string
lead_name: string | null
avatar_url: string | null
avatar_version: string | null
bio: string | null
displayName: string | null
subject: string | null
last_message_text: string | null
last_message_time: string | null
last_message_from_me: 0 | 1
last_message_status: string | null
last_message_type?: string | null
last_message_sender?: string | null
unread_count: number
is_pinned: boolean | null
is_archived: boolean | null
muted_until?: string | null
bot_paused?: boolean | null
labels?: Array<{ label_id: string; name?: string }>
}
export interface PresenceState {
state: 'composing' | 'recording' | 'paused' | 'available' | 'unavailable'
ts: number
}
export type PurgeMode = 'all' | 'data_only';
export type StepStatus = 'waiting' | 'running' | 'ok' | 'error' | 'skip';
export interface PurgeStep {
id: string;
label: string;
detail: string;
icon?: any;
status: StepStatus;
}
export default function TypesPage() { return null; }
+100
View File
@@ -0,0 +1,100 @@
const getAccessToken = () => {
if (typeof window !== 'undefined') return localStorage.getItem('token') || '';
return '';
};
export const normalizeJid = (jid: string) => {
if (!jid) return '';
const [user] = jid.split('@');
const [cleanUser] = user.split(':');
return cleanUser;
};
export const formatPhone = (phone: string, remoteJid?: string): string => {
const raw = String(phone || '').trim();
const jid = String(remoteJid || '');
const cleaned = raw.replace(/\D/g, '');
// ── @lid — identificador interno do WhatsApp: exibe como +número ──────────
if (jid.endsWith('@lid')) {
const id = (normalizeJid(jid) || cleaned).replace(/\D/g, '');
return id ? `+${id}` : raw;
}
// ── Brasil (55) — 12 ou 13 dígitos ───────────────────────────────────────
if ((cleaned.length === 12 || cleaned.length === 13) && cleaned.startsWith('55')) {
return cleaned.replace(/^(\d{2})(\d{2})(\d{4,5})(\d{4})$/, '+$1 $2 $3-$4');
}
// ── EUA / Canadá (1) — 11 dígitos ────────────────────────────────────────
if (cleaned.length === 11 && cleaned.startsWith('1')) {
return cleaned.replace(/^(\d{1})(\d{3})(\d{3})(\d{4})$/, '+$1 ($2) $3-$4');
}
// ── Outros países — formato E.164 genérico ────────────────────────────────
if (cleaned.length >= 7 && cleaned.length <= 15) {
return `+${cleaned}`;
}
return raw || cleaned;
};
export const getAvatarInitials = (chat: any) => {
const name = chat.displayName || chat.subject || chat.phone || '';
return name.slice(0, 2).toUpperCase();
};
export const getAvatarColor = (id: string): string => {
let hash = 0;
const cleanId = String(id || 'anon');
for (let i = 0; i < cleanId.length; i++) {
hash = cleanId.charCodeAt(i) + ((hash << 5) - hash);
}
const h = Math.abs(hash % 360);
// HSL: S=65%, L=45% for a vibrant, accessible look with white text
return `hsl(${h}, 65%, 45%)`;
};
export const getLabelColor = (index: number) => {
const colors = [
'bg-gray-500', 'bg-red-500', 'bg-orange-500', 'bg-yellow-500',
'bg-green-500', 'bg-teal-500', 'bg-blue-500', 'bg-indigo-500',
'bg-purple-500', 'bg-pink-500'
];
return colors[index % colors.length] || 'bg-gray-500';
};
export const getMediaUrl = (url: string | null | undefined): string | null => {
if (!url) return null;
// URL absoluta (ex.: Wasabi/CDN público) → usa direto, como o motor exibe.
if (url.startsWith('http')) return url;
// Mídia do WhatsApp vive no MOTOR (path /media/<instância>/<arquivo>, servido
// do Wasabi/local). O satélite proxia em /api/nw/media/* (same-origin, sem auth
// no browser) — assim exibe igual ao motor, direto do Wasabi.
if (url.startsWith('/media/')) return `/api/nw/media/${url.slice('/media/'.length)}`;
const clean = url.startsWith('/') ? url.slice(1) : url;
return `/api/nw/media/${clean}`;
};
export const getAvatarProxyUrl = (chat: any, _type: 'instance' | 'contact' = 'contact'): string | null => {
// Satélite: não há proxy de avatar exposto na ext API — retorna null e o
// componente Avatar cai para as iniciais (fallback limpo, sem 404s).
return null;
};
export const parseSafeDate = (timestamp: any): Date => {
if (!timestamp) return new Date();
let d = new Date(timestamp);
// If invalid and a number (or numeric string)
if (isNaN(d.getTime())) {
const num = Number(timestamp);
if (!isNaN(num)) {
// Unix seconds (Whats APIs often use this) vs ms
d = new Date(num > 10000000000 ? num : num * 1000);
}
}
return isNaN(d.getTime()) ? new Date() : d;
};
export default function UtilsPage() { return null; }
+5 -1
View File
@@ -13,7 +13,11 @@ export const getActivePlugins = (): string[] => {
};
// Plugins sempre ligados (capability de plataforma, sem opção de desativar).
export const ALWAYS_ON_PLUGINS = ['locacao-salas'];
// 'newwhats': Inbox/Secretária precisam aparecer para TODOS os usuários, mas a
// ativação por plugin é local (localStorage/navegador) e não propaga entre contas.
// Por isso fica ALWAYS_ON; a CONFIG continua exclusiva do superadmin (view 'plugins').
// (Futuro: gate por /api/nw/status quando o backend expuser "configurado".)
export const ALWAYS_ON_PLUGINS = ['locacao-salas', 'newwhats'];
export const isPluginActive = (id: string): boolean => ALWAYS_ON_PLUGINS.includes(id) || getActivePlugins().includes(id);
+8
View File
@@ -5,10 +5,18 @@ import tailwindcss from '@tailwindcss/vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
// Servido atrás de um proxy TLS (ex.: dev.scoreodonto.com via nginx/traefik):
// libera o host e roteia o websocket de HMR por wss:443. Só ativa quando
// DEV_PUBLIC_HOST está definido (container dev) — `npm run dev` local não muda.
const proxiedHost = process.env.DEV_PUBLIC_HOST;
return {
server: {
port: 3000,
host: 'localhost',
...(proxiedHost ? {
allowedHosts: [proxiedHost],
hmr: { protocol: 'wss', host: proxiedHost, clientPort: 443 },
} : {}),
},
plugins: [
react(),
+12
View File
@@ -36,6 +36,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy /nw/* para o Backend — a Secretária IA do motor NewWhats consulta
# os dados desta clínica (rotas /nw/* do plugin satélite).
location /nw/ {
set $backend_upstream http://scoreodonto-backend:8018;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy para o Frontend (Vite React SPA)
location / {
set $frontend_upstream http://scoreodonto-frontend:3013;