Compare commits

..

44 Commits

Author SHA1 Message Date
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
VPS 4 Builder 7c8c8fc0d2 feat(protese): fluxo do lado do protético — entrada de OS, baixa/cobrança e importar OS indicada
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 52s
Fecha o ciclo operacional do laboratório, antes só "receptor passivo" das OS da clínica.

1. Dar entrada em OS pelo lab — POST /api/protese/lab/os: o dono do laboratório registra um
   trabalho recebido fora do app, escolhendo cliente existente OU avulso (clínica/consultório/
   dentista, materializado como registro mínimo) + produto do catálogo ou tipo livre. A OS já
   entra com laboratorio_id e custódia no laboratório. UI: botão "Dar entrada" + modal na LabHome.
2. Dar baixa em pagamento — já existia (lado 'lab'); mantido na PagamentosSecao.
3. Solicitar pagamento — POST /api/protese/os/:id/cobrar: calcula o saldo devedor (custo − pago)
   e notifica o cliente, registrando no histórico. UI: botão "Solicitar pagamento (saldo)" na
   PagamentosSecao do modo bancada.
4. Importar/vincular OS indicada — GET /api/protese/lab/os-indicadas (OS com protetico_id=me e
   laboratorio_id NULL) + POST /api/protese/os/:id/vincular-lab (seta laboratorio_id, status→recebido,
   notifica a clínica). Sem isso a OS só aparecia ao dono via fallback; importando, a equipe/técnicos
   e o financeiro do lab passam a vê-la. UI: seção "OS indicadas a você" na LabHome.

origemDe agora reconhece cliente avulso tipo 'dentista'. Validado em DEV (todos os 4 endpoints).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:43:35 +02:00
VPS 4 Builder 7566c01159 fix(protese): painel do lab usa fechamento próprio em vez do financeiro de pacientes (corrige 403)
LabHomeView chamava GET /api/financeiro (capability 'financeiro', negada ao protético) só para o
KPI "faturamento do mês" → 403 no console ao abrir o painel do laboratório. Passa a usar
GET /api/protese/lab/fechamento (custo das OS entregues no mês), que é do escopo do lab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:22:45 +02:00
VPS 4 Builder 87411e1566 feat(protese): CRM financeiro do laboratório (origem, contato, ticket, fechamento mensal)
Atende ao pedido de CRM financeiro do protético — antes as telas Clientes/Financeiro existiam mas
não distinguiam origem nem traziam relacionamento/fechamento.

- lab/clientes agora traz por cliente: ORIGEM (Clínica / Consultório / Dentista / Biomédico, derivada
  de clinicas.tipo + role do owner), CONTATO (telefone), TICKET MÉDIO, ÚLTIMO PEDIDO, além de
  OS/ativas/atrasadas/faturado/recebido/a-receber.
- Novo lab/fechamento: faturado x recebido por mês.
- LabClientesView reformulada: aba "Clientes (CRM)" com resumo por origem + tabela
  (origem/contato/ticket/últ. pedido/a-receber); aba "Financeiro" com KPIs + FECHAMENTO MENSAL + extrato.

Validado em DEV: cliente Clínica (a-receber 0) e Dentista avulso (a-receber 100); fechamento 06/2026
faturado 550 / recebido 450.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:07:11 +02:00
VPS 4 Builder 29b5b38678 fix(protese): protético acessa o Perfil/Marketplace para se publicar no diretório
Sem isso, o protético recém-cadastrado ficava invisível: o item PROFISSIONAIS não aparecia no
workspace laboratório (plugin não ativo) e o contexto laboratório bloqueava a tela — então ele não
tinha como publicar o perfil no diretório e nenhuma clínica o encontrava para enviar trabalho.

- Sidebar: PROFISSIONAIS aparece sempre para dentista/biomédico/protético (eles divulgam perfil),
  mesmo sem o plugin ativado.
- App: contexto 'laboratorio' libera marketplace-profissionais + diretorio-profissionais.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 03:56:01 +02:00
VPS 4 Builder 361f3809a7 docs(verdade-hoje): produção em v1.0.31 (4673f3c)
build-and-promote / build (push) Successful in 1m0s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:04:20 +02:00
VPS 4 Builder 4673f3c606 feat(protese): cadastro de protético interno (sem conta) + carteira no link mágico
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m2s
- Protético interno: a clínica cadastra um protético que não usa a plataforma. Cria usuário "mudo"
  (e-mail placeholder @interno.local + senha-fantasma; usuarios.email é NOT NULL/UNIQUE) + vínculo
  interno (fora do diretório). POST /protese/proteticos-internos; botão "Cadastrar protético interno"
  na Nova OS, que recarrega o dropdown e seleciona o novo.
- Carteira no link /p/TOKEN: botão "Meus trabalhos" → trabalhos em andamento/finalizados + mini
  financeiro mensal (valores pagos ao protético). GET /api/p/:token/carteira, escopo da relação
  clínica↔protético do token (não vaza outras clínicas; sem dados do paciente).

Validado em DEV: interno criado e acessível no dropdown; OS+link; carteira com andamento + R$250/2026-06.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:11:07 +02:00
VPS 4 Builder dc5a2d89ae feat(protese): agenda & coleta do laboratório (integrada à custódia)
Novo módulo de logística do lab (doc/AGENDA-COLETA-LAB):
- protese_coleta: agendamento de coleta (lab busca) / entrega (lab leva), com data/hora,
  endereço, responsável, status. Ligado à OS.
- POST /protese/os/:id/coleta (agendar), GET /protese/lab/agenda (cronológica),
  POST /protese/coleta/:id/status. 'realizada' MOVE a custódia automaticamente
  (coleta→laboratorio, entrega→clinica) — fonte única do movimento físico. GET detalhe traz coletas[].
- UI: tela AGENDA (lab-agenda) agrupada por dia com "marcar realizada"; seção Coleta/entrega na
  aba Monitoramento da OS (clínica e lab).

Validado em DEV: agenda coleta → realizada → custódia=laboratorio; lab vê na agenda.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:11:24 +02:00
VPS 4 Builder 7671199420 docs(verdade-hoje): produção em v1.0.30 (6cd8c1f)
build-and-promote / build (push) Successful in 1m3s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:00:41 +02:00
VPS 4 Builder 6cd8c1f9e1 feat(protese): marketplace transacional fatia 4 — avaliação, satisfação na nota e selo verificado
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m5s
- A clínica avalia o laboratório (1-5★ + comentário) após a entrega (protese_avaliacao, 1 por OS,
  reavaliável). POST /protese/os/:id/avaliar; avaliação volta no GET detalhe.
- Nota ScoreOdonto passa a combinar 0.6·operacional + 0.4·satisfação (estrelas) quando há avaliações.
  Selo "Verificado" (≥5 entregas). Exposto no marketplace (★nota · nº avaliações · Verificado) e nos
  indicadores do lab (KPI Satisfação). Helper notasScorePorProtetico unificado (dropdown + marketplace).
- UI: seção "Avaliar o laboratório" na OS entregue (lado clínica); selo/avaliações no card do marketplace.
- Correção de passagem: FileText/Star importados em ProteseView (FileText faltava desde a fatia 3).

Validado em DEV: 6 entregas + 5★/3★ → nota 9.2, satisfação 4★, verificado (marketplace e indicadores).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:56:35 +02:00
VPS 4 Builder c084ad553b docs(verdade-hoje): produção em v1.0.29 (cd89ca2)
build-and-promote / build (push) Successful in 1m4s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:03:13 +02:00
VPS 4 Builder cd89ca245c feat(protese): marketplace transacional fatia 3 — cotação (RFQ) multi-lab
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 58s
Entidades protese_rfq + protese_rfq_cotacao. A clínica descreve o trabalho e convida N labs;
cada lab cota (valor/prazo/obs); a clínica compara (ordenado por preço) e escolhe → vira OS
(custo = valor da cotação) e a RFQ fecha (demais recusadas).

- Backend: POST/GET /protese/rfq, GET /protese/rfq/recebidas, POST /protese/rfq/cotacao/:id,
  POST /protese/rfq/:id/escolher (cria a OS).
- Lado lab: tela Cotações (lab-cotacoes) — RFQs recebidas + responder.
- Lado clínica: botão Cotações na tela de Prótese — pedir multi-lab + comparar + escolher.

Validado em DEV ponta a ponta: pedido → cotação R$420 → escolha → OS criada → RFQ fechada.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:43:56 +02:00
VPS 4 Builder 07d798c370 docs(verdade-hoje): produção em v1.0.28 (6a88995)
build-and-promote / build (push) Successful in 50s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:00:31 +02:00
VPS 4 Builder 6a88995146 feat(protese): marketplace transacional fatia 2 — solicitar prótese cria a OS direto
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 47s
"Solicitar prótese" no card do protético abre o SolicitarProteseModal (lab pré-selecionado):
produto do catálogo (ou livre) + paciente + dentista + dentes + prazo + obs → cria a OS via
createProteseOS (que já aceita lab do diretório, sem vínculo prévio). Confirmação com CTA p/ a
Bancada. Modal auto-contido reusando getLaboratorioProdutos/getDentistas/getPacientes/createProteseOS.

Validado em DEV: OS criada para lab do diretório (Coroa de Zircônia, status solicitado).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:56:33 +02:00
VPS 4 Builder f5a6dc7ea0 docs(verdade-hoje): produção em v1.0.27 (b5d093d)
build-and-promote / build (push) Successful in 53s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:52:30 +02:00
VPS 4 Builder b5d093d2ce feat(protese): marketplace transacional fatia 1 — vitrine de catálogo do laboratório
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 49s
doc/MARKETPLACE-TRANSACIONAL-PROTESE: desenho do transacional (vitrine → solicitação → cotação
→ reputação), reusando catálogo/propostas/OS/score existentes.

Fatia 1: no card do protético no marketplace, "Ver catálogo de próteses" expande os produtos
(nome, preço, garantia) — reusa getLaboratorioProdutos (o catálogo já é acessível a quem está no
diretório, zero endpoint novo). Botão de contato vira "Solicitar prótese". A clínica passa a
decidir vendo serviços + preço + garantia + ★nota antes de contratar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:47:08 +02:00
VPS 4 Builder 489d2753ee docs(verdade-hoje): produção em v1.0.26 (d357f3a)
build-and-promote / build (push) Successful in 47s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:40:35 +02:00
VPS 4 Builder d357f3a2e8 feat(protese): Nota ScoreOdonto pública no marketplace de profissionais
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s
GET /api/profissionais/marketplace passa a trazer a 'nota' dos protéticos (helper
notasScorePorProtetico, mesma fórmula da Fase 5 — só com volume mínimo). Card do protético
no marketplace exibe ★ nota. Ordem mantida por proximidade; a nota é comparação visível.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:28:39 +02:00
VPS 4 Builder 2e90a2ba5f chore(protese): remove menu legado do protético (higiene)
O protético opera dentro do workspace 'laboratorio' (menu governado pelo branch tipo).
Remove as telas clínicas legadas (agenda/lancar-gto/proteses/tratamentos/dashboard) do
fallback do protético em Sidebar.tsx e da lista de permissões em App.tsx; o fallback fora do
contexto laboratório fica mínimo (notificações/clínicas/config/diretório).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:28:39 +02:00
VPS 4 Builder 53cf2c52e6 docs(verdade-hoje): produção em v1.0.25 (8040e70)
build-and-promote / build (push) Successful in 48s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:18:54 +02:00
91 changed files with 17482 additions and 316 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/`.
+194
View File
@@ -0,0 +1,194 @@
// 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';
const SLOT_MIN = 30; // duração padrão do slot (min)
const HORIZON_DAYS = 7; // janela padrão de busca
const MAX_SLOTS = 20;
// A agenda do scoreodonto é livre (quase ninguém preenche dentistas_horarios).
// Sem jornada configurada, oferecemos horário comercial padrão (segsex 0818).
const DEFAULT_INI = '08:00';
const DEFAULT_FIM = '18:00';
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`;
// 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) {
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
LIMIT 1`,
[ids, start, end || start]);
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();
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.' });
// Jornadas configuradas (opcional).
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); }
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 < MAX_SLOTS; i++) {
const day = new Date(start0); day.setDate(day.getDate() + i);
const dow = day.getDay();
for (const dent of dentistas) {
if (slots.length >= MAX_SLOTS) break;
const cfg = hoursBy.get(dent.id);
// Janelas do dia: jornada configurada OU padrão comercial (segsex).
const windows = (cfg && cfg.length)
? cfg.filter((h) => h.dia_semana === dow).map((h) => [h.hora_inicio, h.hora_fim])
: ((dow >= 1 && dow <= 5) ? [[DEFAULT_INI, DEFAULT_FIM]] : []);
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 < MAX_SLOTS) {
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) });
}
}
}
}
slots.sort((a, b) => a.start.localeCompare(b.start));
res.json({ slots: slots.slice(0, MAX_SLOTS) });
} 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)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'agendado',$10,'Secretária IA',NOW(),NOW())
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]);
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();
}
});
return router;
}
+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()
}
+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();
}
+166
View File
@@ -0,0 +1,166 @@
// 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, 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'
// 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));
// ── Proxy de MÍDIA: /api/nw/media/* → motor /media/* ───────────────────────
// A mídia do WhatsApp vive no motor (servida do Wasabi/local, pública). O
// satélite a repassa mantendo same-origin no <img>/<video> (sem exigir header
// de auth no browser), com suporte a Range para áudio/vídeo.
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 url = `${cfg.motorUrl.replace(/\/$/, '')}/media/${rest}`;
try {
const headers = {};
if (req.headers.range) headers.range = req.headers.range;
const r = await fetch(url, { headers });
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);
}
if (!res.getHeader('cache-control')) res.setHeader('cache-control', 'private, max-age=3600');
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.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)." }
]
}
+313
View File
@@ -0,0 +1,313 @@
// 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; }
}
// 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.
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 isDeleteMsg = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/[^/]+/.test(tail);
if (!isDeleteChat && !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). 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}`;
}
const url = `${cfg.motorUrl}/api/ext/v1${tail}`;
const opts = { method: req.method, headers: {
'Content-Type': 'application/json',
'x-nw-key': cfg.integrationKey,
'x-nw-email': email,
// 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']) } : {}),
} };
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) && email) {
const ok = await provisionNewwhatsAccount(pool, cfg, email);
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}` });
}
};
}
// ── 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.
export function createWsTunnel() {
return function wsTunnel(req, clientSocket, head) {
const fail = (statusLine) => {
try { clientSocket.write(`HTTP/1.1 ${statusLine}\r\n\r\n`); } catch { /* socket já morto */ }
clientSocket.destroy();
};
// Auth: JWT do scoreodonto no query string (?token=).
let token;
try { token = new URL(req.url, 'http://localhost').searchParams.get('token'); }
catch { return fail('400 Bad Request'); }
let email;
try {
const p = jwt.verify(token, process.env.JWT_SECRET);
email = (p.email || '').toLowerCase();
} catch { return fail('401 Unauthorized'); }
if (!email) return fail('401 Unauthorized');
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: ${email}\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
}
+849 -61
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
# Agenda & Coleta do laboratório
> Desenho + implementação (24/jun/2026). Gap apontado na auditoria: o lab não tem agenda de
> produção nem coleta logística (só "próximas entregas" por prazo e custódia de posse).
## Conceito
- **Coleta** = o lab **busca** o trabalho na clínica (trabalho vai p/ o laboratório).
- **Entrega** = o lab **leva** o trabalho de volta (trabalho volta p/ a clínica).
- Cada agendamento tem data/hora, endereço, responsável, observação e status
(`agendada | realizada | cancelada`). Ligado a uma OS.
- **Integra com a custódia:** ao marcar `realizada`, a custódia muda automaticamente
(coleta→`laboratorio`, entrega→`clinica`) e entra no histórico da OS. Sem dado duplicado.
## Modelo
```sql
CREATE TABLE protese_coleta (
id TEXT PRIMARY KEY,
os_id TEXT NOT NULL,
clinica_id TEXT,
laboratorio_id TEXT,
protetico_id TEXT,
tipo TEXT NOT NULL, -- coleta | entrega
data_hora TIMESTAMPTZ,
endereco TEXT,
responsavel TEXT,
observacao TEXT,
status TEXT DEFAULT 'agendada', -- agendada | realizada | cancelada
created_by TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
## Endpoints
- `POST /api/protese/os/:id/coleta` — agenda (clínica ou lab).
- `GET /api/protese/lab/agenda` — coletas/entregas do lab (cronológica) + próximas entregas por prazo.
- `POST /api/protese/coleta/:id/status``realizada` (move a custódia) | `cancelada`.
- GET detalhe da OS passa a trazer `coletas[]`.
## UI
- **Lab — tela Agenda** (`lab-agenda`, novo item de menu): linha do tempo de coletas/entregas
agendadas + entregas previstas (prazo da OS), com ação "marcar realizada".
- **OS (aba Monitoramento)** — agendar coleta/entrega ao lado da custódia (mesmo contexto físico).
## Decisões
- 1 coleta por OS (MVP). Agrupar várias OS numa rota/motoboy fica como evolução.
- `realizada` é a fonte única do movimento físico (atualiza custódia) — evita registrar custódia
e coleta em separado.
+58
View File
@@ -0,0 +1,58 @@
# Marketplace transacional de Prótese
> Desenho (24/jun/2026). O último grande item do backlog do Provedor de Prótese.
> Princípio: **transformar o diretório (descoberta) em jornada de compra** — descobrir → ver
> serviços/preços → solicitar → cotar → contratar → vira OS. Reaproveita ao máximo o que já existe.
## O que já existe (não recriar)
- **Descoberta:** `/api/profissionais/marketplace` (busca por proximidade/especialidade) **+ Nota
ScoreOdonto** no card (feito).
- **Catálogo:** `protese_produtos` (nome, preço, garantia) + `GET /api/protese/laboratorios/:id/produtos`
— já acessível a quem está no **diretório** (`proteticoAcessivelPelaClinica` aceita `disponivel_diretorio`).
- **Propostas:** `propostas_profissional` (clínica→profissional: mensagem, `pendente→aceita/recusada`).
- **Contratação:** a clínica **já cria OS para qualquer protético do diretório** (Nova OS).
- **Pós-venda:** OS completa (etapas, custódia, ocorrências), financeiro, score — tudo pronto.
**Conclusão:** o "transacional" não exige nova fundação — falta **costurar a jornada** dentro do
marketplace (hoje a clínica vê nome/nota/distância, mas não os serviços, e contrata por outra tela).
## Fatias
### ✅ Fatia 1 — Vitrine (catálogo no marketplace) — IMPLEMENTADA
No card do protético no marketplace, **"Ver catálogo"** expande os produtos (nome, preço, garantia)
via `getLaboratorioProdutos(id)` (reuso — zero endpoint novo). A clínica passa a **decidir vendo
serviços + preço + garantia + ★nota** antes de contratar. Botão de contato vira **"Solicitar
prótese"** (proposta com contexto de prótese).
### ✅ Fatia 2 — Solicitação → OS direto — IMPLEMENTADA
"Solicitar prótese" (card do protético) abre o `SolicitarProteseModal` — fluxo enxuto com o lab
**pré-selecionado**: produto do catálogo (ou descrição livre) + paciente (busca) + dentista + dentes
+ prazo + observações → **cria a OS** via `createProteseOS` (que já aceita lab do diretório, sem
vínculo prévio). Tela de confirmação com CTA para a Bancada. Optei por um modal próprio e auto-contido
(reusa getLaboratorioProdutos/getDentistas/getPacientes/createProteseOS) em vez de extrair a Nova OS —
menor risco. Validado em DEV: OS criada para lab do diretório (Coroa de Zircônia, status solicitado).
### ✅ Fatia 3 — Cotação (RFQ) — IMPLEMENTADA
Entidades novas `protese_rfq` + `protese_rfq_cotacao`. Fluxo: a clínica descreve o trabalho e
**convida N labs** → cada lab **cota** (valor/prazo/obs) → a clínica **compara** (ordenado por preço)
e **escolhe** → vira **OS** (custo = valor da cotação) e a RFQ fecha (demais cotações recusadas).
Endpoints: `POST/GET /protese/rfq`, `GET /protese/rfq/recebidas`, `POST /protese/rfq/cotacao/:id`,
`POST /protese/rfq/:id/escolher`. UI: lado lab = tela **Cotações** (`lab-cotacoes`, responde); lado
clínica = botão **Cotações** na tela de Prótese (pedir multi-lab + comparar + escolher). Validado em
DEV ponta a ponta (pedido → cotação R$420 → escolha → OS criada → RFQ fechada).
### ✅ Fatia 4 — Reputação (avaliação + selo) — IMPLEMENTADA
A clínica avalia o laboratório (1-5★ + comentário) **após a entrega** (`protese_avaliacao`, 1 por OS,
reavaliável). A satisfação **entra na Nota ScoreOdonto**: `0.6·operacional + 0.4·(estrelas/5·10)` (sem
avaliações = só operacional). **Selo "Verificado"** com ≥ 5 entregas. Exposto no marketplace (★nota ·
nº avaliações · Verificado) e nos indicadores do lab (KPI Satisfação). Endpoint `POST
/protese/os/:id/avaliar`. Validado em DEV: 6 entregas + avaliações 5★/3★ → nota **9.2**, satisfação 4★,
verificado, em marketplace e indicadores.
**Marketplace transacional concluído (Fatias 1-4).**
## Decisões de modelagem
- **Catálogo público = catálogo atual** (`protese_produtos`); a vitrine só o exibe — sem duplicar.
- **Solicitação reusa a OS** (não cria entidade paralela): toda contratação termina em `protese_os`,
preservando rastreabilidade/financeiro/score.
- **Cotação (Fatia 3)** é o único caso que precisa de entidade nova (pré-OS multi-lab).
+9
View File
@@ -104,5 +104,14 @@ marketplace/score (Fase 5) — quanto mais clínicas o usam, mais OS e melhor o
**Modo 1 concluído (1a leitura + 1b ação + 1c gestão).**
### ✅ Extras (24/jun)
- **Carteira pela link** (`GET /api/p/:token/carteira`): botão "Meus trabalhos" na página pública →
trabalhos em andamento + finalizados + **mini financeiro mensal** (valores pagos ao protético).
Escopo = a relação clínica↔protético da OS do token (não vaza outras clínicas; sem dados do paciente).
- **Protético INTERNO (sem conta)** (`POST /api/protese/proteticos-internos`): a clínica cadastra um
protético que não usa a plataforma — cria um usuário "mudo" (e-mail placeholder `..@interno.local`,
senha-fantasma; `usuarios.email` é NOT NULL/UNIQUE) + vínculo interno (fora do diretório). Botão
"Cadastrar protético interno" na Nova OS. A partir daí recebe OS e acompanha pelo link, sem conta.
> Nenhuma refatoração: reusa OS/eventos/custódia/ocorrências/blindagem já existentes. Só adiciona a
> camada de token público.
+13 -3
View File
@@ -106,6 +106,16 @@ Score/ranking/marketplace. Não codar indicador antes de existir dado.
> Acoplamentos respeitados: PF/PJ veio com a equipe (Fase 4); Score só após a captura (Fases 2-4).
### Fora de escopo (futuro)
Marketplace **transacional** (propostas/contratação dentro da plataforma) e o Modo 1 (protético sem
conta via link seguro `/p/TOKEN`) seguem como evolução — a base de dados/score já os suporta.
### Pós-roadmap (entregue depois das Fases 1-5)
- **✅ Modo 1** (protético sem conta via link `/p/TOKEN`) — completo (1a leitura, 1b ação, 1c gestão).
Ver `MODO1-LINK-SEGURO-PROTESE.md`.
- **✅ Higiene** — menu legado do protético removido (Sidebar/App): o protético opera só no workspace
laboratório; fallback mínimo fora dele.
- **✅ Score público no marketplace** — `GET /api/profissionais/marketplace` traz a `nota` ScoreOdonto
dos protéticos (helper `notasScorePorProtetico`, mesma fórmula da Fase 5), exibida como ★ no card.
Ordem mantida por proximidade; a nota é critério de comparação visível.
### Ainda em aberto (futuro)
Marketplace **transacional** (anúncio de serviços + solicitação/contratação/propostas dentro da
plataforma); **agenda de produção/coleta**; **multi-unidade** do laboratório; envio automático do
link (e-mail/WhatsApp API) e herança de cadastro novo com e-mail diferente.
+3 -3
View File
@@ -9,7 +9,7 @@
### 1. Onde está o código?
- **Fonte da verdade:** Gitea na **VPS3**, repo `ruicesar/scoreodonto.com`.
- **Dev (trabalho):** VPS4 em `/home/deploy/stack/scoreodonto.com`.
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.24 · commit `28dc27a`**.
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.31 · commit `4673f3c`**.
- **Documentação:** centralizada em `scoreodonto.com/doc/` (pasta única).
### 2. Onde está o banco?
@@ -28,7 +28,7 @@
-**Build Once → Promote (Passo 5, validado em produção):** push builda 1× e publica por **commit** (`:<sha>`); a **tag re-tagueia** esse artefato como `:vX.Y.Z` (sem rebuild — digest idêntico) e deploya. Provado em v1.0.16 (`:v1.0.16` == `:471c2c2`, mesmo digest).
### 5. Como versiona?
- A versão exibida vem do **backend em runtime**: `GET /api/version` (de `process.env`), mostrada em **Configurações → "Sobre o sistema"** e nas telas públicas (o frontend consome via `useAppVersion()`, não embute mais a versão). No ar: **v1.0.24 · `28dc27a` · PROD**.
- A versão exibida vem do **backend em runtime**: `GET /api/version` (de `process.env`), mostrada em **Configurações → "Sobre o sistema"** e nas telas públicas (o frontend consome via `useAppVersion()`, não embute mais a versão). No ar: **v1.0.31 · `4673f3c` · PROD**.
- A versão **nasce da tag git** e é **injetada no deploy em runtime** (`APP_VERSION`=tag, `APP_ENV=PROD`); `commit`/`build` vêm carimbados na imagem.
-**`constants.ts`/`update-version.js` foram removidos** (Passo 5) — não há mais versão manual.
@@ -39,4 +39,4 @@
---
## Veredito de uma linha
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.24**), sem buildar; o **DEV está isolado no `pgdev`** (não toca o banco de produção). **CI/CD por tag ativo** no modelo **Build Once → Promote → Deploy** (push builda por commit; tag re-tagueia sem rebuild e deploya — validado em v1.0.16), com **versão visível em runtime** (`/api/version` → Configurações) nascendo da tag git.
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.31**), sem buildar; o **DEV está isolado no `pgdev`** (não toca o banco de produção). **CI/CD por tag ativo** no modelo **Build Once → Promote → Deploy** (push builda por commit; tag re-tagueia sem rebuild e deploya — validado em v1.0.16), com **versão visível em runtime** (`/api/version` → Configurações) nascendo da tag git.
+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)
+60 -6
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';
@@ -32,7 +37,9 @@ import { ComissoesView } from './views/ComissoesView.tsx';
import { GlosasView } from './views/GlosasView.tsx';
import { SalaHomeView } from './views/SalaHomeView.tsx';
import { LabHomeView } from './views/LabHomeView.tsx';
import { LabAgendaView } from './views/LabAgendaView.tsx';
import { LabClientesView } from './views/LabClientesView.tsx';
import { LabCotacoesView } from './views/LabCotacoesView.tsx';
import { LabEquipeView } from './views/LabEquipeView.tsx';
import { LabIndicadoresView } from './views/LabIndicadoresView.tsx';
import { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
@@ -84,14 +91,19 @@ export type ViewKey =
| 'contratos'
| 'plugins'
| 'rx-radiografias'
| 'wa-inbox'
| 'wa-sessions'
| 'wa-secretaria'
| 'salas'
| 'minhas-salas'
| 'alugar-salas'
| 'sala-home'
| 'lab-home'
| 'lab-bancada'
| 'lab-agenda'
| 'lab-clientes'
| 'lab-financeiro'
| 'lab-cotacoes'
| 'lab-equipe'
| 'lab-indicadores'
| 'marketplace-profissionais'
@@ -140,14 +152,19 @@ 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',
'/sala-home': 'sala-home',
'/lab-home': 'lab-home',
'/lab-bancada': 'lab-bancada',
'/lab-agenda': 'lab-agenda',
'/lab-clientes': 'lab-clientes',
'/lab-financeiro': 'lab-financeiro',
'/lab-cotacoes': 'lab-cotacoes',
'/lab-equipe': 'lab-equipe',
'/lab-indicadores': 'lab-indicadores',
'/marketplace-profissionais': 'marketplace-profissionais',
@@ -201,14 +218,19 @@ 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',
'sala-home': '/sala-home',
'lab-home': '/lab-home',
'lab-bancada': '/lab-bancada',
'lab-agenda': '/lab-agenda',
'lab-clientes': '/lab-clientes',
'lab-financeiro': '/lab-financeiro',
'lab-cotacoes': '/lab-cotacoes',
'lab-equipe': '/lab-equipe',
'lab-indicadores': '/lab-indicadores',
'marketplace-profissionais': '/marketplace-profissionais',
@@ -293,6 +315,14 @@ const MobileBottomNav: React.FC<{
const App: React.FC = () => {
const ver = useAppVersion();
const [isSidebarOpen, setSidebarOpen] = 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();
@@ -307,10 +337,13 @@ 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.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'marketplace-profissionais', 'diretorio-profissionais', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
// Catálogo de PLUGINS: EXCLUSIVO do superadmin (já retorna true acima); ninguém mais.
if (view === 'plugins') return false;
// Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin.
@@ -329,7 +362,7 @@ const App: React.FC = () => {
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
protetico: ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
};
@@ -475,7 +508,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)} />;
case 'ortodontia': return <OrthoView />;
case 'financeiro': return <FinanceiroView />;
case 'dentistas': return <DentistasView />;
@@ -498,14 +531,19 @@ 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" />;
case 'sala-home': return <SalaHomeView />;
case 'lab-home': return <LabHomeView />;
case 'lab-bancada': return <ProteseView />;
case 'lab-agenda': return <LabAgendaView />;
case 'lab-clientes': return <LabClientesView tab="clientes" />;
case 'lab-financeiro': return <LabClientesView tab="financeiro" />;
case 'lab-cotacoes': return <LabCotacoesView />;
case 'lab-equipe': return <LabEquipeView />;
case 'lab-indicadores': return <LabIndicadoresView />;
case 'marketplace-profissionais': return <ProfissionaisPlugin />;
@@ -531,7 +569,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 (
<>
@@ -557,7 +599,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 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)}
@@ -566,6 +608,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">
+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>
+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>
)}
</>
);
};
+34 -7
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
} 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
@@ -47,7 +68,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
pluginMenuItems.push({ id: 'alugar-salas', label: 'ALUGAR SALAS', icon: Building2, color: '#0d9488' });
}
// Marketplace de Profissionais: clínicas contratam, profissionais divulgam perfil/recebem propostas.
if (activePluginIds.includes('profissionais-marketplace') && currentRole !== 'superadmin' && currentRole !== 'paciente') {
// O protético DEPENDE desta tela para publicar o perfil no diretório e ser encontrado pelas clínicas
// — por isso aparece sempre para ele, mesmo sem o plugin ativado.
if ((activePluginIds.includes('profissionais-marketplace') || currentRole === 'protetico' || currentRole === 'biomedico' || currentRole === 'dentista') && currentRole !== 'superadmin' && currentRole !== 'paciente') {
pluginMenuItems.push({ id: 'marketplace-profissionais', label: 'PROFISSIONAIS', icon: UserSearch, color: '#0d9488' });
}
// OBS: o catálogo de PLUGINS é EXCLUSIVO do superadmin (gerência/ativação).
@@ -63,7 +86,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
{ 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 },
{ id: 'lab-agenda', label: 'AGENDA', icon: CalendarIcon },
{ id: 'lab-clientes', label: 'CLIENTES', icon: Building2 },
{ id: 'lab-cotacoes', label: 'COTAÇÕES', icon: FileText },
{ id: 'lab-financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'lab-indicadores', label: 'INDICADORES', icon: BarChart3 },
{ id: 'lab-equipe', label: 'EQUIPE', icon: UsersRound },
@@ -104,7 +129,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
// PAINEL DA SALA só existe quando o workspace ativo é uma sala.
if (item.id === 'sala-home') return (activeWorkspace as any)?.tipo === 'sala';
// PAINEL DO LAB / BANCADA só existem no workspace de laboratório.
if (['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio';
if (['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio';
// Contexto de SALA (workspace tipo='sala'): isola da clínica — menu base só conta/notificações.
// (MINHAS SALAS / ALUGAR SALAS são itens de plugin, mostrados à parte.)
@@ -117,7 +142,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
const ehTecnico = (activeWorkspace as any)?.role === 'tecnico_protese';
return ehTecnico
? ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id)
: ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-indicadores', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id);
: ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-indicadores', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id);
}
// plugins e gestão de tutores: exclusivo superadmin
@@ -149,7 +174,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
// SEM itens odontológicos (ortodontia, RX, tutoria orto).
if (currentRole === 'biomedico') return ['dashboard', 'agenda', 'tratamentos', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
// Protético: laboratório — agenda própria + GTO; sem ortodontia/RX/pacientes clínicos.
if (currentRole === 'protetico') return ['dashboard', 'agenda', 'proteses', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
// Protético: opera dentro do workspace 'laboratorio' (menu governado pelo branch tipo acima).
// Este é só o fallback fora desse contexto — sem telas clínicas (agenda/GTO/tratamentos).
if (currentRole === 'protetico') return ['notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'proteses', 'agenda', 'financeiro', 'relatorios', 'glosas', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'diretorio-profissionais'].includes(item.id);
return false;
});
+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",
+150
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 });
@@ -1095,6 +1116,29 @@ export const HybridBackend = {
const res = await apiFetch(`${API_URL}/protese/lab/pagamentos`);
return await res.json().catch(() => []);
},
getLabFechamento: async (): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/protese/lab/fechamento`);
return await res.json().catch(() => []);
},
// Fase 6: OS indicadas (sem lab vinculado) + importar/vincular ao meu laboratório.
getLabOsIndicadas: async (): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/protese/lab/os-indicadas`);
return await res.json().catch(() => []);
},
vincularOsLab: async (osId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/vincular-lab`, { method: 'POST' });
return await res.json().catch(() => ({ error: 'falha' }));
},
// Fase 6: solicitar pagamento (cobrar saldo) ao cliente.
cobrarOs: async (osId: string, body: { valor?: number; observacao?: string } = {}) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/cobrar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return await res.json().catch(() => ({ error: 'falha' }));
},
// Fase 6: dar entrada em OS pelo lado do laboratório (cliente existente ou avulso).
criarLabOs: async (body: any) => {
const res = await apiFetch(`${API_URL}/protese/lab/os`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return await res.json().catch(() => ({ error: 'falha' }));
},
// Fase 4: dados do laboratório (PF/PJ, CPF/CNPJ) + equipe de técnicos
getLabDados: async (): Promise<any> => {
const res = await apiFetch(`${API_URL}/protese/lab`);
@@ -1124,6 +1168,27 @@ export const HybridBackend = {
const res = await apiFetch(`${API_URL}/protese/lab/indicadores`);
return await res.json().catch(() => ({}));
},
// Agenda & Coleta do laboratório
agendarColeta: async (osId: string, body: { tipo: 'coleta' | 'entrega'; data_hora?: string; endereco?: string; responsavel?: string; observacao?: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/coleta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao agendar');
return await res.json();
},
getLabAgenda: async (): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/protese/lab/agenda`);
return await res.json().catch(() => []);
},
statusColeta: async (coletaId: string, status: 'realizada' | 'cancelada') => {
const res = await apiFetch(`${API_URL}/protese/coleta/${coletaId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao atualizar');
return await res.json();
},
// Fatia 4: a clínica avalia o laboratório ao final da OS
avaliarProteseOS: async (osId: string, body: { estrelas: number; comentario?: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/avaliar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao avaliar');
return await res.json();
},
// Modo 1: link seguro de acompanhamento da OS
gerarProteseLink: async (osId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link`, { method: 'POST' });
@@ -1134,17 +1199,56 @@ export const HybridBackend = {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link/status`);
return await res.json().catch(() => ({ ativo: false }));
},
// Cotação (RFQ) — Fatia 3
criarRfq: async (body: any) => {
const cid = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/rfq?clinicaId=${encodeURIComponent(cid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao pedir cotação');
return await res.json();
},
getRfqs: async (): Promise<any[]> => {
const cid = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/rfq?clinicaId=${encodeURIComponent(cid)}`);
return await res.json().catch(() => []);
},
getRfqRecebidas: async (): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/protese/rfq/recebidas`);
return await res.json().catch(() => []);
},
responderCotacao: async (cotacaoId: string, body: { valor: number; prazo_dias?: number; observacao?: string }) => {
const res = await apiFetch(`${API_URL}/protese/rfq/cotacao/${cotacaoId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao cotar');
return await res.json();
},
escolherCotacao: async (rfqId: string, cotacaoId: string) => {
const cid = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/rfq/${rfqId}/escolher?clinicaId=${encodeURIComponent(cid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cotacao_id: cotacaoId }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao escolher');
return await res.json();
},
revogarProteseLink: async (osId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link/revogar`, { method: 'POST' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao revogar');
return await res.json();
},
// Cadastro de protético INTERNO (sem conta) pela clínica
cadastrarProteticoInterno: async (body: { nome: string; telefone?: string; especialidade?: string }) => {
const cid = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/proteticos-internos?clinicaId=${encodeURIComponent(cid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao cadastrar');
return await res.json();
},
// Leitura PÚBLICA via token (sem login — não usa apiFetch/Authorization)
getProtesePublic: async (token: string) => {
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}`);
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Link inválido ou expirado.');
return await res.json();
},
getCarteiraPublic: async (token: string) => {
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/carteira`);
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao carregar.');
return await res.json();
},
// Ações PÚBLICAS pelo link (Modo 1, fatia 1b) — sem login
custodiaPublic: async (token: string, para_local: 'clinica' | 'laboratorio') => {
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/custodia`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ para_local }) });
@@ -1377,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.
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>
+94 -52
View File
@@ -3,9 +3,10 @@ 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 { 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 +15,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,7 +343,7 @@ 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 }> = ({ onNavigate, sidebarCollapsed, onToggleSidebar }) => {
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);
@@ -353,6 +353,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
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 +458,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 +599,78 @@ 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">
{/* Coluna de controles (desktop: lateral esquerda vertical; mobile: topo compacto) */}
<aside className="shrink-0 mb-3 lg:mb-0 lg:w-56 lg:overflow-y-auto lg:pr-3 lg:border-r lg:border-gray-100">
{/* Título + recolher menu (desktop, ao lado) + AGENDAR (mobile, à direita) */}
<div className="flex items-center gap-2 mb-3">
{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>
{(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} title="Agendar"
className="lg:hidden shrink-0 bg-teal-600 text-white px-3 py-2 rounded-lg text-xs font-bold uppercase flex items-center gap-1.5 hover:bg-teal-700 shadow-sm">
<Plus size={16} /> AGENDAR
</button>
)}
</div>
<div className="flex gap-2 sm:gap-3 items-center flex-wrap lg:flex-col lg:items-stretch">
{/* AGENDAR (desktop, full-width no topo da coluna) */}
{(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} title="Agendar"
className="hidden lg: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>
)}
{/* ícones de ação — mesmo tamanho, distribuídos horizontalmente */}
<div className="flex gap-2 w-full">
{(currentRole !== 'paciente') && (
<button onClick={() => { setShowAtividade(true); loadAtividade(); }} title="Atividade da equipe hoje"
className="flex-1 flex items-center justify-center py-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="flex-1 flex items-center justify-center py-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)} title="Configurações da agenda"
className="flex-1 flex items-center justify-center py-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 flex-1 flex items-center justify-center py-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 flex-1 flex items-center justify-center py-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>
)}
</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 +692,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]}
@@ -720,10 +746,20 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
onChanged={() => { refresh(); refreshGoogle(); loadPendencias(); }}
onEditar={() => { setShowDetails(false); setIsModalOpen(true); }}
onAtender={handleAtender}
onVerWhats={(info) => setWhatsTarget(info)}
podeAtender={!!onNavigate && currentRole !== 'paciente'}
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 +833,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; }
+89
View File
@@ -0,0 +1,89 @@
import React, { useState, useEffect, useCallback } from 'react';
import { CalendarClock, RefreshCw, Building2, Truck, PackageCheck, CheckCircle2, MapPin, User } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
const dataHoraBR = (d: string) => { if (!d) return 'Sem data'; const x = new Date(d); return isNaN(x.getTime()) ? d : x.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); };
const diaBR = (d: string) => { if (!d) return 'Sem data'; const x = new Date(d); return isNaN(x.getTime()) ? d : x.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short' }); };
// Agenda do laboratório: coletas (lab busca) e entregas (lab leva) agendadas.
export const LabAgendaView: React.FC = () => {
const toast = useToast();
const [lista, setLista] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState('');
const carregar = useCallback(async () => {
setLoading(true);
try { const r = await HybridBackend.getLabAgenda(); setLista(Array.isArray(r) ? r : []); }
catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
const acao = async (id: string, status: 'realizada' | 'cancelada') => {
setBusy(id);
try { await HybridBackend.statusColeta(id, status); toast.success(status === 'realizada' ? 'MARCADA COMO REALIZADA.' : 'CANCELADA.'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(''); }
};
// agrupa por dia (data_hora)
const grupos: Record<string, any[]> = {};
for (const c of lista.filter(x => x.status === 'agendada')) {
const k = (c.data_hora || '').slice(0, 10) || 'sem-data';
(grupos[k] = grupos[k] || []).push(c);
}
const realizadas = lista.filter(x => x.status === 'realizada').slice(0, 10);
const Card: React.FC<{ c: any }> = ({ c }) => {
const coleta = c.tipo === 'coleta';
return (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-start gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-white shrink-0 ${coleta ? 'bg-violet-600' : 'bg-blue-600'}`}>{coleta ? <Truck size={18} /> : <PackageCheck size={18} />}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${coleta ? 'bg-violet-100 text-violet-700' : 'bg-blue-100 text-blue-700'}`}>{coleta ? 'Coleta' : 'Entrega'}</span>
<span className="text-[11px] font-black text-gray-500">{dataHoraBR(c.data_hora)}</span>
</div>
<p className="font-bold text-gray-800 text-sm truncate mt-1">{c.os_tipo} {c.paciente_nome ? <span className="text-gray-400 font-medium">· {c.paciente_nome}</span> : ''}</p>
<p className="text-[11px] text-gray-400 uppercase font-bold flex items-center gap-1"><Building2 size={11} /> {c.clinica_nome || 'Clínica'}</p>
{c.endereco && <p className="text-[11px] text-gray-400 flex items-center gap-1 mt-0.5"><MapPin size={11} /> {c.endereco}</p>}
{c.responsavel && <p className="text-[11px] text-gray-400 flex items-center gap-1"><User size={11} /> {c.responsavel}</p>}
{c.status === 'agendada' && (
<div className="flex gap-2 mt-2">
<button disabled={busy === c.id} onClick={() => acao(c.id, 'realizada')} className="px-3 py-1.5 rounded-lg bg-[#2d6a4f] text-white text-[10px] font-black uppercase disabled:opacity-50 flex items-center gap-1"><CheckCircle2 size={12} /> Realizada</button>
<button disabled={busy === c.id} onClick={() => acao(c.id, 'cancelada')} className="px-3 py-1.5 rounded-lg border border-gray-200 text-gray-500 text-[10px] font-black uppercase disabled:opacity-50">Cancelar</button>
</div>
)}
</div>
</div>
);
};
return (
<div className="space-y-6 pb-10 max-w-2xl">
<PageHeader title="AGENDA DE COLETAS">
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>
: lista.filter(x => x.status === 'agendada').length === 0 && realizadas.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma coleta ou entrega agendada.</div>
: (
<div className="space-y-5">
{Object.keys(grupos).sort().map(dia => (
<div key={dia}>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1.5"><CalendarClock size={12} /> {dia === 'sem-data' ? 'Sem data' : diaBR(grupos[dia][0].data_hora)}</p>
<div className="space-y-2">{grupos[dia].map(c => <Card key={c.id} c={c} />)}</div>
</div>
))}
{realizadas.length > 0 && (
<div>
<p className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-2">Realizadas (recentes)</p>
<div className="space-y-2 opacity-60">{realizadas.map(c => <Card key={c.id} c={c} />)}</div>
</div>
)}
</div>
)}
</div>
);
};
+92 -57
View File
@@ -1,27 +1,35 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Building2, Wallet, Clock, RefreshCw, Inbox, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { Building2, Wallet, Clock, RefreshCw, Inbox, CheckCircle2, Phone, CalendarDays, Stethoscope, Briefcase, User } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { PageHeader } from '../components/PageHeader.tsx';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
const mesBR = (m: string) => { const [y, mo] = (m || '').split('-'); return mo ? new Date(Number(y), Number(mo) - 1, 1).toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' }) : m; };
// Área do Laboratório (Fase 3): Clientes (clínicas que enviam OS) + Financeiro (a-receber).
// Uma view, duas abas — o item de menu define a aba inicial.
const ORIGEM_META: Record<string, { icon: any; cls: string }> = {
'Clínica': { icon: Building2, cls: 'bg-blue-100 text-blue-700' },
'Consultório': { icon: Briefcase, cls: 'bg-teal-100 text-teal-700' },
'Dentista': { icon: Stethoscope, cls: 'bg-violet-100 text-violet-700' },
'Biomédico': { icon: User, cls: 'bg-pink-100 text-pink-700' },
'Profissional': { icon: User, cls: 'bg-gray-100 text-gray-600' },
};
// CRM Financeiro do laboratório: Clientes (por origem, com contato/ticket/recebíveis) + Financeiro
// (fechamento mensal + recebimentos).
export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({ tab = 'clientes' }) => {
const [aba, setAba] = useState<'clientes' | 'financeiro'>(tab);
const [clientes, setClientes] = useState<any[]>([]);
const [pagamentos, setPagamentos] = useState<any[]>([]);
const [fechamento, setFechamento] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => { setAba(tab); }, [tab]);
const carregar = useCallback(async () => {
setLoading(true);
try {
const [c, p] = await Promise.all([HybridBackend.getLabClientes(), HybridBackend.getLabPagamentos()]);
setClientes(Array.isArray(c) ? c : []);
setPagamentos(Array.isArray(p) ? p : []);
const [c, p, f] = await Promise.all([HybridBackend.getLabClientes(), HybridBackend.getLabPagamentos(), HybridBackend.getLabFechamento()]);
setClientes(Array.isArray(c) ? c : []); setPagamentos(Array.isArray(p) ? p : []); setFechamento(Array.isArray(f) ? f : []);
} catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
@@ -29,6 +37,9 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
const totFaturado = clientes.reduce((s, c) => s + Number(c.faturado || 0), 0);
const totRecebido = clientes.reduce((s, c) => s + Number(c.recebido || 0), 0);
const totReceber = clientes.reduce((s, c) => s + Number(c.a_receber || 0), 0);
// resumo por origem
const porOrigem: Record<string, number> = {};
for (const c of clientes) porOrigem[c.origem] = (porOrigem[c.origem] || 0) + 1;
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any }> = ({ titulo, valor, cor, Icon }) => (
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
@@ -41,41 +52,50 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
return (
<div className="space-y-6 pb-10">
<PageHeader title={aba === 'clientes' ? 'CLIENTES DO LABORATÓRIO' : 'FINANCEIRO DO LABORATÓRIO'}>
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
<RefreshCw size={16} /> <span className="hidden sm:inline">ATUALIZAR</span>
</button>
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
<div className="flex border-b border-gray-100">
{(['clientes', 'financeiro'] as const).map(t => (
<button key={t} onClick={() => setAba(t)} className={`px-4 py-2.5 text-xs font-black uppercase border-b-2 -mb-px transition-colors ${aba === t ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t === 'clientes' ? 'Clientes' : 'Financeiro'}</button>
<button key={t} onClick={() => setAba(t)} className={`px-4 py-2.5 text-xs font-black uppercase border-b-2 -mb-px transition-colors ${aba === t ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t === 'clientes' ? 'Clientes (CRM)' : 'Financeiro'}</button>
))}
</div>
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
<>
{aba === 'clientes' && (
clientes.length === 0 ? (
<div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma clínica enviou OS ainda.</div>
) : (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Clínica', 'OS', 'Ativas', 'Atrasadas', 'Entregues', 'Faturado', 'A receber'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{clientes.map(c => (
<tr key={c.clinica_id} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-black text-gray-700 uppercase flex items-center gap-2"><Building2 size={14} className="text-teal-600 shrink-0" /> {c.clinica_nome || '—'}</td>
<td className="px-4 py-3 text-gray-600 font-bold">{c.total}</td>
<td className="px-4 py-3 text-teal-600 font-bold">{c.ativas}</td>
<td className={`px-4 py-3 font-bold ${c.atrasadas > 0 ? 'text-red-500' : 'text-gray-400'}`}>{c.atrasadas}</td>
<td className="px-4 py-3 text-green-600 font-bold">{c.entregues}</td>
<td className="px-4 py-3 font-black text-gray-800">{fmtBRL(c.faturado)}</td>
<td className={`px-4 py-3 font-black ${c.a_receber > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(c.a_receber)}</td>
</tr>
))}
</tbody>
</table>
</div>
clientes.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma clínica enviou OS ainda.</div> : (
<>
{/* resumo por origem */}
<div className="flex flex-wrap gap-2">
{Object.entries(porOrigem).map(([o, n]) => {
const m = ORIGEM_META[o] || ORIGEM_META['Profissional']; const I = m.icon;
return <span key={o} className={`text-[10px] font-black px-3 py-1.5 rounded-full uppercase flex items-center gap-1.5 ${m.cls}`}><I size={12} /> {o}: {n}</span>;
})}
</div>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Cliente', 'Origem', 'Contato', 'OS', 'Ativas', 'Ticket méd.', 'Últ. pedido', 'A receber'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{clientes.map(c => {
const m = ORIGEM_META[c.origem] || ORIGEM_META['Profissional'];
return (
<tr key={c.clinica_id} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-black text-gray-700 uppercase">{c.clinica_nome || '—'}</td>
<td className="px-4 py-3"><span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${m.cls}`}>{c.origem}</span></td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{c.contato ? <span className="flex items-center gap-1"><Phone size={11} /> {c.contato}</span> : '—'}</td>
<td className="px-4 py-3 text-gray-600 font-bold">{c.total}</td>
<td className={`px-4 py-3 font-bold ${c.atrasadas > 0 ? 'text-red-500' : 'text-teal-600'}`}>{c.ativas}{c.atrasadas > 0 ? ` (${c.atrasadas}!)` : ''}</td>
<td className="px-4 py-3 text-gray-600">{fmtBRL(c.ticket_medio)}</td>
<td className="px-4 py-3 text-gray-400 whitespace-nowrap">{c.ultimo_pedido ? dataBR(c.ultimo_pedido) : '—'}</td>
<td className={`px-4 py-3 font-black ${c.a_receber > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(c.a_receber)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)
)}
@@ -86,34 +106,49 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
<KPI titulo="Recebido" valor={fmtBRL(totRecebido)} cor="text-green-600" Icon={Wallet} />
<KPI titulo="A receber" valor={fmtBRL(totReceber)} cor="text-amber-500" Icon={Clock} />
</div>
{/* Fechamento mensal */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2">
<Inbox size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Recebimentos</h3>
<span className="text-[10px] font-bold text-gray-400 uppercase ml-auto">{pagamentos.length} lançamento(s)</span>
</div>
{pagamentos.length === 0 ? (
<div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhum recebimento registrado.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Clínica', 'Paciente', 'Trabalho', 'Forma', 'Data', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{pagamentos.map(p => (
<tr key={p.id} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{p.clinica_nome || '—'}</td>
<td className="px-4 py-3 text-gray-500 uppercase">{p.paciente_nome || '—'}</td>
<td className="px-4 py-3 text-gray-500">{p.tipo || '—'}</td>
<td className="px-4 py-3 text-gray-500">{p.forma || '—'}</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{dataBR(p.data)}</td>
<td className="px-4 py-3 font-black text-green-700">{fmtBRL(p.valor)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2"><CalendarDays size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Fechamento mensal</h3></div>
{fechamento.length === 0 ? <div className="py-10 text-center text-gray-400 text-sm font-bold uppercase">Sem movimento ainda.</div> : (
<div className="overflow-x-auto"><table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Mês', 'Entregas', 'Faturado', 'Recebido', 'Saldo'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{fechamento.map(m => (
<tr key={m.mes} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-black text-gray-700 uppercase">{mesBR(m.mes)}</td>
<td className="px-4 py-3 text-gray-600">{m.entregues}</td>
<td className="px-4 py-3 font-bold text-gray-800">{fmtBRL(m.faturado)}</td>
<td className="px-4 py-3 font-bold text-green-600">{fmtBRL(m.recebido)}</td>
<td className={`px-4 py-3 font-black ${m.faturado - m.recebido > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(Math.max(0, m.faturado - m.recebido))}</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
<p className="text-[10px] text-gray-300 font-bold uppercase flex items-center gap-1"><AlertTriangle size={11} /> Faturado = custo das OS entregues (exceto garantia). A receber = faturado recebido.</p>
{/* Extrato */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2"><Inbox size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Recebimentos</h3><span className="text-[10px] font-bold text-gray-400 uppercase ml-auto">{pagamentos.length}</span></div>
{pagamentos.length === 0 ? <div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhum recebimento.</div> : (
<div className="overflow-x-auto"><table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Cliente', 'Trabalho', 'Forma', 'Data', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{pagamentos.map(p => (
<tr key={p.id} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{p.clinica_nome || '—'}</td>
<td className="px-4 py-3 text-gray-500">{p.tipo || '—'}</td>
<td className="px-4 py-3 text-gray-500">{p.forma || '—'}</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{dataBR(p.data)}</td>
<td className="px-4 py-3 font-black text-green-700">{fmtBRL(p.valor)}</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
<p className="text-[10px] text-gray-300 font-bold uppercase">Faturado = custo das OS entregues ( garantia). A receber = faturado recebido.</p>
</>
)}
</>
+85
View File
@@ -0,0 +1,85 @@
import React, { useState, useEffect, useCallback } from 'react';
import { FileText, RefreshCw, Building2, CheckCircle2, XCircle, Send, Clock } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
// Área do Laboratório (Fatia 3): pedidos de cotação recebidos das clínicas.
export const LabCotacoesView: React.FC = () => {
const toast = useToast();
const [lista, setLista] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [forms, setForms] = useState<Record<string, { valor: string; prazo: string; obs: string }>>({});
const [busy, setBusy] = useState('');
const carregar = useCallback(async () => {
setLoading(true);
try { const r = await HybridBackend.getRfqRecebidas(); setLista(Array.isArray(r) ? r : []); }
catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
const setF = (id: string, k: string, v: string) => setForms(s => ({ ...s, [id]: { ...(s[id] || { valor: '', prazo: '', obs: '' }), [k]: v } }));
const responder = async (c: any) => {
const f = forms[c.cotacao_id] || { valor: '', prazo: '', obs: '' };
const valor = parseFloat(f.valor);
if (!(valor > 0)) { toast.error('INFORME O VALOR.'); return; }
setBusy(c.cotacao_id);
try { await HybridBackend.responderCotacao(c.cotacao_id, { valor, prazo_dias: parseInt(f.prazo, 10) || undefined, observacao: f.obs.trim() || undefined }); toast.success('COTAÇÃO ENVIADA.'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(''); }
};
return (
<div className="space-y-6 pb-10 max-w-2xl">
<PageHeader title="PEDIDOS DE COTAÇÃO">
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>
: lista.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhum pedido de cotação.</div>
: (
<div className="space-y-3">
{lista.map(c => {
const aberta = c.rfq_status === 'aberta';
const ganhou = c.cotacao_status === 'escolhida';
const perdeu = c.cotacao_status === 'recusada';
const f = forms[c.cotacao_id] || { valor: '', prazo: '', obs: '' };
return (
<div key={c.cotacao_id} className={`bg-white rounded-2xl border shadow-sm p-4 ${ganhou ? 'border-green-200' : 'border-gray-100'}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-black text-gray-800 text-sm">{c.descricao}</p>
<p className="text-[11px] text-gray-400 uppercase font-bold flex items-center gap-1 mt-0.5"><Building2 size={11} /> {c.clinica_nome || 'Clínica'}{c.dentes ? ` · dentes ${c.dentes}` : ''}</p>
</div>
{ganhou && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-green-100 text-green-700 uppercase flex items-center gap-1 shrink-0"><CheckCircle2 size={11} /> Você ganhou</span>}
{perdeu && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-gray-100 text-gray-500 uppercase flex items-center gap-1 shrink-0"><XCircle size={11} /> Não escolhida</span>}
{aberta && c.cotacao_status === 'enviada' && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-amber-100 text-amber-700 uppercase shrink-0">Cotado · {fmtBRL(c.valor)}</span>}
</div>
{(c.paciente_nome || c.prazo_desejado) && <p className="text-[11px] text-gray-400 mt-2">{c.paciente_nome || ''}{c.prazo_desejado ? ` · prazo desejado ${dataBR(c.prazo_desejado)}` : ''}</p>}
{aberta && (
<div className="mt-3 pt-3 border-t border-gray-100 flex flex-wrap items-end gap-2">
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Valor (R$)</label>
<input type="number" step="0.01" value={f.valor} onChange={e => setF(c.cotacao_id, 'valor', e.target.value)} placeholder={c.valor ? String(c.valor) : '0,00'} className="w-24 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Prazo (dias)</label>
<input type="number" value={f.prazo} onChange={e => setF(c.cotacao_id, 'prazo', e.target.value)} className="w-20 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
</div>
<input value={f.obs} onChange={e => setF(c.cotacao_id, 'obs', e.target.value)} placeholder="Observação" className="flex-1 min-w-[120px] p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<button disabled={busy === c.cotacao_id} onClick={() => responder(c)} className="px-3 py-2 rounded-lg bg-[#2d6a4f] text-white text-[11px] font-black uppercase disabled:opacity-50 flex items-center gap-1.5"><Send size={13} /> {c.cotacao_status === 'enviada' ? 'Atualizar' : 'Cotar'}</button>
</div>
)}
{!aberta && !ganhou && !perdeu && <p className="text-[10px] text-gray-400 uppercase font-bold mt-2 flex items-center gap-1"><Clock size={11} /> Encerrado</p>}
</div>
);
})}
</div>
)}
</div>
);
};
+163 -8
View File
@@ -1,36 +1,53 @@
import React, { useState, useEffect, useCallback } from 'react';
import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox } from 'lucide-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 = [
{ v: 'clinica', label: 'Clínica', icon: Building2 },
{ v: 'consultorio', label: 'Consultório', icon: Briefcase },
{ v: 'dentista', label: 'Dentista', icon: Stethoscope },
];
// Painel do LABORATÓRIO (protético): aparece quando o workspace ativo é tipo='laboratorio'.
// Resumo a partir do que já existe — a Bancada (OS) + o financeiro do workspace do lab.
export const LabHomeView: React.FC = () => {
const ws: any = HybridBackend.getActiveWorkspace();
const toast = useToast();
const [os, setOs] = useState<any[]>([]);
const [fin, setFin] = useState<any[]>([]);
const [indicadas, setIndicadas] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [showEntrada, setShowEntrada] = useState(false);
const [importando, setImportando] = useState<string | null>(null);
const carregar = useCallback(async () => {
setLoading(true);
try {
const [b, f] = await Promise.all([HybridBackend.getProteseBancada(), HybridBackend.getFinanceiro()]);
const [b, f, ind] = await Promise.all([HybridBackend.getProteseBancada(), HybridBackend.getLabFechamento(), HybridBackend.getLabOsIndicadas()]);
setOs(Array.isArray(b) ? b : []);
setFin(Array.isArray(f) ? f : []);
setIndicadas(Array.isArray(ind) ? ind : []);
} catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
const importar = async (osId: string) => {
setImportando(osId);
try { const r = await HybridBackend.vincularOsLab(osId); if (r?.error) throw new Error(r.error); toast.success('OS IMPORTADA PARA O LABORATÓRIO.'); carregar(); }
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);
const entreguesMes = os.filter(o => o.status === 'entregue' && (o.updated_at || '').slice(0, 7) === ymMes).length;
// Faturamento do mês: RECEITA de prótese lançada no workspace do lab (espelho da entrega).
const fatMes = fin
.filter(l => l.origem === 'protese' && (l.data_competencia || l.datavencimento || '').slice(0, 7) === ymMes)
.reduce((s, l) => s + Number(l.valor || 0), 0);
// Faturamento do mês: do fechamento do próprio lab (custo das OS entregues no mês).
const fatMes = Number(fin.find(m => m.mes === ymMes)?.faturado || 0);
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any; sub?: string }> = ({ titulo, valor, cor, Icon, sub }) => (
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
@@ -49,6 +66,9 @@ export const LabHomeView: React.FC = () => {
return (
<div className="space-y-6 pb-10">
<PageHeader title="PAINEL DO LABORATÓRIO">
<button onClick={() => setShowEntrada(true)} className="text-white px-3 py-2 rounded-lg flex items-center gap-2 text-xs font-black uppercase shadow-sm" style={{ backgroundColor: '#2d6a4f' }}>
<Plus size={16} /> <span className="hidden sm:inline">Dar entrada</span>
</button>
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
<RefreshCw size={16} /> <span className="hidden sm:inline">ATUALIZAR</span>
</button>
@@ -71,6 +91,31 @@ export const LabHomeView: React.FC = () => {
<KPI titulo="Faturamento do mês" valor={fmtBRL(fatMes)} cor="text-violet-600" Icon={Wallet} sub="Receita de prótese" />
</div>
{/* OS indicadas ao laboratório aguardando importação */}
{indicadas.length > 0 && (
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-amber-100 bg-amber-50/60 flex items-center gap-2">
<ArrowDownToLine size={16} className="text-amber-600" />
<h3 className="text-sm font-black text-amber-800 uppercase">OS indicadas a você ({indicadas.length})</h3>
<span className="text-[10px] font-bold text-amber-500 uppercase ml-auto hidden sm:inline">Importe para entrar na produção e financeiro</span>
</div>
<div className="divide-y divide-gray-50">
{indicadas.map(o => (
<div key={o.id} className="px-6 py-3 flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="font-black text-gray-800 uppercase truncate text-sm">{o.tipo}</p>
<p className="text-[11px] text-gray-400 font-bold uppercase truncate">{o.cliente_nome || 'Cliente'}{o.prazo_entrega ? ` · prazo ${(o.prazo_entrega).slice(0, 10).split('-').reverse().join('/')}` : ''}</p>
</div>
<span className="text-sm font-black text-gray-700 hidden sm:block">{fmtBRL(o.custo)}</span>
<button disabled={importando === o.id} onClick={() => importar(o.id)} className="px-3 py-2 rounded-lg bg-amber-500 text-white text-[11px] font-black uppercase disabled:opacity-50 flex items-center gap-1.5 shrink-0">
<ArrowDownToLine size={13} /> Importar
</button>
</div>
))}
</div>
</div>
)}
{/* Fila por status */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2">
@@ -105,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>
@@ -119,6 +164,116 @@ export const LabHomeView: React.FC = () => {
</div>
</>
)}
{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<{ osAtivas: any[]; onClose: () => void; onSaved: () => void }> = ({ osAtivas, onClose, onSaved }) => {
const toast = useToast();
const [clientes, setClientes] = useState<any[]>([]);
const [produtos, setProdutos] = useState<any[]>([]);
const [busy, setBusy] = useState(false);
// origem do cliente: existente ou avulso
const [clienteId, setClienteId] = useState(''); // '' = avulso
const [cliNome, setCliNome] = useState('');
const [cliTipo, setCliTipo] = useState('clinica');
const [produtoId, setProdutoId] = useState(''); // '' = tipo livre
const [tipo, setTipo] = useState('');
const [custo, setCusto] = useState('');
const [prazo, setPrazo] = 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(() => {});
}, []);
const salvar = async () => {
if (!clienteId && !cliNome.trim()) { toast.error('INFORME O CLIENTE.'); return; }
if (!produtoId && !tipo.trim()) { toast.error('INFORME O PRODUTO OU TIPO.'); return; }
setBusy(true);
try {
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);
if (r?.error) throw new Error(r.error);
toast.success('ENTRADA REGISTRADA.');
onSaved();
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const lbl = 'text-[10px] font-black text-gray-500 uppercase tracking-wide';
const inp = 'w-full mt-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400';
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-2xl w-full max-w-md max-h-[92vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white z-10">
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Plus size={16} className="text-teal-600" /> Dar entrada em OS</h3>
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-5 space-y-4">
{/* Cliente */}
<div>
<label className={lbl}>Cliente</label>
<select value={clienteId} onChange={e => setClienteId(e.target.value)} className={inp}>
<option value=""> Novo cliente avulso</option>
{clientes.map(c => <option key={c.clinica_id} value={c.clinica_id}>{c.clinica_nome} ({c.origem})</option>)}
</select>
{!clienteId && (
<div className="mt-2 space-y-2 bg-gray-50 rounded-xl p-3">
<input value={cliNome} onChange={e => setCliNome(e.target.value)} placeholder="Nome do cliente (clínica / dentista)" className={inp.replace('mt-1', '')} />
<div className="flex gap-2">
{CLI_TIPO.map(t => { const I = t.icon; return (
<button key={t.v} onClick={() => setCliTipo(t.v)} className={`flex-1 py-2 rounded-lg text-[10px] font-black uppercase flex items-center justify-center gap-1 border ${cliTipo === t.v ? 'border-teal-500 bg-teal-50 text-teal-700' : 'border-gray-200 text-gray-400'}`}><I size={12} /> {t.label}</button>
); })}
</div>
</div>
)}
</div>
{/* Produto */}
<div>
<label className={lbl}>Produto</label>
<select value={produtoId} onChange={e => setProdutoId(e.target.value)} className={inp}>
<option value=""> Tipo livre</option>
{produtos.map(p => <option key={p.id} value={p.id}>{p.nome} {fmtBRL(Number(p.preco || 0))}</option>)}
</select>
{!produtoId && (
<div className="mt-2 flex gap-2">
<input value={tipo} onChange={e => setTipo(e.target.value)} placeholder="Ex.: Coroa de Zircônia" className={inp.replace('mt-1', '') + ' flex-1'} />
<input value={custo} onChange={e => setCusto(e.target.value)} type="number" step="0.01" placeholder="Custo" className={inp.replace('mt-1', '') + ' w-24'} />
</div>
)}
</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>
</div>
</div>
</div>
);
};
+7 -3
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { BarChart3, RefreshCw, Clock, AlertTriangle, RotateCcw, PackageX, Award, CheckCircle2 } from 'lucide-react';
import { BarChart3, RefreshCw, Clock, AlertTriangle, RotateCcw, PackageX, Award, CheckCircle2, BadgeCheck, Star } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { PageHeader } from '../components/PageHeader.tsx';
@@ -41,9 +41,12 @@ export const LabIndicadoresView: React.FC = () => {
<span className="text-2xl font-black leading-none mt-0.5">{temNota ? ind.nota.toFixed(1) : '—'}</span>
</div>
<div className="min-w-0">
<h3 className="text-sm font-black text-gray-800 uppercase">Nota ScoreOdonto</h3>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-sm font-black text-gray-800 uppercase">Nota ScoreOdonto</h3>
{ind.verificado && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700 uppercase flex items-center gap-0.5"><BadgeCheck size={11} /> Verificado</span>}
</div>
{temNota ? (
<p className="text-[11px] text-gray-400 font-bold uppercase mt-1">Calculada sobre {ind.entregues} entrega(s). Penaliza atraso, retrabalho e ocorrências resolvidas.</p>
<p className="text-[11px] text-gray-400 font-bold uppercase mt-1">Sobre {ind.entregues} entrega(s){ind.avaliacoes ? ` · ${ind.satisfacao}★ de ${ind.avaliacoes} avaliações` : ''}. Combina desempenho (atraso/retrabalho/ocorrências) e satisfação da clínica.</p>
) : (
<p className="text-[11px] text-amber-500 font-bold uppercase mt-1">Score em formação mínimo de {ind.min_os} entregas para gerar a nota.</p>
)}
@@ -57,6 +60,7 @@ export const LabIndicadoresView: React.FC = () => {
<KPI titulo="Retrabalho" valor={`${ind.retrabalho_pct}%`} cor="text-orange-500" Icon={RotateCcw} sub={`${ind.garantia} garantia(s)`} />
<KPI titulo="Ocorrências" valor={`${ind.ocorrencia_pct}%`} cor="text-red-500" Icon={AlertTriangle} sub={`${ind.ocorrencias_resolvidas} resolvida(s)`} />
<KPI titulo="Componentes perdidos" valor={String(ind.componentes_ausentes)} cor="text-red-600" Icon={PackageX} sub="Conferidos como ausentes" />
<KPI titulo="Satisfação" valor={ind.satisfacao != null ? `${ind.satisfacao}` : '—'} cor="text-amber-500" Icon={Star} sub={`${ind.avaliacoes || 0} avaliação(ões)`} />
</div>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
+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>
+63 -4
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { FlaskConical, Building2, Clock, ShieldCheck, AlertTriangle, CheckCircle2, Circle, Package, History, Camera, MapPin, ChevronRight, Loader2, ImagePlus, Zap } from 'lucide-react';
import { FlaskConical, Building2, Clock, ShieldCheck, AlertTriangle, CheckCircle2, Circle, Package, History, Camera, MapPin, ChevronRight, Loader2, ImagePlus, Zap, Briefcase, Wallet, ChevronLeft } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
@@ -21,6 +21,57 @@ const Secao: React.FC<{ titulo: string; Icon: any; children: React.ReactNode }>
const PROD_FLUXO = ['recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
const mesBR = (m: string) => { const [y, mo] = (m || '').split('-'); return mo ? new Date(Number(y), Number(mo) - 1, 1).toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }) : m; };
// Carteira do protético (via link): trabalhos + mini financeiro mensal (relação com a clínica).
const CarteiraView: React.FC<{ carteira: any }> = ({ carteira }) => {
if (!carteira) return <div className="flex justify-center py-16"><Loader2 className="animate-spin text-teal-600" size={28} /></div>;
const Linha: React.FC<{ t: any; fin?: boolean }> = ({ t, fin }) => (
<div className="flex items-center gap-2 bg-white border border-gray-100 rounded-xl px-3 py-2 text-xs">
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${STATUS_COR[t.status] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[t.status] || t.status}</span>
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-700 truncate">{t.tipo}</p>
<p className="text-[10px] text-gray-400 truncate">{t.paciente_nome || '—'}{!fin && t.prazo_entrega ? ` · prazo ${dataBR(t.prazo_entrega)}` : ''}</p>
</div>
{Number(t.custo) > 0 && <span className="font-black text-gray-700 shrink-0">{fmtBRL(t.custo)}</span>}
</div>
);
return (
<main className="max-w-lg mx-auto p-3 space-y-3">
{carteira.clinica_nome && <div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 text-[11px] font-black text-blue-700 uppercase flex items-center gap-1.5"><Building2 size={12} /> {carteira.clinica_nome}</div>}
{/* Mini financeiro */}
<div className="bg-[#2d6a4f] text-white rounded-2xl p-4">
<p className="text-[10px] font-black uppercase tracking-widest opacity-80 flex items-center gap-1.5"><Wallet size={12} /> Recebido desta clínica</p>
<p className="text-2xl font-black mt-1">{fmtBRL(carteira.financeiro?.total_pago || 0)}</p>
<div className="mt-3 space-y-1">
{(carteira.financeiro?.por_mes || []).map((m: any) => (
<div key={m.mes} className="flex items-center justify-between text-xs bg-white/10 rounded-lg px-3 py-1.5">
<span className="font-bold capitalize">{mesBR(m.mes)}</span>
<span className="font-black">{fmtBRL(m.total)}</span>
</div>
))}
{(carteira.financeiro?.por_mes || []).length === 0 && <p className="text-[11px] opacity-70 uppercase font-bold">Nenhum pagamento registrado ainda.</p>}
</div>
</div>
<Secao titulo={`Em andamento (${carteira.andamento?.length || 0})`} Icon={Zap}>
<div className="space-y-1.5">
{(carteira.andamento || []).map((t: any) => <Linha key={t.id} t={t} />)}
{(carteira.andamento || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nada em produção.</p>}
</div>
</Secao>
<Secao titulo={`Finalizados (${carteira.finalizados?.length || 0})`} Icon={CheckCircle2}>
<div className="space-y-1.5">
{(carteira.finalizados || []).map((t: any) => <Linha key={t.id} t={t} fin />)}
{(carteira.finalizados || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum entregue ainda.</p>}
</div>
</Secao>
<p className="text-center text-[10px] text-gray-300 uppercase font-bold pb-4">Valores recebidos do laboratório · sem dados do paciente</p>
</main>
);
};
export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => {
const toast = useToast();
const [os, setOs] = useState<any>(null);
@@ -28,8 +79,12 @@ export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => {
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [tela, setTela] = useState<'os' | 'carteira'>('os');
const [carteira, setCarteira] = useState<any>(null);
const recarregar = useCallback(() => HybridBackend.getProtesePublic(token).then(setOs).catch(e => setErro(e.message || 'Link inválido.')), [token]);
useEffect(() => { recarregar().finally(() => setLoading(false)); }, [recarregar]);
const abrirCarteira = () => { setTela('carteira'); if (!carteira) HybridBackend.getCarteiraPublic(token).then(setCarteira).catch(() => setCarteira({ andamento: [], finalizados: [], financeiro: { por_mes: [], total_pago: 0 } })); };
const act = async (fn: () => Promise<any>, ok: string) => {
setBusy(true);
@@ -66,10 +121,14 @@ export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => {
<p className="font-black text-sm leading-tight">ScoreOdonto</p>
<p className="text-[10px] text-white/70 uppercase tracking-wide leading-tight">Acompanhamento de prótese</p>
</div>
<span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-white/15 uppercase">{os.finalizada ? 'Somente leitura' : 'Acompanhamento'}</span>
{tela === 'os'
? <button onClick={abrirCarteira} className="text-[9px] font-black px-2 py-1 rounded-full bg-white/15 uppercase flex items-center gap-1"><Briefcase size={11} /> Meus trabalhos</button>
: <button onClick={() => setTela('os')} className="text-[9px] font-black px-2 py-1 rounded-full bg-white/15 uppercase flex items-center gap-1"><ChevronLeft size={11} /> Voltar à OS</button>}
</header>
<main className="max-w-lg mx-auto p-3 space-y-3">
{tela === 'carteira' && <CarteiraView carteira={carteira} />}
{tela === 'os' && <main className="max-w-lg mx-auto p-3 space-y-3">
{/* Cabeçalho da OS */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<div className="flex items-start justify-between gap-2">
@@ -218,7 +277,7 @@ export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => {
<span className="inline-flex items-center gap-1 mt-2 bg-white text-[#2d6a4f] font-black text-xs uppercase px-4 py-2 rounded-xl">Criar conta grátis <ChevronRight size={14} /></span>
</a>
<p className="text-center text-[10px] text-gray-300 uppercase font-bold pb-4">ScoreOdonto · Provedor de Prótese</p>
</main>
</main>}
</div>
);
};
+241 -21
View File
@@ -1,9 +1,12 @@
import React, { useState, useEffect, useCallback } 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 } from 'lucide-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('/');
@@ -27,10 +30,42 @@ const Badge: React.FC<{ s: string }> = ({ s }) => (
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${STATUS_COR[s] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[s] || s}</span>
);
// ── Cadastro de protético INTERNO (sem conta), inline na Nova OS ────────────
const ProteticoInternoInline: React.FC<{ onCriado: (id: string) => void }> = ({ onCriado }) => {
const toast = useToast();
const [open, setOpen] = useState(false);
const [nome, setNome] = useState('');
const [tel, setTel] = useState('');
const [busy, setBusy] = useState(false);
const criar = async () => {
if (!nome.trim()) { toast.error('INFORME O NOME.'); return; }
setBusy(true);
try { const r = await HybridBackend.cadastrarProteticoInterno({ nome: nome.trim(), telefone: tel.trim() || undefined }); toast.success('PROTÉTICO CADASTRADO.'); setOpen(false); setNome(''); setTel(''); onCriado(r.id); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
return (
<div className="mt-1.5">
{!open ? (
<button onClick={() => setOpen(true)} className="text-[10px] font-black text-violet-600 uppercase flex items-center gap-1"><Plus size={11} /> Cadastrar protético interno (sem conta)</button>
) : (
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Nome do protético" className="w-full px-3 py-2 rounded-lg border border-gray-200 text-sm" />
<input value={tel} onChange={e => setTel(e.target.value)} placeholder="Telefone (opcional)" className="w-full px-3 py-2 rounded-lg border border-gray-200 text-sm" />
<div className="flex gap-2">
<button disabled={busy} onClick={criar} className="flex-1 py-2 rounded-lg bg-[#2d6a4f] text-white text-[11px] font-black uppercase disabled:opacity-50">Cadastrar</button>
<button onClick={() => setOpen(false)} className="px-3 py-2 rounded-lg border border-gray-200 text-gray-500 text-[11px] font-black uppercase">Cancelar</button>
</div>
<p className="text-[9px] text-gray-400 uppercase"> da sua clínica · ele acompanha pelo link, sem criar conta.</p>
</div>
)}
</div>
);
};
// ── 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[]>([]);
@@ -41,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]);
@@ -119,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>
{labs.length === 0 && <p className="text-[10px] text-gray-400 mt-1">Nenhum protético com vínculo ou no diretório. Convide um na Equipe ou no marketplace de Profissionais.</p>}
<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" />
@@ -144,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">
@@ -372,6 +403,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
{naGarantia && modo === 'clinica' && (
<button disabled={busy} onClick={acionarGarantia} className="w-full py-2.5 rounded-xl bg-emerald-600 text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><ShieldCheck size={16} /> Acionar garantia (refazer sem custo)</button>
)}
{os.status === 'entregue' && modo === 'clinica' && <AvaliacaoSecao os={os} onChanged={carregar} />}
</div>
)}
@@ -386,6 +418,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
{tab === 'monitor' && (
<div className="space-y-5">
<MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />
<AgendaColetaSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
{modo === 'clinica' && <CompartilharLinkSecao os={os} />}
</div>
)}
@@ -599,6 +632,73 @@ const CompartilharLinkSecao: React.FC<{ os: any }> = ({ os }) => {
);
};
// ── Seção: Agendar coleta / entrega (integra com a custódia) ────────────────
const AgendaColetaSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
const toast = useToast();
const [tipo, setTipo] = useState<'coleta' | 'entrega'>('coleta');
const [data, setData] = useState('');
const [resp, setResp] = useState('');
const [busy, setBusy] = useState(false);
const add = async () => {
setBusy(true);
try { await HybridBackend.agendarColeta(os.id, { tipo, data_hora: data || undefined, responsavel: resp.trim() || undefined }); toast.success('AGENDADO.'); setData(''); setResp(''); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const marcar = async (id: string, status: 'realizada' | 'cancelada') => { try { await HybridBackend.statusColeta(id, status); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
return (
<div className="border-t border-gray-100 pt-4 space-y-2">
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><Clock size={12} /> Coleta / entrega</p>
{(os.coletas || []).map((c: any) => (
<div key={c.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2">
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${c.tipo === 'coleta' ? 'bg-violet-100 text-violet-700' : 'bg-blue-100 text-blue-700'}`}>{c.tipo}</span>
<span className="text-gray-600 flex-1 truncate">{c.data_hora ? dataHoraBR(c.data_hora) : 'sem data'}{c.responsavel ? ` · ${c.responsavel}` : ''}</span>
{c.status === 'agendada'
? (podeEditar && <button onClick={() => marcar(c.id, 'realizada')} className="text-[9px] font-black text-green-600 uppercase">Realizar</button>)
: <span className={`text-[8px] font-black uppercase ${c.status === 'realizada' ? 'text-green-600' : 'text-gray-400'}`}>{c.status}</span>}
</div>
))}
{(os.coletas || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhuma coleta agendada.</p>}
{podeEditar && (
<div className="flex gap-2 flex-wrap">
<select value={tipo} onChange={e => setTipo(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] font-bold outline-none focus:border-teal-400">
<option value="coleta">Coleta</option>
<option value="entrega">Entrega</option>
</select>
<input type="datetime-local" value={data} onChange={e => setData(e.target.value)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] outline-none focus:border-teal-400" />
<input value={resp} onChange={e => setResp(e.target.value)} placeholder="Responsável" className="flex-1 min-w-[100px] p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button>
</div>
)}
</div>
);
};
// ── Seção: Avaliar o laboratório (após a entrega, lado clínica) ─────────────
const AvaliacaoSecao: React.FC<{ os: any; onChanged: () => void }> = ({ os, onChanged }) => {
const toast = useToast();
const [estrelas, setEstrelas] = useState<number>(os.avaliacao?.estrelas || 0);
const [coment, setComent] = useState<string>(os.avaliacao?.comentario || '');
const [busy, setBusy] = useState(false);
const salvar = async () => {
if (!estrelas) { toast.error('DÊ UMA NOTA DE 1 A 5.'); return; }
setBusy(true);
try { await HybridBackend.avaliarProteseOS(os.id, { estrelas, comentario: coment.trim() || undefined }); toast.success('AVALIAÇÃO ENVIADA.'); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
return (
<div className="pt-3 border-t border-gray-100">
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Star size={12} /> Avaliar o laboratório</p>
<div className="flex gap-1 mb-2">
{[1, 2, 3, 4, 5].map(n => (
<button key={n} onClick={() => setEstrelas(n)} className={n <= estrelas ? 'text-amber-400' : 'text-gray-200'}><Star size={26} fill={n <= estrelas ? 'currentColor' : 'none'} /></button>
))}
</div>
<textarea value={coment} onChange={e => setComent(e.target.value)} rows={2} placeholder="Comentário (opcional)" className="w-full p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-amber-400 resize-none" />
<button disabled={busy} onClick={salvar} className="mt-2 px-3 py-2 rounded-lg bg-amber-500 text-white text-[11px] font-black uppercase disabled:opacity-50">{os.avaliacao ? 'Atualizar avaliação' : 'Enviar avaliação'}</button>
</div>
);
};
// ── Seção: Monitoramento de custódia (onde o trabalho está) ─────────────────
const MonitoramentoSecao: React.FC<{ os: any; podeEditar: boolean; busy: boolean; onMover: (p: 'clinica' | 'laboratorio') => void }> = ({ os, podeEditar, busy, onMover }) => {
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
@@ -774,6 +874,13 @@ const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEdit
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const del = async (id: string) => { try { await HybridBackend.deleteProtesePagamento(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
// Solicitar pagamento (cobrança) do saldo devedor — apenas o laboratório.
const saldoLab = Math.max(0, Number(os.custo || 0) - totalLab);
const cobrar = async () => {
setBusy(true);
try { const r = await HybridBackend.cobrarOs(os.id); if (r?.error) throw new Error(r.error); toast.success('SOLICITAÇÃO ENVIADA AO CLIENTE.'); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
return (
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Wallet size={12} /> Valores recebidos</p>
@@ -808,6 +915,12 @@ const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEdit
<button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button>
</div>
)}
{/* Solicitar pagamento (cobrança) — só o laboratório, quando há saldo devedor. */}
{modo === 'bancada' && podeEditar && saldoLab > 0 && (
<button disabled={busy} onClick={cobrar} className="mt-2 w-full py-2 rounded-lg border border-amber-300 bg-amber-50 text-amber-700 text-[11px] font-black uppercase disabled:opacity-50 flex items-center justify-center gap-1.5">
<Wallet size={13} /> Solicitar pagamento ({fmtBRL(saldoLab)})
</button>
)}
</div>
);
};
@@ -885,17 +998,122 @@ const ProdutosModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
};
// ── View principal ──────────────────────────────────────────────────────────
// ── Modal: Cotações (lado clínica) — pedir a N labs, comparar e escolher ────
const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => void }> = ({ onClose, onEscolhida }) => {
const toast = useToast();
const confirm = useConfirm();
const [aba, setAba] = useState<'novo' | 'lista'>('lista');
const [rfqs, setRfqs] = useState<any[]>([]);
const [labs, setLabs] = useState<any[]>([]);
const [descricao, setDescricao] = useState('');
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([])), []);
useEffect(() => { carregar(); HybridBackend.getProteseLaboratorios().then(r => setLabs(Array.isArray(r) ? r : [])).catch(() => setLabs([])); }, [carregar]);
const toggleLab = (id: string) => setSel(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id]);
const criar = async () => {
if (!descricao.trim()) { toast.error('DESCREVA O TRABALHO.'); return; }
if (!sel.length) { toast.error('SELECIONE AO MENOS UM LABORATÓRIO.'); return; }
setBusy(true);
try { await HybridBackend.criarRfq({ descricao: descricao.trim(), dentes: dentes.trim() || undefined, prazo_desejado: prazo || undefined, proteticos: sel }); toast.success('COTAÇÃO ENVIADA AOS LABORATÓRIOS.'); setDescricao(''); setDentes(''); setPrazo(''); setSel([]); setAba('lista'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const escolher = async (rfq: any, cot: any) => {
if (!(await confirm(`ESCOLHER ${cot.protetico_nome} POR ${fmtBRL(cot.valor)}? UMA OS SERÁ CRIADA.`))) return;
setBusy(true);
try { await HybridBackend.escolherCotacao(rfq.id, cot.id); toast.success('OS CRIADA A PARTIR DA COTAÇÃO.'); carregar(); onEscolhida(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-2xl w-full max-w-lg h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FileText size={16} className="text-violet-600" /> Cotações de prótese</h3>
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
</div>
<div className="flex border-b border-gray-100 px-2">
{(['lista', 'novo'] as const).map(t => (
<button key={t} onClick={() => setAba(t)} className={`px-3 py-2.5 text-xs font-black uppercase border-b-2 -mb-px ${aba === t ? 'border-violet-600 text-violet-700' : 'border-transparent text-gray-400'}`}>{t === 'lista' ? 'Meus pedidos' : 'Pedir cotação'}</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-5">
{aba === 'novo' ? (
<div className="space-y-3">
<textarea value={descricao} onChange={e => setDescricao(e.target.value)} rows={3} placeholder="Descreva o trabalho (ex.: Coroa sobre implante, dente 36, cor A2)" className="w-full p-3 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400 resize-none" />
<div className="grid grid-cols-2 gap-2">
<input value={dentes} onChange={e => setDentes(e.target.value)} placeholder="Dentes (ex.: 36)" className="p-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
<input type="date" value={prazo} onChange={e => setPrazo(e.target.value)} className="p-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
</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.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>
{l.interno && <span className="text-[8px] font-black text-teal-600 uppercase">interno</span>}
{l.nota != null && <span className="text-[9px] font-black text-amber-600"> {l.nota}</span>}
</button>
))}
</div>
</div>
<button disabled={busy} onClick={criar} className="w-full py-3 rounded-xl bg-[#2d6a4f] text-white font-black text-sm uppercase disabled:opacity-50">Enviar pedido a {sel.length} laboratório(s)</button>
</div>
) : (
rfqs.length === 0 ? <p className="text-center text-gray-300 font-bold uppercase text-sm py-12">Nenhum pedido de cotação ainda.</p> : (
<div className="space-y-3">
{rfqs.map(r => (
<div key={r.id} className="border border-gray-100 rounded-2xl p-3">
<div className="flex items-center justify-between gap-2">
<p className="font-black text-gray-800 text-sm">{r.descricao}</p>
<span className={`text-[8px] font-black px-2 py-0.5 rounded-full uppercase ${r.status === 'fechada' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>{r.status === 'fechada' ? 'fechada' : 'aberta'}</span>
</div>
<div className="mt-2 space-y-1.5">
{(r.cotacoes || []).map((c: any) => (
<div key={c.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2">
<span className="font-bold text-gray-700 flex-1 truncate">{c.protetico_nome}</span>
{c.status === 'convidada' ? <span className="text-[10px] text-gray-400 uppercase">aguardando</span>
: <><span className="font-black text-gray-800">{fmtBRL(c.valor)}</span>{c.prazo_dias ? <span className="text-[10px] text-gray-400">{c.prazo_dias}d</span> : null}</>}
{c.status === 'escolhida' && <span className="text-[8px] font-black text-green-700 uppercase">escolhida</span>}
{r.status === 'aberta' && c.status === 'enviada' && <button disabled={busy} onClick={() => escolher(r, c)} className="px-2 py-1 rounded-lg bg-[#2d6a4f] text-white text-[9px] font-black uppercase disabled:opacity-50">Escolher</button>}
</div>
))}
</div>
</div>
))}
</div>
)
)}
</div>
</div>
</div>
);
};
export const ProteseView: React.FC = () => {
const role = HybridBackend.getCurrentRole();
const modo: 'bancada' | 'clinica' = role === 'protetico' ? 'bancada' : 'clinica';
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);
const [detalhe, setDetalhe] = useState<string | null>(null);
const [produtosModal, setProdutosModal] = useState(false);
const [cotacoesOpen, setCotacoesOpen] = useState(false);
const carregar = useCallback(() => {
setLoading(true);
@@ -923,9 +1141,10 @@ export const ProteseView: React.FC = () => {
description={modo === 'bancada' ? 'Fila de trabalhos do laboratório' : 'Ordens de serviço enviadas ao laboratório'}
>
{modo === 'clinica' ? (
<button onClick={() => setNovaOS(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase">
<Plus size={16} /> Nova OS
</button>
<>
<button onClick={() => setCotacoesOpen(true)} className="flex items-center gap-2 border border-violet-200 text-violet-700 px-4 py-2 rounded-xl font-black text-sm uppercase hover:bg-violet-50"><FileText size={16} /> Cotações</button>
<button onClick={() => setNovaOS(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase"><Plus size={16} /> Nova OS</button>
</>
) : (
<>
<button onClick={() => setProdutosModal(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase"><Tags size={16} /> Meus produtos</button>
@@ -962,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. */}
@@ -986,6 +1205,7 @@ export const ProteseView: React.FC = () => {
{novaOS && <NovaOSModal onClose={() => setNovaOS(false)} onSaved={() => { setNovaOS(false); carregar(); }} />}
{detalhe && <OSDetailModal id={detalhe} modo={modo} onClose={() => setDetalhe(null)} onChanged={carregar} />}
{produtosModal && <ProdutosModal onClose={() => setProdutosModal(false)} />}
{cotacoesOpen && <CotacoesClinicaModal onClose={() => setCotacoesOpen(false)} onEscolhida={() => carregar()} />}
</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,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,107 @@
import React from 'react';
import {
MessageCircle,
Megaphone,
Users,
Settings,
BrainCircuit,
} from 'lucide-react';
import { getAvatarProxyUrl } from '../utils/inboxUtils';
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">
{/* 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,161 @@
// 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
attendee_name: string | null
attendee_phone: string | null
status: 'available' | 'booked' | 'cancelled'
notes: string | null
created_at: string
}
// ─── 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
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 }) =>
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; }
+153 -3
View File
@@ -2,8 +2,150 @@ import React, { useState, useEffect, useCallback } from 'react';
import {
UserSearch, Stethoscope, Wrench, Sparkles, MapPin, Search, Mail, Phone, RefreshCw,
Save, BadgeCheck, Inbox, Send, CheckCircle2, XCircle, CircleDollarSign, UserCircle2, X,
ChevronUp, Eraser, FileText, Plus, Pencil, Trash2
ChevronUp, Eraser, FileText, Plus, Pencil, Trash2, Tags, ChevronDown
} from 'lucide-react';
const fmtBRLp = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
// Fatia 2: solicitar prótese direto do marketplace → cria a OS (lab pré-selecionado).
const SolicitarProteseModal: React.FC<{ prof: any; onClose: () => void }> = ({ prof, onClose }) => {
const ws = HybridBackend.getActiveWorkspace();
const [produtos, setProdutos] = useState<any[]>([]);
const [dentistas, setDentistas] = useState<any[]>([]);
const [busca, setBusca] = useState('');
const [pacientes, setPacientes] = useState<any[]>([]);
const [paciente, setPaciente] = useState<any>(null);
const [form, setForm] = useState<any>({ produto_id: '', tipo: '', dentista_id: '', dentes: '', prazo_entrega: '', observacoes: '' });
const [saving, setSaving] = useState(false);
const [okMsg, setOkMsg] = useState(false);
useEffect(() => {
HybridBackend.getLaboratorioProdutos(prof.id).then(r => setProdutos(Array.isArray(r) ? r : [])).catch(() => setProdutos([]));
HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([]));
}, [prof.id, ws?.id]);
useEffect(() => {
if (paciente) return;
const t = setTimeout(() => { HybridBackend.getPacientes(busca).then(r => setPacientes(r.data || [])).catch(() => setPacientes([])); }, 250);
return () => clearTimeout(t);
}, [busca, paciente]);
const set = (k: string, v: any) => setForm((f: any) => ({ ...f, [k]: v }));
const salvar = async () => {
if (!form.produto_id && !form.tipo.trim()) { alert('Escolha um produto do catálogo ou descreva o trabalho.'); return; }
const dent = dentistas.find(d => d.id === form.dentista_id);
setSaving(true);
try {
await HybridBackend.createProteseOS({
...form, protetico_id: prof.id, valor: 0,
paciente_id: paciente?.id || null, paciente_nome: paciente?.nome || null, dentista_nome: dent?.nome || null,
});
setOkMsg(true);
} catch (e: any) { alert((e.message || 'Erro').toUpperCase()); } finally { setSaving(false); }
};
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white">
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Wrench size={16} className="text-violet-600" /> Solicitar prótese</h3>
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
</div>
{okMsg ? (
<div className="p-8 text-center space-y-2">
<CheckCircle2 size={40} className="text-green-500 mx-auto" />
<p className="font-black text-gray-800 uppercase text-sm">OS enviada ao laboratório</p>
<p className="text-xs text-gray-500">{prof.nome} recebeu a solicitação. Acompanhe em Prótese / Bancada.</p>
<button onClick={onClose} className="mt-2 px-4 py-2 rounded-xl bg-gray-800 text-white text-xs font-black uppercase">Fechar</button>
</div>
) : (
<div className="p-5 space-y-4">
<div className="bg-violet-50 rounded-xl px-3 py-2 text-xs font-black text-violet-700 uppercase">Laboratório: {prof.nome}</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Produto do catálogo</label>
{produtos.length > 0 ? (
<select value={form.produto_id} onChange={e => { const pr = produtos.find(x => x.id === e.target.value); set('produto_id', e.target.value); set('tipo', pr?.nome || ''); }} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
<option value="">Escolha o trabalho</option>
{produtos.map(pr => <option key={pr.id} value={pr.id}>{pr.nome} {fmtBRLp(pr.preco)}{pr.garantia_meses ? ` · ${pr.garantia_meses}m` : ''}</option>)}
</select>
) : (
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder="Descreva o trabalho (sem catálogo)" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
)}
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Paciente (opcional)</label>
{paciente ? (
<div className="flex items-center justify-between bg-gray-50 rounded-xl px-3 py-2 mt-1"><span className="text-sm font-bold text-gray-700">{paciente.nome}</span><button onClick={() => { setPaciente(null); setBusca(''); }}><X size={16} className="text-gray-400" /></button></div>
) : (
<div className="relative mt-1">
<Search size={15} className="absolute left-3 top-2.5 text-gray-300" />
<input value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar paciente..." className="w-full pl-9 pr-3 py-2 rounded-xl border border-gray-200 text-sm" />
{busca && pacientes.length > 0 && (
<div className="absolute z-10 left-0 right-0 mt-1 bg-white border border-gray-100 rounded-xl shadow-lg max-h-40 overflow-y-auto">
{pacientes.slice(0, 8).map(p => <button key={p.id} onClick={() => setPaciente(p)} className="w-full text-left px-3 py-2 hover:bg-gray-50 text-sm font-medium text-gray-700">{p.nome}</button>)}
</div>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<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>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Prazo</label>
<input type="date" value={form.prazo_entrega} onChange={e => set('prazo_entrega', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
</div>
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Dentes / observações</label>
<input value={form.dentes} onChange={e => set('dentes', e.target.value)} placeholder="Ex.: 11, 21" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
<textarea value={form.observacoes} onChange={e => set('observacoes', e.target.value)} rows={2} placeholder="Cor, material, instruções…" className="w-full mt-2 px-3 py-2 rounded-xl border border-gray-200 text-sm resize-none" />
</div>
<button disabled={saving} onClick={salvar} className="w-full py-3 rounded-xl bg-[#2d6a4f] text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><Send size={15} /> Enviar solicitação</button>
</div>
)}
</div>
</div>
);
};
// Fatia 1 do marketplace transacional: vitrine do catálogo do laboratório (reusa getLaboratorioProdutos).
const VitrineProtetico: React.FC<{ proteticoId: string }> = ({ proteticoId }) => {
const [aberto, setAberto] = useState(false);
const [produtos, setProdutos] = useState<any[] | null>(null);
const [loading, setLoading] = useState(false);
const toggle = async () => {
if (!aberto && produtos === null) {
setLoading(true);
try { const p = await HybridBackend.getLaboratorioProdutos(proteticoId); setProdutos(Array.isArray(p) ? p : []); }
catch { setProdutos([]); } finally { setLoading(false); }
}
setAberto(a => !a);
};
return (
<div className="mb-2">
<button onClick={toggle} className="w-full py-2 rounded-xl border border-gray-200 text-gray-600 text-[10px] font-black uppercase flex items-center justify-center gap-1.5 hover:bg-gray-50">
<Tags size={12} /> {aberto ? 'Ocultar catálogo' : 'Ver catálogo de próteses'} {aberto ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{aberto && (
<div className="mt-2 space-y-1">
{loading ? <p className="text-[10px] text-gray-300 uppercase font-bold text-center py-2">Carregando</p>
: produtos && produtos.length ? produtos.map(pr => (
<div key={pr.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-1.5">
<span className="font-bold text-gray-700 flex-1 truncate">{pr.nome}</span>
{pr.garantia_meses ? <span className="text-[9px] text-emerald-600 font-black uppercase">{pr.garantia_meses}m gar.</span> : null}
<span className="font-black text-gray-800">{fmtBRLp(pr.preco)}</span>
</div>
)) : <p className="text-[10px] text-gray-300 uppercase font-bold text-center py-2">Sem catálogo publicado.</p>}
</div>
)}
</div>
);
};
import { PageHeader } from '../../components/PageHeader.tsx';
import { useToast } from '../../contexts/ToastContext.tsx';
import { useConfirm } from '../../contexts/ConfirmContext.tsx';
@@ -44,6 +186,9 @@ interface Profissional {
raio_atuacao_km?: number | null;
bio_profissional?: string | null;
dist_km?: number | string | null;
nota?: number | null; // Nota ScoreOdonto (só protéticos com volume mínimo)
verificado?: boolean; // selo (≥ 5 entregas)
avaliacoes?: number; // nº de avaliações da clínica
}
interface Proposta {
@@ -406,6 +551,7 @@ export const ProfissionaisPlugin: React.FC = () => {
// propostas
const [propostas, setPropostas] = useState<{ enviadas: Proposta[]; recebidas: Proposta[] }>({ enviadas: [], recebidas: [] });
const [proposingTo, setProposingTo] = useState<Profissional | null>(null);
const [solicitarTo, setSolicitarTo] = useState<Profissional | null>(null);
// null = checando; true/false = endereço da origem (clínica ou perfil) preenchido?
const [enderecoOk, setEnderecoOk] = useState<boolean | null>(null);
@@ -699,6 +845,8 @@ export const ProfissionaisPlugin: React.FC = () => {
<div className="flex items-center gap-2 flex-wrap">
<span className="font-black text-gray-900 text-sm uppercase truncate">{p.nome}</span>
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${meta.cls}`}>{meta.label}</span>
{p.nota != null && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase" title="Nota ScoreOdonto"> {p.nota}{p.avaliacoes ? ` · ${p.avaliacoes}` : ''}</span>}
{p.verificado && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700 uppercase flex items-center gap-0.5" title="Laboratório verificado"><BadgeCheck size={10} /> Verificado</span>}
</div>
{p.especialidade && <p className="text-xs text-gray-500 font-medium mt-0.5 uppercase">{p.especialidade}</p>}
</div>
@@ -721,10 +869,11 @@ export const ProfissionaisPlugin: React.FC = () => {
</div>
)}
</div>
{p.role === 'protetico' && <VitrineProtetico proteticoId={p.id} />}
{ws?.id && (
<button onClick={() => setProposingTo(p)}
<button onClick={() => p.role === 'protetico' ? setSolicitarTo(p) : setProposingTo(p)}
className="w-full py-2.5 rounded-xl text-white text-[10px] font-black uppercase flex items-center justify-center gap-1.5 transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
<Send size={13} /> ENVIAR PROPOSTA
<Send size={13} /> {p.role === 'protetico' ? 'SOLICITAR PRÓTESE' : 'ENVIAR PROPOSTA'}
</button>
)}
</div>
@@ -893,6 +1042,7 @@ export const ProfissionaisPlugin: React.FC = () => {
{tab === 'modelos' && <ModelosManager />}
{proposingTo && <PropostaModal prof={proposingTo} onClose={() => setProposingTo(null)} onSent={fetchPropostas} />}
{solicitarTo && <SolicitarProteseModal prof={solicitarTo} onClose={() => setSolicitarTo(null)} />}
</div>
);
};
+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;