Compare commits

...

91 Commits

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:34:07 +02:00
VPS 4 Builder ec76dfeec2 feat(newwhats): checklist trata telefone compartilhado e profissional não cadastrado
Fecha furos de identificação relatados:
- Telefone compartilhado na família: nova pergunta telefone_compartilhado +
  cautelas IDENTIDADE/TELEFONE COMPARTILHADO — a IA nunca assume quem fala pelo
  número (ex.: cadastrado no filho, mas quem fala é a mãe; ou pergunta do marido)
  e confirma nome + para quem é antes de agendar; se não bater, encaminha ao humano.
- Profissional não cadastrado: cautela para citações de dentista fora da lista
  (ex.: prótese com Dr. de fora) — encaminha ao humano e pede no resumo que o
  dono cadastre o profissional.
- Pendência de config quando telefone_compartilhado=Sim (completar cadastro dos familiares).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:03:24 +02:00
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
VPS 4 Builder 8040e70310 feat(protese): Modo 1 fatia 1c — gestão do link na UI da clínica (status/acessos/revogar)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 56s
Fecha o Modo 1 (link seguro do protético sem conta):
- GET /api/protese/os/:id/link/status: link ativo, nº de acessos, último acesso e expiração.
- UI (aba Monitoramento, modo clínica): mostra status do link com Copiar / WhatsApp / Revogar
  e os contadores de acesso/expiração; gera sob demanda.

Conversão por herança é nativa (a OS já aponta para o protetico_id do destinatário). Validado em
DEV: acessos contados, expiração 60 dias, revogação → status inativo + leitura pública 404.

Modo 1 concluído (1a leitura + 1b ação + 1c gestão).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:12:03 +02:00
VPS 4 Builder 8552e87665 docs(verdade-hoje): produção em v1.0.24 (28dc27a)
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 07:07:42 +02:00
VPS 4 Builder 28dc27a289 feat(protese): Modo 1 fatia 1b — ações do protético pelo link (sem conta)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 49s
Pelo link /p/TOKEN o protético (sem conta) passa a ATUAR na OS, tudo auditado como "<nome> (link)":
- POST /api/p/:token/custodia — confirmar recebimento / devolução (clínica↔lab)
- POST /api/p/:token/status — avançar a produção (recebido…finalizado); entregue/cancelado
  seguem exclusivos da clínica (400 pelo link)
- POST /api/p/:token/foto — anexar foto do trabalho
Página pública ganha a seção "Atualizar status" (custódia/etapa/foto) enquanto a OS não está
finalizada; header alterna Acompanhamento/Somente leitura.

Validado em DEV: recebimento, avanço de etapa, foto; entregue pelo link → 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:03:52 +02:00
VPS 4 Builder f0b7a45d86 docs(verdade-hoje): produção em v1.0.23 (672c3b0)
build-and-promote / build (push) Successful in 59s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:57:18 +02:00
VPS 4 Builder 672c3b01e5 feat(protese): Modo 1 fatia 1a — leitura da OS por link seguro /p/TOKEN (sem conta)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 58s
Provedor de Prótese SEM conta (doc/MODO1-LINK-SEGURO-PROTESE):

- Token por OS: tabela protese_os_link (aleatório 24B base64url, expira 60 dias, revogável,
  conta acessos). POST /protese/os/:id/link (gera/recupera) + POST .../link/revogar.
- Leitura pública GET /api/p/:token (sem auth): payload curado com a mesma blindagem soLab —
  SEM paciente_id (CPF) e SEM valor cobrado ao paciente; mostra trabalho, etapas, componentes,
  fotos, custódia, ocorrências, histórico e custo do lab.
- Página pública ProtesePublicView (mobile-first 320px+), interceptada no index.tsx antes do
  app autenticado; CTA "Criar conta grátis". Botão Gerar link / WhatsApp / Copiar na aba
  Monitoramento (modo clínica).
- Validado em DEV: leitura, blindagem (CPF/valor ocultos), token inválido→404, revogação→404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:53:02 +02:00
VPS 4 Builder 5f4460e37d feat(cadastro): card "Protético" na vitrine de onboarding + desenho do Modo 1
- OnboardingChat: adiciona o card de atalho "Protéticos" (perfil profissional → protetico),
  ao lado de Dentistas/Biomédicos. O cadastro de protético já funcionava via "Sou profissional",
  faltava o atalho na vitrine.
- doc/MODO1-LINK-SEGURO-PROTESE: desenho do "Provedor sem conta" — link seguro /p/TOKEN
  (token por OS, revogável; rotas públicas com a mesma blindagem soLab; página pública read+ação;
  CTA de conversão com herança de histórico). Evolução futura, sem refatoração.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:37:41 +02:00
VPS 4 Builder e05231663f docs(verdade-hoje): produção em v1.0.22 (84f4a79)
build-and-promote / build (push) Successful in 55s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:25:20 +02:00
VPS 4 Builder 84f4a797d2 feat(protese): Fase 5 — indicadores + Nota ScoreOdonto + ranking no marketplace
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s
Provedor de Prótese, Fase 5 (doc/PROVEDOR-PROTESE-DECISAO.md) — sobre a captura das fases 2-4:

- Indicadores do lab (GET /protese/lab/indicadores, só dono): entregues, tempo médio, atraso %,
  retrabalho % (garantia), ocorrências resolvidas %, componentes ausentes. Tela lab-indicadores.
- Nota ScoreOdonto (0-10) = 10 − (atraso%×3 + retrabalho%×4 + ocorrência%×3), só com ≥3 entregas
  ("score em formação" abaixo disso). Apenas ocorrências RESOLVIDAS pesam (contraditório respeitado).
- Ranking: getProteseLaboratorios devolve a nota por protético e ordena internos→nota→nome; a
  clínica vê ★ nota no dropdown de Nova OS e Lançar GTO.

Roadmap do Provedor de Prótese concluído (Fases 1-5). Marketplace transacional e Modo 1 (link
seguro sem conta) ficam como evolução futura.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:49:32 +02:00
VPS 4 Builder ae48feaa8a feat(protese): Fase 4 — PF/PJ + CNPJ + Equipe (técnicos do laboratório)
Provedor de Prótese, Fase 4 (doc/PROVEDOR-PROTESE-DECISAO.md):

- PF/PJ: clinicas.pessoa_tipo (individual | laboratório) + documento (CPF/CNPJ) editáveis.
  Tela "Laboratório & Equipe" (lab-equipe). GET/PATCH /protese/lab.
- Equipe: novo vinculos.role='tecnico_protese'. Dono convida por e-mail (ensureUsuarioPorEmail
  → credencial temporária). GET/POST/DELETE /protese/lab/equipe (só dono).
- Acesso estendido: proteseOsAccesso reconhece o técnico (vínculo no laboratorio_id) e a bancada
  passa a usar labDoUsuario (dono OU técnico). Técnico opera a produção; menu reduzido e sem
  clientes/financeiro/equipe (endpoints retornam vazio/403). Gestão é exclusiva do dono.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:40:10 +02:00
VPS 4 Builder c8d679eb70 feat(protese): Fase 3 — Clientes e Financeiro do laboratório (operação multi-clínica)
Provedor de Prótese, Fase 3 (doc/PROVEDOR-PROTESE-DECISAO.md):

- Clientes (lab-clientes): clínicas que enviam OS, com OS totais/ativas/atrasadas/entregues,
  faturado e a-receber por cliente. GET /protese/lab/clientes (agrega por clinica_id no escopo
  do laboratório).
- Financeiro do lab (lab-financeiro): KPIs faturado/recebido/a-receber + extrato de recebimentos
  (pagamentos lado lab) com clínica/paciente/forma/data. GET /protese/lab/pagamentos.
  Regra: faturado = custo das OS entregues (≠ garantia); a-receber = faturado − recebido.
- Menu do laboratório ganha CLIENTES e FINANCEIRO (Sidebar + App rotas/contexto + nova
  LabClientesView com abas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:09:25 +02:00
VPS 4 Builder 3ffaff9d7d feat(protese): Fase 2 — rastreabilidade de componentes + ocorrências com contraditório
Provedor de Prótese, Fase 2 (doc/PROVEDOR-PROTESE-DECISAO.md):

- Componentes: eixo 'direcao' (enviado clínica→lab | devolvido lab→clínica) + conferência
  (pendente/ok/divergente/ausente, com autor/data). Endpoint .../componentes/:id/conferir.
  UI: badges de direção e conferência + botões inline na aba Itens & Valores.
- Ocorrências (protese_os_ocorrencia): 10 categorias (parafuso substituído, peça danificada,
  retrabalho…), descrição, responsável, autor. Evidências reusam protese_os_foto (ocorrencia_id).
- Contraditório: aberta → respondida → em_analise → resolvida | contestada; 'resolvida' é final.
  Endpoints /ocorrencias, /ocorrencias/:id/resposta, /ocorrencias/:id/status. Nova aba
  "Ocorrências" (com contador) no modal da OS.
- Indicadores propositalmente NÃO implementados (só ocorrências resolvidas pesarão — Fase 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:53:33 +02:00
VPS 4 Builder 7ae6993c4b feat(protese): Fase 1 — clínica de origem na OS + blindagem de CPF (LGPD)
Provedor de Prótese, Fase 1 do roadmap (doc/PROVEDOR-PROTESE-DECISAO.md):

- Origem da OS: bancada e detalhe passam a trazer clinica_nome (subquery em clinicas);
  o protético vê a clínica de origem na lista da bancada e no topo do modal. Destrava o
  cenário multi-clínica (Clínica A/B/C → mesmo laboratório).
- Blindagem de CPF (LGPD): confirmado que pacientes.id É o CPF. O provedor de prótese
  (lab que não é da clínica) deixa de receber paciente_id no detalhe e na bancada,
  mantendo paciente_nome; a clínica continua vendo o CPF para operar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:20:53 +02:00
VPS 4 Builder 9f103be397 feat(protese): etapas livres, ajustes do trabalho e privacidade de valores do paciente
- Etapas repetíveis/puláveis: cada etapa tem ação Ir/Voltar/Repetir (não só "avançar
  para a próxima"). 'entregue' segue na Linha do tempo.
- Ajustes do trabalho (aba Etapas): editar cor/material (PATCH .../os/:id); trocar o
  trabalho por outro + MOTIVO obrigatório (POST .../trocar); acréscimo/redução do valor
  cobrado do paciente com observação (POST .../ajuste-valor) — tudo auditado no histórico.
- Privacidade: o protético (lab que não é da clínica) NÃO vê o que o paciente paga/deve —
  GET detalhe omite pagamentos do lado clínica e zera o valor cobrado; a bancada também
  zera o valor. O ajuste de valor do paciente é exclusivo da clínica (403 para o lab). No
  front, o modo bancada esconde "Recebido do paciente".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:32:21 +02:00
VPS 4 Builder f4f433b14c feat(protese): avançar etapas pela clínica + recibo térmico/laser + abas reordenadas
- Aba "Linha do tempo" movida para o FINAL (entregue/cancelar continuam nela).
- Etapas agora avançáveis também pela clínica (não só pelo lab): nova lista "Etapas do
  trabalho" na 1ª aba, com botão "Avançar" por etapa. Backend: avanço da produção passa
  a aceitar lab OU clínica de origem (auditado por actor). 'entregue' segue na timeline
  (exige foto).
- Impressão por etapa em DOIS formatos: térmica (bobina 80mm) e laser (A4), com os dados
  da OS preenchidos no recibo (paciente, trabalho, dentista, lab, dentes, material/cor,
  componentes, prazo, local atual + assinaturas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:06:29 +02:00
VPS 4 Builder 8cf4a83161 feat(protese): modal da OS em abas + cupom de etapa + monitoramento de custódia
Reorganiza o gerenciamento da OS em ABAS (sem o scrollão único):
- Etapas: dados + stepper; substitui entregue/cancelar por "imprimir cupom" de cada
  etapa (comprovante imprimível que circula com o trabalho — janela de impressão).
- Linha do tempo: histórico com data/hora/autor; AQUI ficam marcar entregue / cancelar
  (e o fluxo do lab / acionar garantia).
- Itens & Valores: componentes, fotos e pagamentos.
- Monitoramento: custódia do trabalho (clínica ↔ laboratório) — "protético retirou" /
  "devolveu à clínica", com local atual em destaque e histórico de movimentações.

Backend: protese_os.local_atual + tabela protese_os_custodia + POST .../custodia
(valida transição, registra movimento e evento). GET detalhe expõe local_atual + custodia.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:32:31 +02:00
VPS 4 Builder 623c9990d6 feat(protese): profissionaliza a OS — etapas, fotos, componentes, pagamentos e auditoria
Reformula o modal de detalhe da OS de prótese, fechando os gaps de gestão:

- Etapas: stepper visual do fluxo (solicitado→…→entregue) com a etapa atual e datas.
- Fotos: agora anexáveis TAMBÉM no modo clínica (antes só na bancada — beco sem saída
  para entregar), com autoria (quem anexou), data e legenda; remover foto. Toda foto
  entra no histórico.
- Componentes enviados: nova tabela protese_os_componente — item, qtd, quem enviou e
  quando (ex.: implante, pilar, modelo). Add/remover.
- Valores recebidos (dois lados): nova tabela protese_os_pagamento — lado='clinica'
  (paciente→clínica) e lado='lab' (clínica→lab), com totais, forma, autor e data.
- Auditoria: histórico com data+HORA e autor; foto/componente/pagamento viram eventos
  rastreados. Helpers carregaOSComAcesso/logEventoOS reaproveitados nos sub-recursos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:41:19 +02:00
VPS 4 Builder b80d061a6f docs(verdade-hoje): produção em v1.0.21 (064f36d)
build-and-promote / build (push) Successful in 1m1s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:21:43 +02:00
VPS 4 Builder 064f36d614 docs(protese): marca Fase 2 como implementada/validada + nota do bug pacientenome
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:35:46 +02:00
VPS 4 Builder 50e1483ad5 feat(protese): Fase 2 frontend — área do Laboratório (painel + bancada)
Workspace tipo='laboratorio' ganha menu próprio (PAINEL DO LAB + BANCADA),
isolado das telas clínicas — espelha o tratamento de tipo='sala'. lab-bancada
reaproveita ProteseView (modo bancada); lab-home é o novo LabHomeView (fila por
status, atrasadas, entregues e faturamento do mês a partir do que já existe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:17:07 +02:00
VPS 4 Builder a1c203ced5 feat(protese): Fase 2 backend — Laboratório como workspace de 1ª classe
- Workspace do protético passa a ser tipo='laboratorio' (re-tag dos existentes +
  novos nascem assim no /api/register). Área de gestão própria, como a clínica.
- Roteamento por laboratorio_id: OS nascem com o lab (POST /os e GTO→OS); a
  Bancada filtra por laboratorio_id com fallback por protetico_id (OS legadas).
  Helper labDoProtetico(). Backfill aditivo + índice.
- Receita espelhada (2.1): a entrega lança RECEITA no workspace do lab
  (clinica_id=laboratorio_id, valor=os.custo), espelho da DESPESA da clínica —
  idempotente (financeiro_lab_id), isolado em try/catch.

fix: corrige coluna `paciente_nome`→`pacientenome` no INSERT da DESPESA de prótese
(bug pré-existente da Fase 1, em produção): a entrega de OS com custo>0 abortava
com "column paciente_nome does not exist". A mesma correção vale para a RECEITA nova.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:17:07 +02:00
VPS 4 Builder 9ed8bc25bd docs(verdade-hoje): produção em v1.0.20 (8676308)
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-23 21:11:56 +02:00
VPS 4 Builder 8676308325 docs(salas): registra fix de menu + feature de manutenção/operador na análise da recepção
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 59s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:25:09 +02:00
VPS 4 Builder 983582f066 feat(salas): acesso operacional do funcionário à sala + bloqueio p/ manutenção
Funcionário/membro com vínculo na clínica dona da sala passa a ter a sala como
workspace de acesso OPERACIONAL (papel_sala='operador'): vê a agenda da sala e
bloqueia horário para manutenção, sem locação nem financeiro. O Painel da Sala
detecta o modo operador e esconde recebíveis/relatório/baixa, mostrando só a
agenda + ação de bloqueio.

Bloqueio de manutenção: reserva tipo='manutencao' (coluna nova, default
'locacao'), valor 0, nasce 'confirmada' (sem aprovação) e NÃO gera recebível.
Reaproveita o anti-conflito de horário da sala (409 em sobreposição) e o
cancelamento por solicitante/unidade. A agenda renderiza o bloqueio em cinza.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:23:36 +02:00
VPS 4 Builder 718a7886e1 docs(protese): modelo Fase 2 (área do protético) + análise área recepção/TC
- FASE2-PROTETICO-AREA: Laboratório como workspace de 1ª classe (tipo='laboratorio'),
  roteamento por laboratorio_id, área de gestão própria, financeiro espelhado.
- FASE2-IMPL-2.0-2.1: detalhamento de implementação (migrações, backfill, bancada,
  receita espelhada, menu da área).
- ANALISE-AREA-RECEPCAO-TC: gap de permissões da recepção fundamentado em modelos
  validados (Treatment Coordinator / RBAC / Lab Case Manager).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:49:36 +02:00
VPS 4 Builder 9b0ba65eca fix(salas): restringe MINHAS/ALUGAR SALAS a papéis de locação
Os menus de locador (MINHAS SALAS) e locatário (ALUGAR SALAS) eram exibidos a
"todos menos superadmin", vazando para funcionário e paciente — papéis que não
são titulares de sala e não tomam decisões de locação. Passa a exibir apenas a
papéis que podem ser titulares (donosala/donoclinica/donoconsultorio/admin/
dentista/biomedico/protetico).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:49:36 +02:00
VPS 4 Builder 553efb003b fix(rbac): expõe PRÓTESES e CONTRATOS no editor de cargos
PAGINAS_RBAC não listava 'proteses' nem 'contratos', então essas páginas não
eram concedíveis por nenhum cargo (nem via "MARCAR TODAS"/GERENTE, que derivam
de TODAS_PAGINAS = flatMap de PAGINAS_RBAC). Resultado: era impossível um dono
dar acesso à área de Prótese a um funcionário pela interface.

Adiciona ambas ao grupo ATENDIMENTO e inclui proteses/lancar-gto nos templates
RECEPÇÃO e AUXILIAR (alinhado ao papel de Treatment Coordinator).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:49:36 +02:00
VPS 4 Builder 321ca7e127 feat(protese): Fase 1 — finalizar GTO abre OS de prótese na Bancada
Itens de GTO ligados a um produto do catálogo do protético geram, na
finalização da GTO, uma OS de prótese (status 'solicitado') na Bancada do
laboratório — idempotente por gto_item_id e isolado em try/catch para nunca
derrubar o finalize. Migrações aditivas (gto_items.produto_id/protetico_id,
protese_os.gto_item_id, re-tag de especialidades para area='protese').

No Lançar GTO, a especialidade de prótese troca o seletor de procedimentos
pelo fluxo laboratório → produto → valor → observação. Busca de pacientes
passa a ser server-side com debounce (antes filtrava só a 1ª página).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:49:36 +02:00
VPS 4 Builder d005fe77a9 feat(clinicas): permite criar unidades adicionais (multi-clínica)
- ClinicasView: botão '+ Nova clínica/consultório' + modal de criação,
  gateado pelo papel da conta (donoclinica/donoconsultorio/admin).
- Corrige applyWorkspace destrutivo no onboarding: usa refreshWorkspaces
  (acrescenta a unidade à lista, em vez de sobrescrever as existentes).

Antes não havia caminho de UI para criar uma 2ª clínica fora do
onboarding; o backend já suportava N clínicas. Validado em DEV (8020).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 05:46:08 +02:00
VPS 4 Builder 0ade8027f5 docs(verdade-hoje): atualiza versão corrente para v1.0.19 (715b2dc)
Alinha o documento ao git HEAD/produção atual. Mantém intactas as
menções históricas (validação em v1.0.16, colisão de tag v1.0.14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 05:28:35 +02:00
VPS 4 Builder 8f9177a1c1 docs(status): marca fila A concluída — produção em v1.0.19 (715b2dc)
build-and-promote / build (push) Successful in 52s
build-and-promote / promote (push) Has been skipped
Atualiza STATUS-SEGURANCA-E-PRODUCAO.md: cabeçalho (prod v1.0.19), nova
seção "Fila A concluída" (push → fix CI → build → tag/promote → backup →
verificação), seção A marcada como feita, commits agora publicados e
"Como retomar" com pendências restantes (B + A.2) e rollback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:36:50 +02:00
113 changed files with 23466 additions and 394 deletions
+70
View File
@@ -0,0 +1,70 @@
# Integração NewWhats (satélite) — scoreodonto.com ↔ Motor
> **Único deste projeto** (scoreodonto.com). Clonado e **adaptado ao domínio
> odonto** a partir do satélite do `mercado.clube67.com`. Backend **ESM** — sem o
> host de plugins do mercado; é um módulo registrado no `server.js`. v1.0.0.
## ⚠️ Regra de registro bidirecional (obrigatória)
- Toda mudança que toque o contrato (rotas, payloads, auth, webhook) **deve ser
registrada no motor** e na doc central
`documentos-unicos/projetos/newwhats.clube67.com/integracao-satelites.md`.
- Toda mudança no motor que afete o contrato **deve ser refletida aqui**.
- O satélite é um **braço inteligente do motor** — consome/alimenta, não duplica.
## Diferenças vs o satélite do mercado
- **ESM** (não CommonJS) e **sem PluginManager** — registrado via
`registerNewwhats(app, pool)` no `server.js`.
- **Config por ENV** (não tabela `plugin_configs`): `NEWWHATS_URL`,
`NEWWHATS_INTEGRATION_KEY`, `NEWWHATS_WEBHOOK_SECRET`, `NEWWHATS_CLINICA_ID`.
- **Negócio adaptado ao odonto**: `/nw/*` e `sync-knowledge` usam
clínica/especialidades/dentistas/procedimentos/planos (multi-tenant por
`clinica_id`), no lugar de clubes/sorteio/vagas.
- **Removidos** (não úteis aqui): `media-transcribe` (o motor já transcreve —
ver Fase 5), `smart-router`, `personas`, `follow-up-detector`, `context-builder`
(heurísticas específicas do mercado). Reavaliar se necessário.
## Arquivos
| Arquivo | Papel |
|---|---|
| `config.js` | lê a config da integração das ENV |
| `auth.js` | `nwAuth` — valida `x-nw-key`/Bearer nas rotas `/nw/*` |
| `routes.js` | `/nw/*` odonto: `/ping`, `/clinica`, `/especialidades`, `/dentistas`, `/procedimentos`, `/planos` |
| `sync-knowledge.js` | `buildKnowledgeText(pool, clinicaId)` + `syncKnowledge(pool)` → motor `/api/secretaria/*` |
| `webhook-receiver.js` | `POST /api/webhook/newwhats` (HMAC) — `message.new`, `session.status` (enxuto) |
| `proxy.js` | `/api/nw/v1/*` → motor `/api/ext/v1/*` (injeta `x-nw-key`) |
| `index.js` | `registerNewwhats(app, pool)` — registra tudo |
| `manifest.json` | metadados + `config_env` |
## Contrato (resumo)
- **Satélite → Motor**: proxy `/api/nw/v1/*` → ext-api `/api/ext/v1/*` (Bearer/x-nw-key);
`sync-knowledge``/api/secretaria/*`.
- **Motor → Satélite**: webhook `POST /api/webhook/newwhats` (HMAC `webhook_secret`);
Secretária consulta `/nw/*` (`integration_key`).
## Frontend (React SPA)
- Plugin `newwhats` registrado em `frontend/views/PluginsView.tsx` (catálogo
PLUGINS) — **config exclusiva do superadmin** (a view `plugins` já é gateada em
`App.tsx isViewAllowed`).
- `ALWAYS_ON` em `pluginRegistry.ts` → Inbox/Secretária aparecem para **todos** os
usuários (a ativação por plugin é localStorage e não propaga entre contas).
- Views: `frontend/views/WaInboxView.tsx` (conversas/thread/envio) e
`WaSecretariaView.tsx` (ask) — consomem `/api/nw/v1/*`. **Estágio 1** (funcional,
visual aproximado do motor; refinar para ficar idêntico).
- Sidebar: itens WHATSAPP + SECRETÁRIA IA (gated por `isPluginActive('newwhats')`).
## Pendências / TODO
- [ ] **Segurança**: gatear o proxy `/api/nw/v1/*` com a auth do scoreodonto (hoje
aberto — qualquer um que alcance a rota é proxiado ao motor).
- [x] **Ponte de config** (feito): config persiste na tabela `settings`
(`id=newwhats_config`); backend lê do DB com fallback ENV
(`config.js loadConfigFromDb`). UI dedicada (`NewwhatsConfigModal`) com
**dropdown de clínicas**, secrets que mantêm valor se em branco e **Testar
conexão** (`GET /api/nw/status`). Endpoints superadmin: `GET/PUT /api/nw/config`,
`GET /api/nw/clinicas`, `GET /api/nw/status`.
- [ ] `webhook message.new`: capturar lead / acionar fluxo (hoje só loga).
- [ ] Proxy WS (`/api/nw/v1/stream`) — só o REST foi portado (Inbox sem tempo real).
- [ ] Parear scoreodonto com o motor (criar apiKey/pair + agente) e preencher as ENV.
- [ ] Endpoint/admin para disparar `syncKnowledge` (hoje exportado, sem rota).
- [ ] Refinar Inbox/Secretária para ficarem idênticas ao motor.
> Fonte da verdade do contrato: `documentos-unicos/projetos/newwhats.clube67.com/`.
+694
View File
@@ -0,0 +1,694 @@
// Ponte de agenda: expõe /api/nw/agenda/* para o MOTOR (tools da Secretária)
// operarem na agenda REAL do scoreodonto — em vez do calendário isolado do motor.
//
// Segurança: chamada server-to-server autenticada por segredo compartilhado
// (header x-nw-agenda-secret === webhookSecret configurado). Todo acesso é
// preso ao clinica_id enviado (o motor conhece a clínica da instância).
//
// Endpoints:
// GET /slots — horários livres (jornada dos dentistas agendamentos)
// POST /book — cria agendamento real (anti-overbooking + vínculo de paciente)
import express from 'express';
import { getConfig } from './config.js';
import { cautelasParaIA } from './checklist-defs.js';
import { resolverFeriado, feriadoNacionalNome } from './feriados.js';
const SLOT_MIN = 60; // duração/espaçamento padrão do slot (1h entre atendimentos)
const HORIZON_DAYS = 7; // janela padrão de busca
const MAX_SLOTS = 20; // quantos slots RETORNAR (após ordenar por horário)
const CANDIDATE_CAP = 240; // teto de GERAÇÃO (clínica + salas competem por horário)
// Fallback quando a CLÍNICA não configurou horário de funcionamento: comercial
// padrão (segsex 0818). Uma vez configurado (clinicas_horarios), manda ele.
const DEFAULT_INI = '08:00';
const DEFAULT_FIM = '18:00';
const DIAS_NOME = ['domingo', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado'];
const pad = (n) => String(n).padStart(2, '0');
const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const fmt = (d) => `${ymd(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
const hm = (t) => String(t ?? '').slice(0, 5); // 'HH:MM:SS'|Date → 'HH:MM'
// Interseção de duas listas de janelas [ini,fim] (strings 'HH:MM'). Retorna as
// sobreposições (ex.: clínica 0818 ∩ dentista 1320 = 1318).
function interseccao(a, b) {
const out = [];
for (const [ai, af] of a) for (const [bi, bf] of b) {
const ini = ai > bi ? ai : bi;
const fim = af < bf ? af : bf;
if (ini < fim) out.push([ini, fim]);
}
return out;
}
// Carrega o horário de FUNCIONAMENTO da clínica → { byDow: Map<dow,[[ini,fim]]>, hasConfig }.
async function carregarHorariosClinica(pool, clinicaId) {
const byDow = new Map();
let hasConfig = false;
try {
const { rows } = await pool.query(
`SELECT dia_semana, hora_inicio, hora_fim FROM clinicas_horarios WHERE clinica_id = $1 AND ativo = 1`,
[clinicaId]);
hasConfig = rows.length > 0;
for (const r of rows) {
const arr = byDow.get(r.dia_semana) || [];
arr.push([hm(r.hora_inicio), hm(r.hora_fim)]);
byDow.set(r.dia_semana, arr);
}
} catch { /* tabela pode não existir ainda → cai no fallback */ }
return { byDow, hasConfig };
}
// Carrega as folgas (por data) dos dentistas da clínica → Map<dentista_id,[{ini,fim}]>.
async function carregarFolgas(pool, clinicaId) {
const byDent = new Map();
try {
const { rows } = await pool.query(
`SELECT f.dentista_id, to_char(f.data_inicio,'YYYY-MM-DD') AS ini, to_char(f.data_fim,'YYYY-MM-DD') AS fim
FROM dentistas_folgas f JOIN dentistas d ON d.id = f.dentista_id
WHERE d.clinica_id = $1`, [clinicaId]);
for (const r of rows) {
const arr = byDent.get(r.dentista_id) || [];
arr.push([r.ini, r.fim]);
byDent.set(r.dentista_id, arr);
}
} catch { /* tabela pode não existir ainda */ }
return byDent;
}
const emFolga = (folgas, dateStr) => (folgas || []).some(([i, f]) => dateStr >= i && dateStr <= f);
// Janelas de funcionamento da CLÍNICA num dia específico, já considerando feriado.
// Retorna { janelas: [[ini,fim]], fechado: bool, motivo: string|null }.
async function janelasClinicaNoDia(pool, clinicaId, dateStr, dow, clinicHours) {
const fer = await resolverFeriado(pool, clinicaId, dateStr);
if (fer && fer.fecha) return { janelas: [], fechado: true, motivo: `Feriado: ${fer.nome}` };
// Feriado com horário especial (fecha=0 + horas) sobrepõe o horário normal.
if (fer && !fer.fecha && fer.hora_inicio && fer.hora_fim) {
return { janelas: [[hm(fer.hora_inicio), hm(fer.hora_fim)]], fechado: false, motivo: `Horário especial (${fer.nome})` };
}
// Horário normal do dia da semana.
let janelas;
if (clinicHours.hasConfig) {
janelas = (clinicHours.byDow.get(dow) || []).map(([i, f]) => [i, f]);
} else {
janelas = (dow >= 1 && dow <= 5) ? [[DEFAULT_INI, DEFAULT_FIM]] : [];
}
if (!janelas.length) return { janelas: [], fechado: true, motivo: DIAS_NOME[dow] === 'domingo' || DIAS_NOME[dow] === 'sábado' ? `Fechado (${DIAS_NOME[dow]})` : 'Fechado neste dia' };
return { janelas, fechado: false, motivo: null };
}
// Overbooking: MESMA regra do server.js (agrupa o dentista por usuario_id/email,
// para o mesmo profissional atuando em clínicas diferentes). Cópia da lógica —
// se a regra do scoreodonto mudar, revisar aqui também.
async function findConflict(db, dentistaId, start, end, excludeId = null) {
if (!dentistaId || !start) return null;
const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]);
let ids = [dentistaId];
const uid = dr[0]?.usuario_id || null;
const email = (dr[0]?.email || '').trim().toLowerCase() || null;
if (uid) {
const { rows } = await db.query('SELECT id FROM dentistas WHERE usuario_id = $1', [uid]);
if (rows.length) ids = rows.map((r) => r.id);
} else if (email) {
const { rows } = await db.query('SELECT id FROM dentistas WHERE lower(trim(email)) = $1', [email]);
if (rows.length) ids = rows.map((r) => r.id);
}
const { rows } = await db.query(
`SELECT id FROM agendamentos
WHERE dentistaid = ANY($1)
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))
AND start_time < $3 AND COALESCE(end_time, start_time) > $2
AND ($4::text IS NULL OR id <> $4)
LIMIT 1`,
[ids, start, end || start, excludeId]);
return rows[0] || null;
}
export function createAgendaBridge(pool) {
const router = express.Router();
// Auth server-to-server por segredo compartilhado.
router.use((req, res, next) => {
const secret = getConfig().webhookSecret;
if (!secret || req.headers['x-nw-agenda-secret'] !== secret) {
return res.status(401).json({ error: 'Segredo da ponte inválido' });
}
next();
});
// ── GET /slots ──────────────────────────────────────────────────────────────
// Query: clinica_id (obrig.), dentista_id?, date? (YYYY-MM-DD), days?, duration?
router.get('/slots', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null;
const duration = Math.max(10, parseInt(req.query.duration, 10) || SLOT_MIN);
const days = Math.min(30, Math.max(1, parseInt(req.query.days, 10) || HORIZON_DAYS));
const from = req.query.date ? new Date(`${req.query.date}T00:00:00`) : new Date();
const periodo = req.query.periodo ? String(req.query.periodo).toLowerCase() : null; // manha|tarde
try {
// Dentistas no escopo da clínica.
const dParams = [clinicaId];
let dWhere = 'clinica_id = $1';
if (dentistaId) { dParams.push(dentistaId); dWhere += ` AND id = $${dParams.length}`; }
const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams);
if (!dentistas.length) return res.json({ slots: [], motivo: 'Nenhum dentista cadastrado na clínica.' });
// Jornada dos DENTISTAS (dentistas_horarios) — opcional, por dentista.
const { rows: horarios } = await pool.query(
`SELECT dentista_id, dia_semana, hora_inicio, hora_fim
FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1`, [clinicaId]);
const hoursBy = new Map();
for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push(h); hoursBy.set(h.dentista_id, a); }
// Horário de FUNCIONAMENTO da clínica (config do dono) — a "casca" externa.
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
// Folgas por data dos dentistas (férias/indisponibilidade).
const folgasBy = await carregarFolgas(pool, clinicaId);
const start0 = new Date(from); start0.setHours(0, 0, 0, 0);
const end0 = new Date(start0); end0.setDate(end0.getDate() + days);
const { rows: busy } = await pool.query(
`SELECT dentistaid, start_time, end_time FROM agendamentos
WHERE clinica_id = $1 AND start_time >= $2 AND start_time < $3
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))`,
[clinicaId, start0.toISOString(), end0.toISOString()]);
const busyBy = new Map();
for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); }
const now = new Date();
const slots = [];
for (let i = 0; i < days && slots.length < CANDIDATE_CAP; i++) {
const day = new Date(start0); day.setDate(day.getDate() + i);
const dow = day.getDay();
// Janelas da CLÍNICA no dia (já considera feriado). Fechado → pula o dia.
const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, ymd(day), dow, clinicHours);
if (fechado || !clinicWins.length) continue;
for (const dent of dentistas) {
if (slots.length >= CANDIDATE_CAP) break;
if (emFolga(folgasBy.get(dent.id), ymd(day))) continue; // dentista de folga nesse dia
const cfg = hoursBy.get(dent.id);
// Dentista: se configurou jornada, usa a do dia; senão, atende sempre
// que a clínica está aberta. Efetivo = CLÍNICA ∩ DENTISTA.
const dentWins = (cfg && cfg.length)
? cfg.filter((h) => h.dia_semana === dow).map((h) => [hm(h.hora_inicio), hm(h.hora_fim)])
: clinicWins;
const windows = interseccao(clinicWins, dentWins);
const bs = busyBy.get(dent.id) || [];
for (const [ini, fim] of windows) {
const [hi, mi] = String(ini).split(':').map(Number);
const [hf, mf] = String(fim).split(':').map(Number);
let t = new Date(day); t.setHours(hi, mi || 0, 0, 0);
const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0);
while (t.getTime() + duration * 60000 <= limit.getTime() && slots.length < CANDIDATE_CAP) {
const s = new Date(t);
const e = new Date(t.getTime() + duration * 60000);
t = e;
if (s <= now) continue;
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
slots.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e) });
}
}
}
}
// ── B (salas): slots do dentista num QUARTO alugado (sala_reservas aprovada).
// Disponibilidade = janela da reserva [inicio,fim]; casa o dentista da clínica
// pelo usuario_id. Respeita folgas e agendamentos existentes (por dentista).
if (slots.length < CANDIDATE_CAP) {
try {
const { rows: reservas } = await pool.query(
`SELECT r.sala_id, r.inicio, r.fim, s.nome AS sala_nome, d.id AS dentista_id, d.nome AS dentista_nome
FROM sala_reservas r
JOIN salas s ON s.id = r.sala_id
JOIN dentistas d ON d.usuario_id = r.profissional_usuario_id
WHERE d.clinica_id = $1
AND lower(coalesce(r.status,'')) NOT IN ('cancelada','cancelado','recusada','recusado','rejeitada','pendente','negada')
AND r.fim > $2 AND r.inicio < $3
ORDER BY r.inicio`,
[clinicaId, start0.toISOString(), end0.toISOString()]);
const step = duration * 60000;
for (const rv of reservas) {
if (slots.length >= CANDIDATE_CAP) break;
const bs = busyBy.get(rv.dentista_id) || [];
const rIni = new Date(rv.inicio), rFim = new Date(rv.fim);
let t = new Date(Math.max(rIni.getTime(), start0.getTime(), now.getTime()));
const off = (t.getTime() - rIni.getTime()) % step;
if (off !== 0) t = new Date(t.getTime() + (step - off)); // alinha ao grid da reserva
while (t.getTime() + step <= rFim.getTime() && slots.length < CANDIDATE_CAP) {
const s = new Date(t), e = new Date(t.getTime() + step); t = e;
if (s <= now) continue;
if (emFolga(folgasBy.get(rv.dentista_id), ymd(s))) continue;
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
slots.push({ dentista_id: rv.dentista_id, dentista_nome: rv.dentista_nome, start: fmt(s), end: fmt(e), sala_id: rv.sala_id, local: rv.sala_nome });
}
}
} catch { /* sala_reservas pode não existir */ }
}
let out = slots.sort((a, b) => a.start.localeCompare(b.start));
// Filtro manhã/tarde (a Secretária pergunta o período antes de oferecer).
if (periodo === 'manha' || periodo === 'manhã') out = out.filter((s) => parseInt(s.start.slice(11, 13), 10) < 12);
else if (periodo === 'tarde') out = out.filter((s) => parseInt(s.start.slice(11, 13), 10) >= 12);
res.json({ slots: out.slice(0, MAX_SLOTS) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── GET /status ──────────────────────────────────────────────────────────────
// Situação de funcionamento da clínica p/ a Secretária SABER quando responder
// "estamos fechados". Query: clinica_id (obrig.), date? (YYYY-MM-DD, def: hoje BR).
// Retorna: hoje (aberto/motivo/horários), próximo dia aberto, grade da semana e
// próximos feriados.
router.get('/status', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
try {
// "Hoje" no fuso de Brasília (o servidor pode rodar em UTC).
const hojeBR = req.query.date
? String(req.query.date)
: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Sao_Paulo' }); // YYYY-MM-DD
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
const dateAt = (str) => new Date(`${str}T12:00:00`); // meio-dia: evita borda de fuso
const addYmd = (str, k) => { const d = dateAt(str); d.setDate(d.getDate() + k); return ymd(d); };
const statusDoDia = async (str) => {
const dow = dateAt(str).getDay();
const r = await janelasClinicaNoDia(pool, clinicaId, str, dow, clinicHours);
return {
data: str, dia_semana: dow, dia_nome: DIAS_NOME[dow],
aberto: !r.fechado && r.janelas.length > 0, motivo: r.motivo,
horarios: r.janelas.map(([i, f]) => ({ inicio: i, fim: f })),
};
};
// Grade dos próximos 7 dias (inclui hoje).
const semana = [];
for (let k = 0; k < 7; k++) semana.push(await statusDoDia(addYmd(hojeBR, k)));
const hoje = semana[0];
// Próximo dia aberto: primeiro aberto na semana; se nenhum, varre +14.
let proximo = semana.find((s) => s.aberto) || null;
if (!proximo) for (let k = 7; k <= 21; k++) { const s = await statusDoDia(addYmd(hojeBR, k)); if (s.aberto) { proximo = s; break; } }
// Próximos feriados (45 dias) — clínica (1 query) + nacionais (em memória).
const feriados = [];
let excMap = new Map();
try {
const { rows } = await pool.query(
`SELECT data, nome, fecha, recorrente FROM feriados WHERE clinica_id = $1`, [clinicaId]);
for (const r of rows) {
const ds = (r.data instanceof Date) ? ymd(r.data) : String(r.data).slice(0, 10);
excMap.set(ds, r);
if (r.recorrente === 1) excMap.set('R' + ds.slice(5), r);
}
} catch { /* tabela pode não existir */ }
for (let k = 0; k < 45; k++) {
const str = addYmd(hojeBR, k);
const exc = excMap.get(str) || excMap.get('R' + str.slice(5));
if (exc) { if (exc.fecha !== 0) feriados.push({ data: str, nome: exc.nome, origem: 'clinica' }); continue; }
const nac = feriadoNacionalNome(str);
if (nac) feriados.push({ data: str, nome: nac, origem: 'nacional' });
}
res.json({ hoje, proximo_aberto: proximo, semana, feriados_proximos: feriados.slice(0, 8) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── POST /pedido ──────────────────────────────────────────────────────────────
// A Secretária IA registra um pedido de horário FORA da janela livre (zona cinza:
// mais cedo/mais tarde) para a SECRETÁRIA HUMANA confirmar. A IA não agenda esses.
// Body: { clinica_id, data (YYYY-MM-DD), hora (HH:MM), hora_fim?, dentista_nome?,
// nome_cliente, telefone?, procedimento? }
router.post('/pedido', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.data || !b.hora) return res.status(400).json({ error: 'clinica_id, data e hora obrigatórios' });
try {
// Dia totalmente fechado (feriado/fechamento) → não adianta criar pedido.
const fer = await resolverFeriado(pool, b.clinica_id, b.data);
if (fer && fer.fecha) return res.json({ ok: false, motivo: `Fechado nesse dia (${fer.nome}).` });
// Resolve o dentista pelo nome (opcional).
let dentistaId = null;
if (b.dentista_nome) {
const { rows } = await pool.query(
`SELECT id FROM dentistas WHERE clinica_id = $1 AND lower(nome) LIKE '%'||lower($2)||'%' LIMIT 1`,
[b.clinica_id, b.dentista_nome]);
dentistaId = rows[0]?.id || null;
}
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await pool.query(
`INSERT INTO agenda_pedidos (id, clinica_id, dentista_id, data, hora_inicio, hora_fim, paciente_nome, paciente_telefone, procedimento, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pendente')`,
[id, b.clinica_id, dentistaId, b.data, hm(b.hora), b.hora_fim ? hm(b.hora_fim) : null,
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null, b.procedimento || null]);
res.json({ ok: true, pedido_id: id });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /duvida ──────────────────────────────────────────────────────────────
// Encaminha uma DÚVIDA/caso incerto à secretária humana (sem agendar). Cria um
// agenda_pedidos tipo='duvida' com o resumo; entra na mesma fila de pendentes.
router.post('/duvida', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.resumo) return res.status(400).json({ error: 'clinica_id e resumo obrigatórios' });
try {
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await pool.query(
`INSERT INTO agenda_pedidos (id, clinica_id, tipo, resumo, paciente_nome, paciente_telefone, status)
VALUES ($1,$2,'duvida',$3,$4,$5,'pendente')`,
[id, b.clinica_id, String(b.resumo).slice(0, 2000),
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null]);
res.json({ ok: true, pedido_id: id });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /contexto ─────────────────────────────────────────────────────────────
// Contexto clínico p/ a Secretária: dentistas + especialidades + "situações"
// (regras da clínica). Query: clinica_id.
router.get('/contexto', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
try {
const { rows: dentistas } = await pool.query(
`SELECT nome, especialidade, especialidades FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0 ORDER BY nome`, [clinicaId]);
let situacoes = [];
try {
const { rows } = await pool.query(`SELECT texto FROM agenda_situacoes WHERE clinica_id = $1 AND ativo = 1 ORDER BY ordem, created_at`, [clinicaId]);
situacoes = rows.map((r) => r.texto);
} catch { /* tabela pode não existir ainda */ }
// Cautelas do checklist do dono (declaração × dados reais) → a IA não chuta.
let cautelas = [];
try {
const respostas = {};
const { rows } = await pool.query(`SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1`, [clinicaId]);
for (const r of rows) respostas[r.chave] = r.resposta == null ? null : Number(r.resposta);
const nPac = await pool.query(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`, [clinicaId]).then((x) => Number(x.rows[0]?.n || 0)).catch(() => 0);
cautelas = cautelasParaIA(respostas, { dentistas: dentistas.length, pacientes: nPac });
} catch { /* checklist novo/indisponível */ }
res.json({
dentistas: dentistas.map((d) => ({
nome: d.nome,
especialidades: Array.isArray(d.especialidades) ? d.especialidades : (d.especialidade ? [d.especialidade] : []),
})),
situacoes,
cautelas,
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /dentista-contato ─────────────────────────────────────────────────────
// Resolve o WhatsApp de um dentista da clínica (Parte A: a IA aciona o Dr).
// Query: clinica_id (obrig.), nome? OU dentista_id?
router.get('/dentista-contato', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
try {
const params = [clinicaId]; let where = 'clinica_id = $1';
if (req.query.dentista_id) { params.push(String(req.query.dentista_id)); where += ` AND id = $${params.length}`; }
else if (req.query.nome) { params.push(String(req.query.nome)); where += ` AND lower(nome) LIKE '%'||lower($${params.length})||'%'`; }
const { rows } = await pool.query(
`SELECT id, nome, telefone FROM dentistas WHERE ${where} AND ativo IS DISTINCT FROM 0 ORDER BY nome LIMIT 1`, params);
if (!rows.length) return res.json({ encontrado: false });
const d = rows[0];
const tel = String(d.telefone || '').replace(/\D/g, '');
res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, telefone: tel || null });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /book ────────────────────────────────────────────────────────────────
// Body: { clinica_id, dentista_id, start, end?, paciente_nome, paciente_celular, procedimento? }
router.post('/book', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.dentista_id || !b.start) {
return res.status(400).json({ error: 'clinica_id, dentista_id e start obrigatórios' });
}
const end = b.end || new Date(new Date(b.start).getTime() + SLOT_MIN * 60000).toISOString();
const client = await pool.connect();
try {
await client.query('BEGIN');
const conflict = await findConflict(client, b.dentista_id, b.start, end);
if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário já ocupado.', conflict_id: conflict.id }); }
// Vincula paciente pelo telefone (escopo da clínica; casa pelos últimos 8 dígitos).
let pacienteId = null;
let pacienteCriado = false;
if (b.paciente_celular) {
const tel = String(b.paciente_celular).replace(/\D/g, '').slice(-8);
if (tel) {
const { rows } = await client.query(
`SELECT id FROM pacientes
WHERE clinica_id = $1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2 LIMIT 1`,
[b.clinica_id, tel]);
pacienteId = rows[0]?.id || null;
}
}
// Não encontrado → cria um paciente mínimo (lead do WhatsApp vira paciente).
if (!pacienteId && b.paciente_nome) {
const pid = `pac_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
await client.query(
'INSERT INTO pacientes (id, nome, telefone, clinica_id) VALUES ($1,$2,$3,$4)',
[pid, b.paciente_nome, b.paciente_celular || null, b.clinica_id]);
pacienteId = pid;
pacienteCriado = true;
}
const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id = $1 AND clinica_id = $2', [b.dentista_id, b.clinica_id]);
const setorId = dd[0]?.setor_id || null;
const id = `ag_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
const { rows: ins } = await client.query(
`INSERT INTO agendamentos
(id, clinica_id, pacientenome, pacientecelular, pacienteid, dentistaid, procedimento,
start_time, end_time, status, setor_id, created_by_nome, created_at, updated_at, sala_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'agendado',$10,'Secretária IA',NOW(),NOW(),$11)
RETURNING id, start_time, end_time, dentistaid`,
[id, b.clinica_id, b.paciente_nome || 'Cliente WhatsApp', b.paciente_celular || null,
pacienteId, b.dentista_id, b.procedimento || 'Consulta', b.start, end, setorId, b.sala_id || null]);
await client.query('COMMIT');
res.json({ ok: true, agendamento: ins[0], paciente_vinculado: !!pacienteId, paciente_criado: pacienteCriado });
} catch (e) {
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
res.status(500).json({ error: e.message });
} finally {
client.release();
}
});
// ── GET /encaixe/analisar ─────────────────────────────────────────────────────
// Fluxo de "adiantar consulta": dado um dia (e horário desejado), diz se o horário
// está OCUPADO (por quem) e qual o horário LIVRE mais cedo que dá para oferecer/
// encaixar — incluindo a janela FLEX (zona cinza), pois na contenção a Secretária
// pode usá-la. Query: clinica_id, dentista_id?, data (YYYY-MM-DD), hora? (HH:MM).
router.get('/encaixe/analisar', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
const data = String(req.query.data || '');
if (!clinicaId || !data) return res.status(400).json({ error: 'clinica_id e data obrigatórios' });
const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null;
const horaDesejada = req.query.hora ? String(req.query.hora).slice(0, 5) : null;
try {
const day = new Date(`${data}T00:00:00`);
const dow = day.getDay();
const dParams = [clinicaId]; let dWhere = 'clinica_id = $1 AND ativo IS DISTINCT FROM 0';
if (dentistaId) { dParams.push(dentistaId); dWhere += ` AND id = $${dParams.length}`; }
const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams);
if (!dentistas.length) return res.json({ mais_cedo: null, contestado: null });
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, data, dow, clinicHours);
if (fechado || !clinicWins.length) return res.json({ mais_cedo: null, contestado: null, motivo: 'fechado' });
// Jornada FLEX-inclusiva (usa hora_*_flex quando houver — a zona cinza entra no encaixe).
const { rows: horarios } = await pool.query(
`SELECT dentista_id,
COALESCE(hora_inicio_flex, hora_inicio) AS hora_inicio,
COALESCE(hora_fim_flex, hora_fim) AS hora_fim
FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1 AND dia_semana = $2`, [clinicaId, dow]);
const hoursBy = new Map();
for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push([hm(h.hora_inicio), hm(h.hora_fim)]); hoursBy.set(h.dentista_id, a); }
const start0 = new Date(day); start0.setHours(0, 0, 0, 0);
const end0 = new Date(start0); end0.setDate(end0.getDate() + 1);
const { rows: busy } = await pool.query(
`SELECT id, dentistaid, pacientenome, pacientecelular, start_time, end_time FROM agendamentos
WHERE clinica_id=$1 AND start_time>=$2 AND start_time<$3
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))`,
[clinicaId, start0.toISOString(), end0.toISOString()]);
const busyBy = new Map();
for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); }
// Quem ocupa o horário desejado (direto, sem depender do grid).
let contestado = null;
if (horaDesejada) {
const alvo = new Date(`${data}T${horaDesejada}:00`);
const occ = busy.find((b) => new Date(b.start_time) <= alvo && new Date(b.end_time || b.start_time) > alvo)
|| busy.find((b) => fmt(new Date(b.start_time)).slice(11, 16) === horaDesejada);
if (occ) {
const dn = dentistas.find((d) => d.id === occ.dentistaid);
contestado = { agendamento_id: occ.id, dentista_id: occ.dentistaid, dentista_nome: dn?.nome || null,
paciente_nome: occ.pacientenome || null, paciente_celular: occ.pacientecelular || null,
start: fmt(new Date(occ.start_time)), hora: fmt(new Date(occ.start_time)).slice(11, 16) };
}
}
// Slots LIVRES do dia (flex-inclusivo).
const now = new Date(); const dur = SLOT_MIN; const livres = [];
for (const dent of dentistas) {
const dw = (hoursBy.get(dent.id) && hoursBy.get(dent.id).length) ? hoursBy.get(dent.id) : clinicWins;
const windows = interseccao(clinicWins, dw);
const bs = busyBy.get(dent.id) || [];
for (const [ini, fim] of windows) {
const [hi, mi] = String(ini).split(':').map(Number);
const [hf, mf] = String(fim).split(':').map(Number);
let t = new Date(day); t.setHours(hi, mi || 0, 0, 0);
const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0);
while (t.getTime() + dur * 60000 <= limit.getTime()) {
const s = new Date(t), e = new Date(t.getTime() + dur * 60000); t = e;
if (s <= now) continue;
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
livres.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e), hora: fmt(s).slice(11, 16) });
}
}
}
livres.sort((a, b) => a.start.localeCompare(b.start));
// mais_cedo = livre imediatamente ANTES do desejado (o mais perto por baixo); senão o 1º livre.
let mais_cedo = null;
if (horaDesejada) {
const antes = livres.filter((s) => s.hora < horaDesejada);
mais_cedo = antes.length ? antes[antes.length - 1] : (livres[0] || null);
} else {
mais_cedo = livres[0] || null;
}
res.json({ mais_cedo, contestado, total_livres: livres.length });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /encaixe/mover ───────────────────────────────────────────────────────
// Move um agendamento existente para um novo horário (o A aceitou adiantar).
// Body: { clinica_id, agendamento_id, start, end? }.
router.post('/encaixe/mover', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.agendamento_id || !b.start) return res.status(400).json({ error: 'clinica_id, agendamento_id e start obrigatórios' });
const end = b.end || new Date(new Date(b.start).getTime() + SLOT_MIN * 60000).toISOString();
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query('SELECT id, dentistaid FROM agendamentos WHERE id=$1 AND clinica_id=$2 FOR UPDATE', [b.agendamento_id, b.clinica_id]);
const ag = rows[0];
if (!ag) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Agendamento não encontrado.' }); }
const conflict = await findConflict(client, ag.dentistaid, b.start, end, b.agendamento_id);
if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário de destino já ocupado.' }); }
await client.query('UPDATE agendamentos SET start_time=$1, end_time=$2, updated_at=NOW() WHERE id=$3', [b.start, end, b.agendamento_id]);
await client.query('COMMIT');
res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch { /* ignore */ } res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// ════════ Cadastro de pacientes (Secretária super inteligente) ════════════════
const soDigitos = (v) => String(v || '').replace(/\D/g, '');
function idadeDe(dob) {
if (!dob) return null;
const s = String(dob).trim();
let d = null, m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) d = new Date(+m[1], +m[2] - 1, +m[3]);
else { m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/); if (m) d = new Date(+m[3], +m[2] - 1, +m[1]); }
if (!d || isNaN(d.getTime())) return null;
const n = new Date(); let a = n.getFullYear() - d.getFullYear();
const mo = n.getMonth() - d.getMonth();
if (mo < 0 || (mo === 0 && n.getDate() < d.getDate())) a--;
return a >= 0 && a < 130 ? a : null;
}
// GET /pacientes/lookup?clinica_id=&phone= — quem está cadastrado NESTE número (a família).
router.get('/pacientes/lookup', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
const tel = soDigitos(req.query.phone).slice(-8);
if (!clinicaId || !tel) return res.status(400).json({ error: 'clinica_id e phone obrigatórios' });
try {
const { rows } = await pool.query(
`SELECT id, nome, cpf, telefone, datanascimento, convenio, grupofamiliarid, grupofamiliarrelacao
FROM pacientes
WHERE clinica_id = $1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2
ORDER BY (grupofamiliarrelacao = 'titular' OR grupofamiliarrelacao IS NULL) DESC, nome`,
[clinicaId, tel]);
res.json({
encontrados: rows.length,
pacientes: rows.map((p) => ({
id: p.id, nome: p.nome, cpf: p.cpf || null,
data_nascimento: p.datanascimento || null, idade: idadeDe(p.datanascimento),
convenio: p.convenio || null, relacao: p.grupofamiliarrelacao || null, grupo_id: p.grupofamiliarid || null,
})),
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /pacientes — cadastra/atualiza. Dedup por CPF; idade por nascimento; grupo familiar
// (dependente vinculado ao titular via grupofamiliarid = id do titular).
router.post('/pacientes', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.nome) return res.status(400).json({ error: 'clinica_id e nome obrigatórios' });
const cpf = soDigitos(b.cpf);
const telefone = b.telefone ? soDigitos(b.telefone) : null;
const idade = idadeDe(b.data_nascimento);
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1) Dedup forte por CPF (identificador confiável — evita duplicar quem já existe).
let existente = null;
if (cpf) {
const { rows } = await client.query(
`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(cpf,''),'\\D','','g')=$2 LIMIT 1`, [b.clinica_id, cpf]);
existente = rows[0] || null;
}
// 2) Resolve o RESPONSÁVEL (para dependentes): por id, cpf ou telefone (o titular do número).
let responsavel = null;
if (b.responsavel_id) {
const { rows } = await client.query('SELECT * FROM pacientes WHERE id=$1 AND clinica_id=$2', [b.responsavel_id, b.clinica_id]); responsavel = rows[0] || null;
} else if (b.responsavel_cpf) {
const { rows } = await client.query(`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(cpf,''),'\\D','','g')=$2 LIMIT 1`, [b.clinica_id, soDigitos(b.responsavel_cpf)]); responsavel = rows[0] || null;
} else if (b.responsavel_telefone) {
const { rows } = await client.query(
`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2
AND (grupofamiliarrelacao='titular' OR grupofamiliarrelacao IS NULL) ORDER BY nome LIMIT 1`,
[b.clinica_id, soDigitos(b.responsavel_telefone).slice(-8)]); responsavel = rows[0] || null;
}
// Dependente = tem responsável OU menor de 18 (padrão) sem marcar eh_titular.
const ehDependente = !!responsavel || (idade != null && idade < 18 && !b.eh_titular);
let relacao = b.relacao || (ehDependente ? (idade != null && idade < 18 ? 'filho(a)' : 'dependente') : (b.eh_titular ? 'titular' : null));
// Telefone do dependente sem número próprio → herda o do responsável (compartilhado).
let telFinal = telefone;
if (ehDependente && !telFinal && responsavel && responsavel.telefone) telFinal = soDigitos(responsavel.telefone);
// 3) Grupo familiar: titular vira cabeça (grupofamiliarid = id do titular) + grupos_familiares.
let grupoId = null;
if (ehDependente && responsavel) {
grupoId = responsavel.grupofamiliarid || responsavel.id;
if (!responsavel.grupofamiliarid) {
await client.query(`UPDATE pacientes SET grupofamiliarid=$1, grupofamiliarrelacao=COALESCE(grupofamiliarrelacao,'titular') WHERE id=$1`, [responsavel.id]);
}
await client.query(`INSERT INTO grupos_familiares (id, nome, responsavel_id, created_at) VALUES ($1,$2,$3,NOW()) ON CONFLICT (id) DO NOTHING`, [grupoId, responsavel.nome, responsavel.id]);
}
// 4) Atualiza (dedup) OU cria.
if (existente) {
await client.query(
`UPDATE pacientes SET nome=$2, telefone=COALESCE($3,telefone), email=COALESCE($4,email),
convenio=COALESCE($5,convenio), datanascimento=COALESCE($6,datanascimento),
grupofamiliarid=COALESCE($7,grupofamiliarid), grupofamiliarrelacao=COALESCE($8,grupofamiliarrelacao) WHERE id=$1`,
[existente.id, b.nome, telFinal, b.email || null, b.convenio || null, b.data_nascimento || null, grupoId, relacao]);
await client.query('COMMIT');
return res.json({ ok: true, atualizado: true, paciente: { id: existente.id, nome: b.nome, relacao, grupo_id: grupoId, idade } });
}
const id = `pac_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await client.query(
`INSERT INTO pacientes (id, clinica_id, nome, cpf, telefone, email, convenio, datanascimento, grupofamiliarid, grupofamiliarrelacao)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[id, b.clinica_id, b.nome, cpf || null, telFinal, b.email || null, b.convenio || null, b.data_nascimento || null, grupoId, relacao]);
await client.query('COMMIT');
res.json({ ok: true, criado: true, paciente: { id, nome: b.nome, cpf: cpf || null, relacao, grupo_id: grupoId, idade, dependente: ehDependente } });
} catch (e) {
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
res.status(500).json({ error: e.message });
} finally { client.release(); }
});
return router;
}
+404
View File
@@ -0,0 +1,404 @@
// Config de horários/feriados — API para o FRONTEND do scoreodonto (dono/dentista).
// Auth: JWT do scoreodonto (Authorization: Bearer). Ownership:
// - Horário da CLÍNICA + feriados → só o DONO (clinicas.owner_id / vínculo dono).
// - Horário do DENTISTA → o próprio dentista (dentistas.usuario_id) OU o dono.
//
// Montado em /api/nw/agenda-config pelo registerNewwhats.
import express from 'express';
import jwt from 'jsonwebtoken';
import { feriadosNacionais } from './feriados.js';
import { CHECKLIST_QUESTOES, pendenciasDeConfig } from './checklist-defs.js';
function authFromReq(req) {
try {
const tk = (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim();
if (!tk) return null;
const p = jwt.verify(tk, process.env.JWT_SECRET);
return { userId: p.userId || null, email: (p.email || '').toLowerCase() || null };
} catch { return null; }
}
// É dono do workspace (clínica)? owner_id direto OU vínculo dono/admin.
async function isDono(pool, clinicaId, userId) {
if (!clinicaId || !userId) return false;
try {
const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
if (cl[0]?.owner_id && cl[0].owner_id === userId) return true;
const { rows: v } = await pool.query(
`SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2
AND role IN ('donoclinica','donoconsultorio','admin') LIMIT 1`, [clinicaId, userId]);
return v.length > 0;
} catch { return false; }
}
const uid = (p) => `${p}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const hhmm = (v) => { const m = String(v ?? '').match(/^(\d{1,2}):(\d{2})/); return m ? `${m[1].padStart(2, '0')}:${m[2]}` : null; };
export function createAgendaConfig(pool) {
const router = express.Router();
router.use((req, res, next) => {
const auth = authFromReq(req);
if (!auth) return res.status(401).json({ error: 'Sessão inválida.' });
req.nwUser = auth;
next();
});
// ── Horário de FUNCIONAMENTO da clínica (dono) ────────────────────────────────
router.get('/clinica/:clinicaId/horarios', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, dia_semana, to_char(hora_inicio,'HH24:MI') AS hora_inicio,
to_char(hora_fim,'HH24:MI') AS hora_fim, ativo
FROM clinicas_horarios WHERE clinica_id = $1 ORDER BY dia_semana, hora_inicio`,
[req.params.clinicaId]);
res.json({ horarios: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Substitui TODA a grade da clínica (mais simples e previsível que patch por dia).
// Body: { horarios: [{ dia_semana, hora_inicio, hora_fim }] }
router.put('/clinica/:clinicaId/horarios', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId)))
return res.status(403).json({ error: 'Apenas o dono pode configurar o horário da clínica.' });
const linhas = Array.isArray(req.body?.horarios) ? req.body.horarios : [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM clinicas_horarios WHERE clinica_id = $1', [clinicaId]);
for (const h of linhas) {
const dow = Number(h.dia_semana); const ini = hhmm(h.hora_inicio); const fim = hhmm(h.hora_fim);
if (!(dow >= 0 && dow <= 6) || !ini || !fim || ini >= fim) continue;
await client.query(
`INSERT INTO clinicas_horarios (id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo)
VALUES ($1,$2,$3,$4,$5,1)`, [uid('ch'), clinicaId, dow, ini, fim]);
}
await client.query('COMMIT');
res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// ── Horário do DENTISTA (o próprio dentista ou o dono) ────────────────────────
router.get('/dentista/:dentistaId/horarios', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, dia_semana, to_char(hora_inicio,'HH24:MI') AS hora_inicio,
to_char(hora_fim,'HH24:MI') AS hora_fim,
to_char(hora_inicio_flex,'HH24:MI') AS hora_inicio_flex,
to_char(hora_fim_flex,'HH24:MI') AS hora_fim_flex, ativo
FROM dentistas_horarios WHERE dentista_id = $1 ORDER BY dia_semana, hora_inicio`,
[req.params.dentistaId]);
res.json({ horarios: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.put('/dentista/:dentistaId/horarios', async (req, res) => {
const dentistaId = req.params.dentistaId;
let dent;
try { const { rows } = await pool.query('SELECT clinica_id, usuario_id FROM dentistas WHERE id = $1', [dentistaId]); dent = rows[0]; }
catch (e) { return res.status(500).json({ error: e.message }); }
if (!dent) return res.status(404).json({ error: 'Dentista não encontrado.' });
const ehProprio = dent.usuario_id && dent.usuario_id === req.nwUser.userId;
const ehDono = await isDono(pool, dent.clinica_id, req.nwUser.userId);
if (!ehProprio && !ehDono)
return res.status(403).json({ error: 'Só o próprio dentista (ou o dono) pode configurar estes horários.' });
const linhas = Array.isArray(req.body?.horarios) ? req.body.horarios : [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM dentistas_horarios WHERE dentista_id = $1', [dentistaId]);
for (const h of linhas) {
const dow = Number(h.dia_semana); const ini = hhmm(h.hora_inicio); const fim = hhmm(h.hora_fim);
if (!(dow >= 0 && dow <= 6) || !ini || !fim || ini >= fim) continue;
// Janela estendida (zona cinza): flex_inicio ≤ inicio e flex_fim ≥ fim.
let fIni = hhmm(h.hora_inicio_flex), fFim = hhmm(h.hora_fim_flex);
if (fIni && fIni >= ini) fIni = null; // só vale se for MAIS cedo
if (fFim && fFim <= fim) fFim = null; // só vale se for MAIS tarde
await client.query(
`INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, hora_inicio_flex, hora_fim_flex, ativo)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,1)`, [uid('dh'), dentistaId, dent.clinica_id, dow, ini, fim, fIni, fFim]);
}
await client.query('COMMIT');
res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// ── Folgas/férias do DENTISTA por data (o próprio dentista ou o dono) ─────────
async function guardDentista(pool, dentistaId, user) {
const { rows } = await pool.query('SELECT clinica_id, usuario_id FROM dentistas WHERE id = $1', [dentistaId]);
const dent = rows[0];
if (!dent) return { ok: false, code: 404, msg: 'Dentista não encontrado.' };
const ehProprio = dent.usuario_id && dent.usuario_id === user.userId;
const ehDono = await isDono(pool, dent.clinica_id, user.userId);
if (!ehProprio && !ehDono) return { ok: false, code: 403, msg: 'Só o próprio dentista (ou o dono) pode alterar.' };
return { ok: true, dent };
}
router.get('/dentista/:dentistaId/folgas', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, to_char(data_inicio,'YYYY-MM-DD') AS data_inicio, to_char(data_fim,'YYYY-MM-DD') AS data_fim, motivo
FROM dentistas_folgas WHERE dentista_id = $1 AND data_fim >= CURRENT_DATE ORDER BY data_inicio`,
[req.params.dentistaId]);
res.json({ folgas: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Body: { data_inicio (YYYY-MM-DD), data_fim? (def = inicio), motivo? }
router.post('/dentista/:dentistaId/folgas', async (req, res) => {
const g = await guardDentista(pool, req.params.dentistaId, req.nwUser).catch((e) => ({ ok: false, code: 500, msg: e.message }));
if (!g.ok) return res.status(g.code).json({ error: g.msg });
const b = req.body || {};
if (!b.data_inicio) return res.status(400).json({ error: 'data_inicio obrigatória' });
const fim = b.data_fim && b.data_fim >= b.data_inicio ? b.data_fim : b.data_inicio;
try {
const { rows } = await pool.query(
`INSERT INTO dentistas_folgas (id, dentista_id, clinica_id, data_inicio, data_fim, motivo)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING id, to_char(data_inicio,'YYYY-MM-DD') AS data_inicio, to_char(data_fim,'YYYY-MM-DD') AS data_fim, motivo`,
[uid('fol'), req.params.dentistaId, g.dent.clinica_id, b.data_inicio, fim, b.motivo || null]);
res.status(201).json(rows[0]);
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/dentista/:dentistaId/folgas/:id', async (req, res) => {
const g = await guardDentista(pool, req.params.dentistaId, req.nwUser).catch((e) => ({ ok: false, code: 500, msg: e.message }));
if (!g.ok) return res.status(g.code).json({ error: g.msg });
try { await pool.query('DELETE FROM dentistas_folgas WHERE id = $1 AND dentista_id = $2', [req.params.id, req.params.dentistaId]); res.json({ ok: true }); }
catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Feriados/fechamentos (dono) ───────────────────────────────────────────────
// Lista os NACIONAIS (computados) + as exceções da clínica de um ano.
router.get('/clinica/:clinicaId/feriados', async (req, res) => {
const clinicaId = req.params.clinicaId;
const ano = Number(req.query.ano) || new Date().getFullYear();
try {
const nac = Object.entries(feriadosNacionais(ano)).map(([data, nome]) => ({ data, nome, origem: 'nacional' }));
const { rows: exc } = await pool.query(
`SELECT id, to_char(data,'YYYY-MM-DD') AS data, nome, fecha,
to_char(hora_inicio,'HH24:MI') AS hora_inicio, to_char(hora_fim,'HH24:MI') AS hora_fim, recorrente
FROM feriados WHERE clinica_id = $1 AND (extract(year from data) = $2 OR recorrente = 1) ORDER BY data`,
[clinicaId, ano]);
res.json({ nacionais: nac, excecoes: exc.map((e) => ({ ...e, origem: 'clinica' })) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Cria uma exceção da clínica (fechamento/ponte/horário especial). Dono.
// Body: { data (YYYY-MM-DD), nome, fecha?(bool), hora_inicio?, hora_fim?, recorrente?(bool) }
router.post('/clinica/:clinicaId/feriados', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId)))
return res.status(403).json({ error: 'Apenas o dono pode configurar feriados/fechamentos.' });
const b = req.body || {};
if (!b.data || !b.nome) return res.status(400).json({ error: 'data e nome obrigatórios' });
const fecha = b.fecha === false ? 0 : 1;
const ini = fecha ? null : hhmm(b.hora_inicio);
const fim = fecha ? null : hhmm(b.hora_fim);
try {
const { rows } = await pool.query(
`INSERT INTO feriados (id, clinica_id, data, nome, fecha, hora_inicio, hora_fim, recorrente)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING id, to_char(data,'YYYY-MM-DD') AS data, nome, fecha, recorrente`,
[uid('fer'), clinicaId, b.data, b.nome, fecha, ini, fim, b.recorrente ? 1 : 0]);
res.status(201).json(rows[0]);
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/clinica/:clinicaId/feriados/:id', async (req, res) => {
if (!(await isDono(pool, req.params.clinicaId, req.nwUser.userId)))
return res.status(403).json({ error: 'Apenas o dono pode remover feriados/fechamentos.' });
try {
await pool.query('DELETE FROM feriados WHERE id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Situações / regras da clínica (dono) — a "área de situações" da Secretária ─
router.get('/clinica/:clinicaId/situacoes', async (req, res) => {
try {
const { rows } = await pool.query(`SELECT id, texto, ativo, ordem FROM agenda_situacoes WHERE clinica_id = $1 ORDER BY ordem, created_at`, [req.params.clinicaId]);
res.json({ situacoes: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Substitui toda a lista. Body: { situacoes: [texto] }
router.put('/clinica/:clinicaId/situacoes', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar as situações.' });
const linhas = Array.isArray(req.body?.situacoes) ? req.body.situacoes : [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM agenda_situacoes WHERE clinica_id = $1', [clinicaId]);
let i = 0;
for (const s of linhas) {
const t = String(s || '').trim(); if (!t) continue;
await client.query(`INSERT INTO agenda_situacoes (id, clinica_id, texto, ativo, ordem) VALUES ($1,$2,$3,1,$4)`, [uid('sit'), clinicaId, t, i++]);
}
await client.query('COMMIT'); res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// ── Checklist do DONO + pendências (só o dono do workspace) ───────────────────
// Conta dentistas/pacientes reais da clínica p/ conferir contra as respostas.
async function contagens(clinicaId) {
const q = async (sql) => { try { const { rows } = await pool.query(sql, [clinicaId]); return Number(rows[0]?.n || 0); } catch { return 0; } };
const dentistas = await q(`SELECT COUNT(*) n FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0`);
const pacientes = await q(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`);
return { dentistas, pacientes };
}
async function respostasChecklist(clinicaId) {
const map = {};
try { const { rows } = await pool.query('SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1', [clinicaId]); for (const r of rows) map[r.chave] = r.resposta == null ? null : Number(r.resposta); } catch { /* tabela nova */ }
return map;
}
router.get('/clinica/:clinicaId/checklist', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa o checklist.' });
try {
const map = await respostasChecklist(clinicaId);
res.json({ questoes: CHECKLIST_QUESTOES.map((q) => ({ ...q, resposta: map[q.chave] ?? null })) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Salva respostas. Body: { respostas: { chave: 1|0|null } }
router.put('/clinica/:clinicaId/checklist', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar o checklist.' });
const respostas = req.body?.respostas || {};
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const q of CHECKLIST_QUESTOES) {
if (!(q.chave in respostas)) continue;
const v = respostas[q.chave];
const r = v === 1 || v === true ? 1 : v === 0 || v === false ? 0 : null;
await client.query(
`INSERT INTO agenda_checklist (clinica_id, chave, resposta, updated_at) VALUES ($1,$2,$3,now())
ON CONFLICT (clinica_id, chave) DO UPDATE SET resposta = EXCLUDED.resposta, updated_at = now()`,
[clinicaId, q.chave, r]);
}
await client.query('COMMIT'); res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// Área de pendências do DONO — engloba TUDO: pendências de config (checklist ×
// banco) + a fila operacional (pedidos de horário + dúvidas aguardando humano).
router.get('/clinica/:clinicaId/pendencias', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa as pendências.' });
try {
const counts = await contagens(clinicaId);
const respostas = await respostasChecklist(clinicaId);
const config = pendenciasDeConfig(respostas, counts);
let horarios = 0, duvidas = 0;
try {
const { rows } = await pool.query(
`SELECT COALESCE(tipo,'horario') tipo, COUNT(*) n FROM agenda_pedidos WHERE clinica_id = $1 AND status = 'pendente' GROUP BY 1`, [clinicaId]);
for (const r of rows) { if (r.tipo === 'duvida') duvidas = Number(r.n); else horarios = Number(r.n); }
} catch { /* tabela nova */ }
res.json({
contagens: counts,
config,
operacionais: { horarios, duvidas, total: horarios + duvidas },
total: config.length + horarios + duvidas,
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Pedidos pendentes (zona cinza) — a secretária humana confirma/recusa ──────
async function ehMembro(clinicaId, userId) {
if (await isDono(pool, clinicaId, userId)) return true;
try { const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2 LIMIT 1', [clinicaId, userId]); return rows.length > 0; }
catch { return false; }
}
router.get('/clinica/:clinicaId/pedidos', async (req, res) => {
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
const status = req.query.status ? String(req.query.status) : 'pendente';
try {
const { rows } = await pool.query(
`SELECT p.id, COALESCE(p.tipo,'horario') AS tipo, p.resumo,
to_char(p.data,'YYYY-MM-DD') AS data, to_char(p.hora_inicio,'HH24:MI') AS hora_inicio,
to_char(p.hora_fim,'HH24:MI') AS hora_fim, p.paciente_nome, p.paciente_telefone, p.procedimento,
p.dentista_id, d.nome AS dentista_nome, p.status, p.created_at
FROM agenda_pedidos p LEFT JOIN dentistas d ON d.id = p.dentista_id
WHERE p.clinica_id = $1 AND p.status = $2 ORDER BY p.created_at DESC LIMIT 100`,
[req.params.clinicaId, status]);
res.json({ pedidos: rows, total: rows.length });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Confirma → cria o agendamento real e marca o pedido como confirmado.
// Body: { dentista_id? (se o pedido não tinha), duracao_min? }
router.post('/clinica/:clinicaId/pedidos/:id/confirmar', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await ehMembro(clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query('SELECT * FROM agenda_pedidos WHERE id = $1 AND clinica_id = $2 FOR UPDATE', [req.params.id, clinicaId]);
const ped = rows[0];
if (!ped) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Pedido não encontrado.' }); }
if (ped.status !== 'pendente') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Pedido já resolvido.' }); }
const dentistaId = req.body?.dentista_id || ped.dentista_id;
if (!dentistaId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Escolha o dentista para confirmar.' }); }
const dur = Math.max(10, Number(req.body?.duracao_min) || 30);
const hi = String(ped.hora_inicio).slice(0, 5);
const start = `${(ped.data instanceof Date ? ped.data.toISOString().slice(0, 10) : String(ped.data).slice(0, 10))} ${hi}:00`;
const endD = new Date(`${start.replace(' ', 'T')}`); endD.setMinutes(endD.getMinutes() + dur);
const end = `${start.slice(0, 10)} ${String(endD.getHours()).padStart(2, '0')}:${String(endD.getMinutes()).padStart(2, '0')}:00`;
const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id = $1', [dentistaId]);
const agId = `ag_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await client.query(
`INSERT INTO agendamentos (id, clinica_id, pacientenome, pacientecelular, dentistaid, procedimento,
start_time, end_time, status, setor_id, sala_id, created_by_nome, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'agendado',$9,$10,'Secretária (confirmado)',NOW(),NOW())`,
[agId, clinicaId, ped.paciente_nome || 'Cliente WhatsApp', ped.paciente_telefone || null, dentistaId,
ped.procedimento || 'Consulta', start, end, dd[0]?.setor_id || null, ped.sala_id || null]);
await client.query(`UPDATE agenda_pedidos SET status='confirmado', agendamento_id=$1, resolved_at=NOW(), resolved_by=$2 WHERE id=$3`,
[agId, req.nwUser.userId, req.params.id]);
await client.query('COMMIT');
res.json({ ok: true, agendamento_id: agId });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// Resolve uma DÚVIDA (tipo='duvida') sem criar agendamento — a humana já
// decidiu/atendeu (ou vai agendar manualmente uma avaliação).
router.post('/clinica/:clinicaId/pedidos/:id/resolver', async (req, res) => {
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
try {
await pool.query(`UPDATE agenda_pedidos SET status='resolvido', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/clinica/:clinicaId/pedidos/:id/recusar', async (req, res) => {
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
try {
await pool.query(`UPDATE agenda_pedidos SET status='recusado', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Lista dentistas da clínica (p/ a UI do dono escolher quem configurar / e o
// dentista achar seu registro). Retorna também se o usuário logado é o dentista.
router.get('/clinica/:clinicaId/dentistas', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, nome, usuario_id, ativo FROM dentistas WHERE clinica_id = $1 ORDER BY ordem NULLS LAST, nome`,
[req.params.clinicaId]);
res.json({ dentistas: rows.map((d) => ({ ...d, eh_voce: d.usuario_id === req.nwUser.userId })) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
return router;
}
+118
View File
@@ -0,0 +1,118 @@
// Schema (idempotente) das tabelas de horário/feriado usadas pela Secretária.
// Chamado uma vez no registerNewwhats. Espelha os tipos de `dentistas_horarios`.
//
// clinicas_horarios — horário de FUNCIONAMENTO da clínica (config do DONO).
// feriados — EXCEÇÕES da clínica (fechamento/ponte/horário especial);
// os feriados NACIONAIS são computados (ver feriados.js).
export async function ensureAgendaSchema(pool) {
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS clinicas_horarios (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
dia_semana integer NOT NULL, -- 0=domingo … 6=sábado
hora_inicio time without time zone NOT NULL,
hora_fim time without time zone NOT NULL,
ativo smallint DEFAULT 1
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_clinicas_horarios_clinica ON clinicas_horarios (clinica_id)`);
await pool.query(`
CREATE TABLE IF NOT EXISTS feriados (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
data date NOT NULL,
nome varchar NOT NULL,
fecha smallint DEFAULT 1, -- 1=fechado o dia; 0=abre em horário especial
hora_inicio time without time zone, -- horário especial (quando fecha=0)
hora_fim time without time zone,
recorrente smallint DEFAULT 0, -- 1=repete todo ano (mesmo dia/mês)
created_at timestamptz DEFAULT now()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_feriados_clinica_data ON feriados (clinica_id, data)`);
// ── dentistas_folgas — folga/férias/indisponibilidade por DATA do dentista.
// O dentista (ou o dono) marca dias em que NÃO atende; o /slots pula.
await pool.query(`
CREATE TABLE IF NOT EXISTS dentistas_folgas (
id varchar PRIMARY KEY,
dentista_id varchar NOT NULL,
clinica_id varchar,
data_inicio date NOT NULL,
data_fim date NOT NULL, -- inclusive; = data_inicio p/ 1 dia
motivo varchar,
created_at timestamptz DEFAULT now()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_dentistas_folgas_dent ON dentistas_folgas (dentista_id, data_inicio, data_fim)`);
// ── B (salas): agendamentos.sala_id — permite agendar num quarto alugado.
// Aditivo/nullable: NÃO afeta o fluxo existente (clínica) quando nulo.
await pool.query(`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS sala_id varchar`);
// ── Janela ESTENDIDA do dentista (zona cinza): a IA agenda livre em
// [hora_inicio, hora_fim]; entre a estendida e a livre exige confirmação
// humana. Nulo = sem zona cinza (só a janela livre).
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_inicio_flex time without time zone`);
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_fim_flex time without time zone`);
// ── agenda_pedidos — pedidos da ZONA CINZA aguardando a secretária humana.
// A IA cria (status 'pendente') e diz "vou confirmar"; a humana confirma
// (vira agendamento) ou recusa.
await pool.query(`
CREATE TABLE IF NOT EXISTS agenda_pedidos (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
dentista_id varchar,
sala_id varchar,
data date NOT NULL,
hora_inicio time without time zone NOT NULL,
hora_fim time without time zone,
paciente_nome varchar,
paciente_telefone varchar,
procedimento varchar,
origem varchar DEFAULT 'secretaria_ia',
status varchar DEFAULT 'pendente', -- pendente | confirmado | recusado
agendamento_id varchar, -- preenchido ao confirmar
created_at timestamptz DEFAULT now(),
resolved_at timestamptz,
resolved_by varchar
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_pedidos_clinica_status ON agenda_pedidos (clinica_id, status, created_at)`);
// Pedido também serve para DÚVIDAS (sem horário): tipo + resumo; data/hora nullable.
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS tipo varchar DEFAULT 'horario'`); // horario | duvida
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS resumo text`);
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN data DROP NOT NULL`).catch(() => {});
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN hora_inicio DROP NOT NULL`).catch(() => {});
// ── agenda_situacoes — "área de situações": regras da clínica que a Secretária
// IA deve seguir (ex.: "plano X não cobre canal posterior — ofereça avaliação").
await pool.query(`
CREATE TABLE IF NOT EXISTS agenda_situacoes (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
texto text NOT NULL,
ativo smallint DEFAULT 1,
ordem integer DEFAULT 0,
created_at timestamptz DEFAULT now()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_situacoes_clinica ON agenda_situacoes (clinica_id)`);
// ── agenda_checklist — respostas Sim/Não do DONO sobre a clínica (ver
// checklist-defs.js). Conferidas contra o banco → geram pendências de config
// e deixam a Secretária cautelosa. resposta: 1=sim, 0=não, NULL=não respondido.
await pool.query(`
CREATE TABLE IF NOT EXISTS agenda_checklist (
clinica_id varchar NOT NULL,
chave varchar NOT NULL,
resposta smallint,
updated_at timestamptz DEFAULT now(),
PRIMARY KEY (clinica_id, chave)
)`);
return true;
} catch (e) {
// Não derruba o boot do satélite se o banco estiver indisponível no momento.
console.error('[newwhats] ensureAgendaSchema falhou:', e.message);
return false;
}
}
+25
View File
@@ -0,0 +1,25 @@
// Middleware de auth das rotas /nw/* — valida a integration_key enviada pelo
// motor NewWhats. Aceita header x-nw-key ou Authorization: Bearer <key>.
import { timingSafeEqual } from 'crypto'
import { getConfig } from './config.js'
export function nwAuth(req, res, next) {
const key = req.headers['x-nw-key']
|| (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim()
if (!key) {
return res.status(401).json({ error: 'Chave de integração ausente. Use o header x-nw-key.' })
}
const stored = getConfig().integrationKey
if (!stored) {
return res.status(503).json({ error: 'Integração NewWhats não configurada (NEWWHATS_INTEGRATION_KEY).' })
}
const a = Buffer.from(stored)
const b = Buffer.from(key)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(403).json({ error: 'Chave de integração inválida.' })
}
next()
}
+99
View File
@@ -0,0 +1,99 @@
// Checklist do DONO do workspace: perguntas Sim/Não sobre a clínica que a
// Secretária IA usa para saber quando PODE agendar sozinha e quando deve ter
// cautela. As respostas são conferidas contra o banco (dentistas/pacientes):
// quando a declaração do dono não bate com os dados, geramos uma PENDÊNCIA
// para ele configurar/cadastrar — e a IA fica cautelosa (encaminha ao humano).
//
// Fonte única: config (UI), consistência (pendências) e injeção no cérebro
// (cautelas) consomem esta mesma definição para nunca divergirem.
export const CHECKLIST_QUESTOES = [
{
chave: 'multi_dentista',
pergunta: 'A clínica tem mais de um dentista?',
ajuda: 'Se sim, a Secretária não pode assumir um único dentista nem "clínico geral" por padrão — precisa saber quem atende cada paciente.',
},
{
chave: 'pacientes_cadastrados',
pergunta: 'Os pacientes já estão cadastrados no sistema (/pacientes)?',
ajuda: 'Mesmo que sim, o cadastro pode estar INCOMPLETO (nem todos os familiares). A Secretária confirma a identidade antes de agendar e encaminha ao humano quando não reconhece quem fala.',
},
{
chave: 'telefone_compartilhado',
pergunta: 'Um mesmo telefone pode ser de vários pacientes (família)?',
ajuda: 'Ex.: o número está no cadastro do filho, mas quem manda mensagem é a mãe. Se sim, a Secretária SEMPRE confirma com quem está falando e para quem é o atendimento — nunca assume pelo número.',
},
{
chave: 'dentista_fixo',
pergunta: 'Cada paciente tem um dentista de referência (fixo)?',
ajuda: 'Se sim, um paciente de retorno deve voltar para o MESMO dentista — a IA não deve agendá-lo no clínico geral.',
},
];
const sim = (r, k) => r[k] === 1;
const nao = (r, k) => r[k] === 0;
// Pendências de CONFIG do dono: a declaração não bate com o banco.
// counts = { dentistas, pacientes }.
export function pendenciasDeConfig(respostas, counts) {
const r = respostas || {};
const dentistas = Number(counts?.dentistas || 0);
const pacientes = Number(counts?.pacientes || 0);
const P = [];
if (sim(r, 'multi_dentista') && dentistas <= 1)
P.push({ chave: 'multi_dentista', severidade: 'alta', titulo: 'Cadastre os demais dentistas',
detalhe: `Você indicou que a clínica tem mais de um dentista, mas há apenas ${dentistas} cadastrado. A Secretária não consegue direcionar cada paciente ao dentista certo — cadastre os outros dentistas.` });
if (nao(r, 'multi_dentista') && dentistas > 1)
P.push({ chave: 'multi_dentista', severidade: 'media', titulo: 'Confirme quantos dentistas atendem',
detalhe: `Você indicou um único dentista, mas há ${dentistas} cadastrados no sistema. Ajuste a resposta ou os cadastros.` });
if (sim(r, 'pacientes_cadastrados') && pacientes === 0)
P.push({ chave: 'pacientes_cadastrados', severidade: 'alta', titulo: 'Cadastre os pacientes',
detalhe: 'Você indicou que os pacientes estão cadastrados, mas não há nenhum paciente no sistema. A Secretária não reconhece quem já é paciente.' });
if (r['pacientes_cadastrados'] == null || nao(r, 'pacientes_cadastrados'))
P.push({ chave: 'pacientes_cadastrados', severidade: 'media', titulo: 'Complete o cadastro de pacientes',
detalhe: 'Enquanto os pacientes não estiverem cadastrados, a Secretária vai encaminhar todo retorno para a secretária humana confirmar (não agenda sozinha).' });
if (sim(r, 'dentista_fixo') && dentistas <= 1 && sim(r, 'multi_dentista'))
P.push({ chave: 'dentista_fixo', severidade: 'media', titulo: 'Cadastre os dentistas de referência',
detalhe: 'Você indicou que cada paciente tem um dentista fixo, mas o sistema ainda não tem os dentistas todos cadastrados para fazer esse vínculo.' });
if (sim(r, 'telefone_compartilhado'))
P.push({ chave: 'telefone_compartilhado', severidade: 'media', titulo: 'Complete o cadastro de cada familiar',
detalhe: 'Como um mesmo telefone pode ser de vários pacientes (ex.: cadastrado só no filho, mas quem fala é a mãe), a Secretária vai SEMPRE confirmar quem está falando e não assume pelo número. Mantenha cada familiar cadastrado e vinculado ao número para ela acertar mais e encaminhar menos ao humano.' });
return P;
}
// Cautelas para injetar no cérebro da IA (texto direto no system prompt).
export function cautelasParaIA(respostas, counts) {
const r = respostas || {};
const dentistas = Number(counts?.dentistas || 0);
const pacientes = Number(counts?.pacientes || 0);
const C = [];
const multi = sim(r, 'multi_dentista') || dentistas > 1;
const temPacientes = pacientes > 0 || sim(r, 'pacientes_cadastrados');
const telShared = sim(r, 'telefone_compartilhado') || (temPacientes && r['telefone_compartilhado'] == null);
if (multi)
C.push('A clínica tem MAIS DE UM dentista: NUNCA assuma um dentista nem "clínico geral" por padrão para quem já é paciente. Descubra/pergunte qual dentista atende. Sem certeza → encaminhe ao humano.');
if (sim(r, 'multi_dentista') && dentistas <= 1)
C.push('ATENÇÃO: o sistema ainda não tem todos os dentistas cadastrados. Para pacientes de retorno ou que pedem um dentista específico, encaminhe ao humano — NÃO agende no clínico geral.');
if (sim(r, 'dentista_fixo'))
C.push('Cada paciente tem um dentista fixo: um retorno deve voltar ao MESMO dentista. Se você não sabe qual é (falta cadastro), encaminhe ao humano.');
// Identidade de quem fala — o cadastro é incompleto e o telefone é ambíguo.
if (temPacientes)
C.push('IDENTIDADE: o cadastro pode estar INCOMPLETO. Ao achar um paciente pelo telefone, NÃO assuma que é quem está falando — confirme o NOME de quem fala e PARA QUEM é o atendimento antes de qualquer coisa.');
if (telShared)
C.push('TELEFONE COMPARTILHADO: um mesmo número pode ser de vários da família (ex.: está no cadastro do filho, mas quem fala é a mãe; ou perguntam pelo tratamento do marido). SEMPRE pergunte com quem você fala e de quem é o atendimento; se a pessoa/atendimento não bater com o cadastro do número, encaminhe ao humano — não agende no paciente errado.');
if (pacientes === 0 || nao(r, 'pacientes_cadastrados'))
C.push('O cadastro de pacientes está incompleto: você NÃO consegue confirmar histórico nem o dentista de quem diz já ser paciente. Nesses casos, encaminhe ao humano em vez de agendar.');
// Profissional citado que não existe na lista de dentistas (sempre possível).
C.push('PROFISSIONAL NÃO CADASTRADO: se o cliente citar um dentista/profissional que NÃO está na lista de dentistas acima (ex.: prótese com um Dr. de fora), NÃO invente nem agende — encaminhe ao humano com encaminhar_humano e diga NO RESUMO que o profissional citado não está cadastrado, para o dono cadastrá-lo.');
return C;
}
+64
View File
@@ -0,0 +1,64 @@
// Config da integração com o motor NewWhats.
// Fonte: tabela `settings` (id='newwhats_config') — editável pelo super-admin na UI.
// Fallback: variáveis de ambiente (NEWWHATS_*), úteis para bootstrap/deploy.
//
// NEWWHATS_URL / motorUrl URL do motor
// NEWWHATS_INTEGRATION_KEY / integrationKey x-nw-key
// NEWWHATS_WEBHOOK_SECRET / webhookSecret HMAC do webhook
// NEWWHATS_CLINICA_ID / clinicaId clínica que este satélite atende
let cache = null; // { motorUrl, integrationKey, webhookSecret, clinicaId } | null
function fromEnv() {
return {
motorUrl: (process.env.NEWWHATS_URL || '').replace(/\/$/, ''),
integrationKey: process.env.NEWWHATS_INTEGRATION_KEY || '',
webhookSecret: process.env.NEWWHATS_WEBHOOK_SECRET || '',
clinicaId: process.env.NEWWHATS_CLINICA_ID || '',
};
}
// DB (se houver valor) tem prioridade; ENV preenche o que faltar.
export function getConfig() {
const env = fromEnv();
if (!cache) return env;
return {
motorUrl: (cache.motorUrl || env.motorUrl || '').replace(/\/$/, ''),
integrationKey: cache.integrationKey || env.integrationKey || '',
webhookSecret: cache.webhookSecret || env.webhookSecret || '',
clinicaId: cache.clinicaId || env.clinicaId || '',
};
}
export function isConfigured() {
const c = getConfig();
return Boolean(c.motorUrl && c.integrationKey);
}
// Carrega a config do banco para o cache (chamado no boot e após salvar).
export async function loadConfigFromDb(pool) {
try {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'newwhats_config'");
cache = rows[0]?.data || {};
} catch {
if (!cache) cache = {};
}
return getConfig();
}
// Persiste no banco e atualiza o cache.
export async function saveConfigToDb(pool, cfg) {
const data = {
motorUrl: (cfg.motorUrl || '').replace(/\/$/, ''),
integrationKey: cfg.integrationKey || '',
webhookSecret: cfg.webhookSecret || '',
clinicaId: cfg.clinicaId || '',
};
await pool.query(
`INSERT INTO settings (id, category, data) VALUES ('newwhats_config', 'integration', $1)
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data`,
[JSON.stringify(data)]
);
cache = data;
return getConfig();
}
+92
View File
@@ -0,0 +1,92 @@
// Feriados nacionais brasileiros (fixos + móveis) + resolução com exceções da
// clínica (tabela `feriados`). Usado pela agenda-bridge para a Secretária saber
// quando é feriado e o /slots pular esses dias.
//
// Fixos: datas nacionais. Móveis: derivados da Páscoa (Computus de Meeus).
// Consciência Negra (20/11) é nacional desde 2024 (Lei 14.759/2023).
const pad = (n) => String(n).padStart(2, '0');
const ymd = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;
// Domingo de Páscoa (algoritmo de Meeus/Jones/Butcher) — retorna {mes, dia}.
function pascoa(ano) {
const a = ano % 19;
const b = Math.floor(ano / 100);
const c = ano % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const mes = Math.floor((h + l - 7 * m + 114) / 31);
const dia = ((h + l - 7 * m + 114) % 31) + 1;
return { mes, dia };
}
// Soma dias a uma data (UTC, evita fuso) → 'YYYY-MM-DD'.
function addDias(y, m, d, delta) {
const base = new Date(Date.UTC(y, m - 1, d));
base.setUTCDate(base.getUTCDate() + delta);
return ymd(base.getUTCFullYear(), base.getUTCMonth() + 1, base.getUTCDate());
}
// Mapa { 'YYYY-MM-DD': 'Nome do feriado' } dos feriados NACIONAIS do ano.
export function feriadosNacionais(ano) {
const p = pascoa(ano);
const py = ano, pm = p.mes, pd = p.dia;
const map = {
[ymd(ano, 1, 1)]: 'Confraternização Universal',
[ymd(ano, 4, 21)]: 'Tiradentes',
[ymd(ano, 5, 1)]: 'Dia do Trabalho',
[ymd(ano, 9, 7)]: 'Independência do Brasil',
[ymd(ano, 10, 12)]: 'Nossa Senhora Aparecida',
[ymd(ano, 11, 2)]: 'Finados',
[ymd(ano, 11, 15)]: 'Proclamação da República',
[ymd(ano, 11, 20)]: 'Consciência Negra',
[ymd(ano, 12, 25)]: 'Natal',
// Móveis (relativos ao Domingo de Páscoa)
[addDias(py, pm, pd, -48)]: 'Carnaval (segunda-feira)',
[addDias(py, pm, pd, -47)]: 'Carnaval (terça-feira)',
[addDias(py, pm, pd, -2)]: 'Sexta-feira Santa',
[addDias(py, pm, pd, 60)]: 'Corpus Christi',
};
return map;
}
// Nome do feriado nacional numa data 'YYYY-MM-DD', ou null.
export function feriadoNacionalNome(dateStr) {
const ano = Number(String(dateStr).slice(0, 4));
if (!ano) return null;
return feriadosNacionais(ano)[dateStr] ?? null;
}
// Resolve o feriado de uma data para uma clínica: nacional OU exceção da clínica
// (tabela `feriados`, que também pode ANULAR um dia — ver `fecha`). Retorna
// { fecha: bool, nome, hora_inicio?, hora_fim? } ou null (dia normal).
//
// Regras:
// - Exceção da clínica tem prioridade (permite abrir num feriado nacional, ou
// fechar num dia comum, ou definir horário especial).
// - Sem exceção: cai no feriado nacional (fecha o dia inteiro).
export async function resolverFeriado(pool, clinicaId, dateStr) {
try {
const mmdd = String(dateStr).slice(5); // 'MM-DD'
const { rows } = await pool.query(
`SELECT nome, fecha, hora_inicio, hora_fim FROM feriados
WHERE clinica_id = $1
AND (data = $2 OR (recorrente = 1 AND to_char(data,'MM-DD') = $3))
ORDER BY (data = $2) DESC LIMIT 1`,
[clinicaId, dateStr, mmdd]);
if (rows.length) {
const r = rows[0];
return { fecha: r.fecha !== false, nome: r.nome || 'Fechamento', hora_inicio: r.hora_inicio || null, hora_fim: r.hora_fim || null, origem: 'clinica' };
}
} catch { /* tabela pode não existir ainda */ }
const nac = feriadoNacionalNome(dateStr);
if (nac) return { fecha: true, nome: nac, hora_inicio: null, hora_fim: null, origem: 'nacional' };
return null;
}
+182
View File
@@ -0,0 +1,182 @@
// Integração NewWhats (satélite) para o scoreodonto — ESM, adaptada do mercado.
// Registra rotas no app Express existente.
//
// Uso no server.js:
// import { registerNewwhats } from './newwhats/index.js'
// registerNewwhats(app, pool, { superadminGuard })
import { createRoutes } from './routes.js'
import { createWebhookReceiver } from './webhook-receiver.js'
import { createRestProxy, createAccountsRoute, provisionNewwhatsAccount } from './proxy.js'
import { getConfig, isConfigured, loadConfigFromDb, saveConfigToDb } from './config.js'
import { Readable } from 'node:stream'
export { syncKnowledge, buildKnowledgeText } from './sync-knowledge.js'
export { createWsTunnel } from './proxy.js'
// Provisiona (eager) a conta do email no motor NewWhats, best-effort e idempotente.
// Chamado no cadastro de usuário/clínica do scoreodonto: "assim que a conta nasce
// aqui, já nasce lá — pronta esperando quando a pessoa for mexer com WhatsApp".
export async function provisionNewwhatsAccountEager(pool, email) {
try {
const cfg = getConfig()
if (!cfg.motorUrl || !cfg.integrationKey || !email) return false
return await provisionNewwhatsAccount(pool, cfg, email)
} catch { return false }
}
import { createAgendaBridge } from './agenda-bridge.js'
import { createAgendaConfig } from './agenda-config.js'
import { ensureAgendaSchema } from './agenda-schema.js'
// Valida uma chave contra o motor. Envia um email-sonda (a chave mestra exige
// x-nw-email). 200 = ok com dados; 404 = chave válida mas email-sonda inexistente
// (= chave OK); 401 = chave inválida.
async function testMotorKey(motorUrl, key) {
try {
const r = await fetch(`${motorUrl}/api/ext/v1/sessions`, {
headers: { 'x-nw-key': key, 'x-nw-email': 'probe@nw.local' },
});
if (r.ok || r.status === 404) return { ok: true, detail: 'Conexão OK' };
return { ok: false, detail: `Motor respondeu ${r.status}` };
} catch (e) {
return { ok: false, detail: `Motor inacessível: ${e.message}` };
}
}
export function registerNewwhats(app, pool, opts = {}) {
// passthrough middleware caso o guard não seja fornecido (dev)
const superadminGuard = opts.superadminGuard || ((_req, _res, next) => next());
// ── Ponte de agenda (motor → agenda real do scoreodonto) ───────────────────
// Auth própria por segredo compartilhado (x-nw-agenda-secret). Server-to-server.
app.use('/api/nw/agenda', createAgendaBridge(pool));
// ── Schema de horários/feriados (idempotente) + config (dono/dentista) ─────
// clinicas_horarios + feriados; usados pela agenda-bridge (Secretária) e pelas
// telas de configuração do scoreodonto.
ensureAgendaSchema(pool).catch((e) => console.error('[newwhats] schema agenda:', e?.message));
app.use('/api/nw/agenda-config', createAgendaConfig(pool));
// ── Proxy de MÍDIA: /api/nw/media/* → motor ────────────────────────────────
// A mídia/avatar vivem no motor (Wasabi ou local). O Wasabi é servido por
// /api/storage/view/* (plugin UnifiedStorageProvider, que cobre Wasabi + fallback
// local); a mídia local ANTIGA fica no /media estático. Tentamos o storage do
// plugin primeiro e caímos no /media se der 404. Same-origin no <img>/<video>.
app.use('/api/nw/media', async (req, res) => {
if (req.method !== 'GET') { res.status(405).end(); return; }
const cfg = getConfig();
if (!cfg.motorUrl) { res.status(503).end(); return; }
const rest = req.path.replace(/^\//, '');
const base = cfg.motorUrl.replace(/\/$/, '');
const headers = {};
if (req.headers.range) headers.range = req.headers.range;
const candidates = [`${base}/api/storage/view/${rest}`, `${base}/media/${rest}`];
try {
let r = null;
for (const url of candidates) {
r = await fetch(url, { headers });
if (r.status !== 404) break; // achou (ou erro que não é 404) → usa esse
}
res.status(r.status);
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'cache-control', 'content-disposition']) {
const v = r.headers.get(h);
if (v) res.setHeader(h, v);
}
// Mídia é imutável (path único por arquivo) → cache longo no browser.
if (!res.getHeader('cache-control')) res.setHeader('cache-control', 'public, max-age=604800, immutable');
if (r.body) Readable.fromWeb(r.body).pipe(res);
else res.end();
} catch (e) {
res.status(502).json({ error: `Mídia indisponível: ${e.message}` });
}
});
// Carrega a config do banco para o cache (não bloqueia o boot).
loadConfigFromDb(pool).then((c) =>
console.log(`[newwhats] config carregada — ${isConfigured() ? 'configurada' : 'AGUARDANDO config'} (clinica=${c.clinicaId || '—'})`)
).catch(() => {});
// ── Config (super-admin) ───────────────────────────────────────────────────
// GET: nunca devolve os segredos em claro — só se estão definidos.
app.get('/api/nw/config', superadminGuard, (_req, res) => {
const c = getConfig();
res.json({
motorUrl: c.motorUrl,
clinicaId: c.clinicaId,
hasIntegrationKey: Boolean(c.integrationKey),
hasWebhookSecret: Boolean(c.webhookSecret),
configured: isConfigured(),
});
});
// PUT: secrets em branco MANTÊM o valor atual (não precisa redigitar).
app.put('/api/nw/config', superadminGuard, async (req, res) => {
try {
const cur = getConfig();
const b = req.body || {};
const next = {
motorUrl: (b.motorUrl ?? cur.motorUrl) || '',
clinicaId: (b.clinicaId ?? cur.clinicaId) || '',
integrationKey: b.integrationKey ? String(b.integrationKey) : cur.integrationKey,
webhookSecret: b.webhookSecret ? String(b.webhookSecret) : cur.webhookSecret,
};
await saveConfigToDb(pool, next);
res.json({ ok: true, configured: isConfigured() });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Aplica um TOKEN de pareamento — auto-configura URL + chave + segredo do webhook.
// Token = "nwpair_" + base64url(JSON({ u: motorUrl, k: integrationKey, s: webhookSecret })).
// A clínica continua sendo escolhida localmente (o motor não conhece os IDs daqui).
app.post('/api/nw/config/token', superadminGuard, async (req, res) => {
try {
let raw = String((req.body || {}).token || '').trim();
raw = raw.replace(/^nwpair[_:]\s*/i, '');
let p;
try { p = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); }
catch { return res.status(400).json({ error: 'Token inválido (não foi possível decodificar).' }); }
const motorUrl = (p.u || p.url || '').replace(/\/$/, '');
const integrationKey = p.k || p.key || p.integrationKey || '';
const webhookSecret = p.s || p.secret || p.webhookSecret || '';
if (!motorUrl || !integrationKey) {
return res.status(400).json({ error: 'Token incompleto (faltam URL ou chave).' });
}
// Valida a chave contra o motor ANTES de salvar.
const { ok: motorOk, detail } = await testMotorKey(motorUrl, integrationKey);
if (!motorOk) return res.status(400).json({ error: `Token não validou no motor: ${detail}` });
const cur = getConfig();
await saveConfigToDb(pool, { motorUrl, integrationKey, webhookSecret: webhookSecret || cur.webhookSecret, clinicaId: cur.clinicaId });
res.json({ ok: true, configured: isConfigured(), motorUrl, hasWebhookSecret: Boolean(webhookSecret || cur.webhookSecret), detail });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Lista de clínicas para o dropdown.
app.get('/api/nw/clinicas', superadminGuard, async (_req, res) => {
try {
const { rows } = await pool.query('SELECT id, nome_fantasia FROM clinicas ORDER BY nome_fantasia');
res.json(rows);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Testa a conexão com o motor (usa a config atual).
app.get('/api/nw/status', superadminGuard, async (_req, res) => {
const c = getConfig();
if (!c.motorUrl || !c.integrationKey) return res.json({ configured: false, motorOk: false, detail: 'URL ou chave ausente' });
const { ok, detail } = await testMotorKey(c.motorUrl, c.integrationKey);
res.json({ configured: true, motorOk: ok, detail });
});
// ── Operação ────────────────────────────────────────────────────────────────
app.use('/nw', createRoutes(pool)); // Secretária do motor consulta a clínica
app.use(createWebhookReceiver(pool)); // webhook motor → satélite
app.get('/api/nw/accounts', createAccountsRoute(pool)); // seletor multi-conta da inbox (próprias + liberadas) — ANTES do catch-all
app.use('/api/nw/v1', createRestProxy(pool)); // proxy do inbox → motor (express 5: use, não wildcard) + enforcement de ownership
console.log('[newwhats] integração registrada (/nw, /api/webhook/newwhats, /api/nw/v1/*, /api/nw/config)');
}
+21
View File
@@ -0,0 +1,21 @@
{
"id": "newwhats",
"name": "NewWhats — Integração WhatsApp (Odonto)",
"version": "1.0.0",
"project": "scoreodonto.com",
"description": "Satélite do motor NewWhats adaptado ao domínio odontológico: expõe /nw/* (clínica, especialidades, dentistas, procedimentos, planos) para a Secretária IA, recebe webhooks do motor e faz proxy do inbox. Config por variáveis de ambiente (ESM, sem tabela plugin_configs).",
"runtime": "esm-module",
"entry": "index.js#registerNewwhats",
"features": [
"Rotas /nw/* (dados da clínica para a Secretária IA)",
"sync-knowledge (base de conhecimento odonto → motor)",
"Webhook receiver (message.new, session.status) com HMAC",
"Proxy REST /api/nw/v1/* → motor /api/ext/v1/*"
],
"config_env": [
{ "key": "NEWWHATS_URL", "required": true, "help": "URL do motor NewWhats." },
{ "key": "NEWWHATS_INTEGRATION_KEY", "required": true, "help": "x-nw-key: valida /nw/* e é injetada no proxy. Igual à apiKey criada no motor." },
{ "key": "NEWWHATS_WEBHOOK_SECRET", "required": false, "help": "Segredo HMAC para validar x-nw-signature nos webhooks. Recomendado." },
{ "key": "NEWWHATS_CLINICA_ID", "required": true, "help": "Qual clínica este satélite atende (multi-tenant)." }
]
}
+466
View File
@@ -0,0 +1,466 @@
// Proxy REST: /api/nw/v1/* (frontend) → motor /api/ext/v1/*.
//
// Segurança: valida o JWT do scoreodonto (só usuário logado acessa).
// Resolução de conta: SEMPRE via x-nw-email (chave mestra resolve o tenant pelo
// email). Todo usuário da clínica tem conta própria no motor — se o motor não
// conhecer o email, é erro (404 "conta não encontrada"), não há fallback.
// O email vem SEMPRE do token verificado — nunca do cliente.
import net from 'node:net';
import tls from 'node:tls';
import jwt from 'jsonwebtoken';
import { getConfig } from './config.js';
function authFromReq(req) {
try {
const tk = (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim();
if (!tk) return null;
const p = jwt.verify(tk, process.env.JWT_SECRET);
const email = (p.email || '').toLowerCase() || null;
if (!email) return null;
return { email, userId: p.userId || null };
} catch { return null; }
}
// Resolve o dono do workspace (clínica OU sala). Retorna o userId dono ou null.
async function resolveOwnerId(pool, clinicaId) {
if (!pool || !clinicaId) return null;
try {
const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
if (cl.length) {
if (cl[0].owner_id) return cl[0].owner_id;
// Clínicas antigas sem owner_id: dono = vínculo donoclinica/donoconsultorio.
const { rows: v } = await pool.query(
"SELECT usuario_id FROM vinculos WHERE clinica_id = $1 AND role IN ('donoclinica','donoconsultorio') ORDER BY id LIMIT 1",
[clinicaId]);
return v[0]?.usuario_id || null;
}
const { rows: sl } = await pool.query('SELECT owner_usuario_id FROM salas WHERE id = $1', [clinicaId]);
return sl[0]?.owner_usuario_id || null;
} catch { return null; }
}
// Resolve o dono do workspace COM e-mail. Retorna { ownerId, email } ou null.
// Usado para a inbox compartilhada: a caixa da clínica vive na conta (tenant) do
// dono no motor, então falamos com o motor usando o e-mail DELE.
async function resolveOwner(pool, clinicaId) {
const ownerId = await resolveOwnerId(pool, clinicaId);
if (!ownerId) return null;
try {
const { rows } = await pool.query('SELECT lower(email) AS email FROM usuarios WHERE id = $1', [ownerId]);
return { ownerId, email: rows[0]?.email || null };
} catch { return { ownerId, email: null }; }
}
// Autoriza (ou nega 403) uma ação sensível de sessão de WhatsApp.
// Mapeamento das ações do motor:
// POST /sessions → scan INICIAL (criar) → só o dono
// GET /sessions/:id/qr → re-scan (parear QR) → dono OU can_rescan
// POST /sessions/:id/connect → re-scan (reconectar) → dono OU can_rescan
// DELETE /sessions/:id → excluir → dono OU can_delete
// Retorna { ok: true } ou { ok: false, status, error }.
async function guardSessionAction(pool, req, tail, userId) {
const method = req.method.toUpperCase();
const m = tail.match(/^\/sessions(?:\/([^/?]+)(\/qr|\/connect)?)?/);
if (!m) return { ok: true }; // não é uma rota de sessão
const instanceId = m[1] || null;
const sub = m[2] || null;
const isCreate = method === 'POST' && !instanceId; // POST /sessions
const isRescan = (method === 'GET' && sub === '/qr') ||
(method === 'POST' && sub === '/connect'); // parear/reconectar
const isDelete = method === 'DELETE' && instanceId && !sub; // DELETE /sessions/:id
if (!isCreate && !isRescan && !isDelete) return { ok: true }; // GET lista / etc → livre
const clinicaId = req.headers['x-nw-clinica'] ? String(req.headers['x-nw-clinica']) : null;
if (!clinicaId) return { ok: false, status: 403, error: 'Workspace não identificado para esta ação.' };
const ownerId = await resolveOwnerId(pool, clinicaId);
if (ownerId && ownerId === userId) return { ok: true }; // dono: poder total
// Scan inicial é exclusivo do dono — não delegável.
if (isCreate) return { ok: false, status: 403, error: 'Apenas o dono pode conectar um novo WhatsApp.' };
// Re-scan / exclusão: exigem delegação por sessão.
if (!instanceId) return { ok: false, status: 403, error: 'Sessão não identificada.' };
let authz = null;
try {
const { rows } = await pool.query(
'SELECT can_rescan, can_delete FROM wa_session_authz WHERE clinica_id = $1 AND instance_id = $2 AND usuario_id = $3',
[clinicaId, instanceId, userId]);
authz = rows[0] || null;
} catch { /* nega por padrão */ }
if (isRescan && authz?.can_rescan) return { ok: true };
if (isDelete && authz?.can_delete) return { ok: true };
return {
ok: false, status: 403,
error: isDelete
? 'Somente o dono pode excluir esta sessão. Peça autorização de exclusão.'
: 'Somente o dono pode reconectar esta sessão. Peça autorização de re-scan.',
};
}
// Auto-provisiona (idempotente) a conta do email no motor, via chave mestra.
// Usado como self-heal quando o motor responde "Conta não encontrada".
export async function provisionNewwhatsAccount(pool, cfg, email) {
if (!cfg.motorUrl || !cfg.integrationKey || !email) return false;
let name = email;
try {
if (pool) {
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE lower(email) = $1', [email.toLowerCase()]);
if (rows[0]?.nome) name = rows[0].nome;
}
} catch { /* usa email como nome */ }
try {
const r = await fetch(`${cfg.motorUrl}/api/ext/v1/satellites/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integrationKey },
body: JSON.stringify({ email, name }),
});
return r.ok;
} catch { return false; }
}
// Monta a assinatura do operador para prefixar mensagens: primeiro nome; se houver
// outro membro do workspace com o MESMO primeiro nome, desambigua com "Nome I." (inicial
// do primeiro sobrenome). Retorna null se não houver nome.
export async function operatorSignature(pool, userId, clinicaId) {
if (!pool || !userId) return null;
try {
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [userId]);
const full = String(rows[0]?.nome || '').trim();
if (!full) return null;
const parts = full.split(/\s+/);
const first = parts[0];
let ambiguous = false;
if (clinicaId) {
const { rows: m } = await pool.query(
`SELECT u.nome FROM vinculos v JOIN usuarios u ON u.id = v.usuario_id
WHERE v.clinica_id = $1 AND u.id <> $2`, [clinicaId, userId]);
ambiguous = m.some(x => String(x.nome || '').trim().split(/\s+/)[0].toLowerCase() === first.toLowerCase());
}
if (ambiguous && parts.length > 1 && parts[1][0]) {
return `${first} ${parts[1][0].toUpperCase()}.`;
}
return first;
} catch { return null; }
}
// Gate de acesso a ÁREAS (Inbox / Secretária). Dono passa sempre; os demais só com
// delegação (can_inbox / can_secretaria) do dono em alguma sessão do workspace.
async function guardAreaAccess(pool, tail, userId, clinicaId) {
const isSecretaria = /^\/(secretaria|sec)(\/|$|\?)/.test(tail);
const isInbox = /^\/(inbox|conversations)(\/|$|\?)/.test(tail);
if (!isSecretaria && !isInbox) return { ok: true };
if (!pool || !clinicaId) return { ok: true }; // sem workspace resolvido, não barra aqui
const ownerId = await resolveOwnerId(pool, clinicaId);
if (ownerId && ownerId === userId) return { ok: true };
const col = isSecretaria ? 'can_secretaria' : 'can_inbox';
try {
const { rows } = await pool.query(
`SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND ${col} = true LIMIT 1`,
[clinicaId, userId]);
if (rows.length) return { ok: true };
} catch { /* nega por padrão */ }
return { ok: false, status: 403, error: isSecretaria ? 'Sem acesso à Secretária. Peça autorização ao dono.' : 'Sem acesso ao Inbox. Peça autorização ao dono.' };
}
// Gate de ações DESTRUTIVAS no inbox (apagar conversa / apagar mensagem).
// Só o dono ou quem tiver can_delete_msg. (Complementa o gate de acesso ao inbox.)
async function guardInboxDestructive(pool, method, tail, userId, clinicaId) {
const isDeleteChat = method === 'DELETE' && /^\/inbox\/[^/]+\/?(\?|$)/.test(tail);
const isClearMsgs = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/?(\?|$)/.test(tail); // limpar conversa (todas as msgs)
const isDeleteMsg = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/[^/]+/.test(tail);
if (!isDeleteChat && !isClearMsgs && !isDeleteMsg) return { ok: true };
if (!pool || !clinicaId) return { ok: true };
const ownerId = await resolveOwnerId(pool, clinicaId);
if (ownerId && ownerId === userId) return { ok: true };
try {
const { rows } = await pool.query(
'SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND can_delete_msg = true LIMIT 1',
[clinicaId, userId]);
if (rows.length) return { ok: true };
} catch { /* nega por padrão */ }
return { ok: false, status: 403, error: 'Sem permissão para apagar conversas/mensagens. Peça autorização ao dono.' };
}
export function createRestProxy(pool) {
return async function restProxy(req, res) {
const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) {
return res.status(503).json({ error: 'Integração NewWhats não configurada.' });
}
const auth = authFromReq(req);
if (!auth) return res.status(401).json({ error: 'Sessão inválida (faça login no scoreodonto).' });
const { email, userId } = auth;
const tail = req.originalUrl.replace(/^\/api\/nw\/v1/, '');
const clinicaId = req.headers['x-nw-clinica'] ? String(req.headers['x-nw-clinica']) : null;
// Enforcement de ownership/permissões antes de repassar ao motor.
const guard = await guardSessionAction(pool, req, tail, userId);
if (!guard.ok) return res.status(guard.status).json({ error: guard.error });
// Gate de acesso às áreas Inbox / Secretária.
const area = await guardAreaAccess(pool, tail, userId, clinicaId);
if (!area.ok) return res.status(area.status).json({ error: area.error });
// Gate de ações destrutivas do inbox (apagar conversa/mensagem).
const destructive = await guardInboxDestructive(pool, req.method, tail, userId, clinicaId);
if (!destructive.ok) return res.status(destructive.status).json({ error: destructive.error });
// Assinatura do operador: prefixa o texto enviado com "*Nome:*\n" (identifica quem
// respondeu numa caixa compartilhada). O ":" fica DENTRO do negrito para que TODOS
// os dispositivos (contato/outros operadores) e jids vejam "Nome:" em negrito
// consistente — não só a nossa UI. Só no envio de texto por humano.
if (req.method === 'POST' && /^\/inbox\/[^/]+\/send$/.test(tail) && req.body && typeof req.body.text === 'string' && req.body.text.trim()) {
const sig = await operatorSignature(pool, userId, clinicaId);
if (sig) req.body.text = `*${sig}:*\n${req.body.text}`;
}
// Conta-alvo no motor. Por padrão é a do próprio usuário (e-mail do JWT). Mas a
// inbox da clínica vive na conta (tenant) do DONO — então, nas rotas de inbox,
// se o usuário NÃO é dono do workspace e chegou até aqui (guardAreaAccess já
// exigiu can_inbox), falamos com o motor como o dono. Assim, logado como ele
// mesmo, o usuário vê a caixa do número da clínica no seletor — sem trocar de
// login. Só a inbox comuta a conta; demais rotas seguem com o e-mail próprio.
let targetEmail = email;
if (/^\/(inbox|conversations)(\/|$|\?)/.test(tail) && clinicaId) {
const owner = await resolveOwner(pool, clinicaId);
if (owner && owner.ownerId !== userId && owner.email) targetEmail = owner.email;
}
const url = `${cfg.motorUrl}/api/ext/v1${tail}`;
// Upload de arquivo grande: o corpo é multipart e NÃO passa pelo body-parser JSON
// (o stream chega intacto). Encaminhamos o stream cru ao motor, preservando o
// Content-Type (com boundary) — sem base64, sem limite de JSON.
const isMultipart = /multipart\/form-data/i.test(req.headers['content-type'] || '');
const opts = { method: req.method, headers: {
'x-nw-key': cfg.integrationKey,
'x-nw-email': targetEmail,
// clínica do workspace ativo (modelo de canal) — repassa ao motor se veio.
...(req.headers['x-nw-clinica'] ? { 'x-nw-clinica': String(req.headers['x-nw-clinica']) } : {}),
...(isMultipart
? { 'content-type': req.headers['content-type'] }
: { 'Content-Type': 'application/json' }),
} };
if (isMultipart) {
opts.body = req; // stream cru do upload → motor
opts.duplex = 'half'; // exigido pelo fetch (undici) ao enviar stream
} else if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) {
opts.body = JSON.stringify(req.body);
}
try {
let r = await fetch(url, opts);
let text = await r.text();
// Self-heal: a conta ainda não foi espelhada no motor. Provisiona (idempotente)
// e refaz o request UMA vez — assim "quando a pessoa for mexer, tudo funciona".
if (r.status === 404 && /conta n[ãa]o encontrada/i.test(text) && targetEmail) {
const ok = await provisionNewwhatsAccount(pool, cfg, targetEmail);
if (ok) { r = await fetch(url, opts); text = await r.text(); }
}
res.status(r.status);
try { res.json(JSON.parse(text)); } catch { res.send(text); }
} catch (e) {
res.status(502).json({ error: `Motor indisponível: ${e.message}` });
}
};
}
// ── GET /api/nw/accounts — contas de WhatsApp que o usuário logado pode operar ──
//
// Alimenta o seletor de número da inbox (multi-conta, SEM trocar de login) e o
// ícone (i) de papéis. Junta: (1) as sessões da PRÓPRIA conta do usuário no motor
// e (2) as sessões LIBERADAS pelo dono (can_inbox por sessão em wa_session_authz),
// buscadas no motor com o e-mail do dono. Cada item traz o papel (sec_numbers).
export function createAccountsRoute(pool) {
return async function accountsRoute(req, res) {
const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) {
return res.status(503).json({ error: 'Integração NewWhats não configurada.' });
}
const auth = authFromReq(req);
if (!auth) return res.status(401).json({ error: 'Sessão inválida (faça login no scoreodonto).' });
const { email, userId } = auth;
const motorGet = async (path, asEmail) => {
try {
const r = await fetch(`${cfg.motorUrl}/api/ext/v1${path}`, {
headers: { 'x-nw-key': cfg.integrationKey, 'x-nw-email': asEmail },
});
if (!r.ok) return [];
const j = await r.json();
return Array.isArray(j) ? j : [];
} catch { return []; }
};
try {
// Papéis (sec_numbers) são resolvidos no motor pela CONTA (x-nw-email). Um
// usuário sem conta no motor (ex.: recepcionista que só existe no scoreodonto)
// não tem números próprios — então o papel de cada número LIBERADO precisa ser
// buscado com o e-mail do DONO da sessão, não do usuário logado. Cache por e-mail.
const numbersCache = new Map();
const numbersFor = async (asEmail) => {
if (!numbersCache.has(asEmail)) {
const nums = await motorGet('/secretaria/numbers', asEmail);
numbersCache.set(asEmail, new Map(nums.map((n) => [n.instance_id, n])));
}
return numbersCache.get(asEmail);
};
const metaFrom = (roleMap, instanceId, fallbackName) => {
const n = roleMap.get(instanceId) || {};
return { role: n.role || null, label: n.label || fallbackName || null, area: n.area || null, notes: n.notes || null };
};
const out = [];
const seen = new Set();
const pushWith = (s, roleMap, extra) => {
if (!s || seen.has(s.id)) return;
seen.add(s.id);
out.push({
instanceId: s.id, phone: s.phone || null, name: s.name || null,
status: s.status || null, avatar: s.avatar || null,
...metaFrom(roleMap, s.id, s.name), ...extra,
});
};
// 1) Sessões PRÓPRIAS (conta do próprio usuário no motor) — papel da própria conta.
const ownRoles = await numbersFor(email);
for (const s of await motorGet('/sessions', email)) {
pushWith(s, ownRoles, { clinicaId: null, ownerEmail: email, ownerNome: null, own: true, canInbox: true });
}
// 2) Sessões LIBERADAS por outros donos (can_inbox por sessão).
const { rows: grants } = await pool.query(
`SELECT DISTINCT clinica_id, instance_id FROM wa_session_authz
WHERE usuario_id = $1 AND can_inbox = true`, [userId]);
const byClinica = new Map();
for (const g of grants) {
if (!byClinica.has(g.clinica_id)) byClinica.set(g.clinica_id, new Set());
byClinica.get(g.clinica_id).add(g.instance_id);
}
for (const [clinicaId, allowed] of byClinica) {
const owner = await resolveOwner(pool, clinicaId);
if (!owner || !owner.email || owner.ownerId === userId) continue; // dono próprio já veio em (1)
let ownerNome = null;
try {
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [owner.ownerId]);
ownerNome = rows[0]?.nome || null;
} catch { /* opcional */ }
const ownerRoles = await numbersFor(owner.email); // papéis dos números vêm da conta do DONO
for (const s of await motorGet('/sessions', owner.email)) {
if (allowed.has(s.id)) pushWith(s, ownerRoles, { clinicaId, ownerEmail: owner.email, ownerNome, own: false, canInbox: true });
}
}
res.json(out);
} catch (e) {
res.status(502).json({ error: `Falha ao montar contas: ${e.message}` });
}
};
}
// ── Túnel WebSocket: /api/nw/v1/stream (browser) → motor /api/ext/v1/stream ──
//
// O inbox em tempo real do motor (QR, status da sessão, mensagens novas) roda
// sobre WebSocket. Aqui abrimos um túnel TCP bruto socket↔socket e injetamos a
// x-nw-key no handshake com o motor — a chave nunca chega ao browser.
//
// Auth do usuário: o browser NÃO envia header Authorization no upgrade WS, então
// o JWT do scoreodonto vem por ?token= (mesmo esquema do /api/ws do app). Só
// usuário logado abre o túnel; o token fica local — não é repassado ao motor.
//
// Motor: autentica o /api/ext/v1/stream pela x-nw-key + x-nw-email (a chave é a
// mestra; o tenant é resolvido pelo email do usuário) e exige o path EXATO, sem
// query — por isso reescrevemos um request-line limpo. O email vem do JWT.
//
// Conta-alvo do stream: por padrão a própria. Se o browser mandar ?clinica= (a
// conta ativa do seletor) e o usuário não for o dono mas tiver can_inbox nela,
// o stream passa a ser o do DONO — assim a caixa compartilhada recebe mensagens
// novas em tempo real. Mesma regra do proxy REST (reescrita de x-nw-email).
export function createWsTunnel(pool) {
return async function wsTunnel(req, clientSocket, head) {
const fail = (statusLine) => {
try { clientSocket.write(`HTTP/1.1 ${statusLine}\r\n\r\n`); } catch { /* socket já morto */ }
clientSocket.destroy();
};
// Auth: JWT do scoreodonto no query string (?token=); conta ativa em ?clinica=.
let token, clinica;
try {
const u = new URL(req.url, 'http://localhost');
token = u.searchParams.get('token');
clinica = u.searchParams.get('clinica');
} catch { return fail('400 Bad Request'); }
let email, userId;
try {
const p = jwt.verify(token, process.env.JWT_SECRET);
email = (p.email || '').toLowerCase();
userId = p.userId || null;
} catch { return fail('401 Unauthorized'); }
if (!email) return fail('401 Unauthorized');
// Reescrita da conta-alvo p/ a caixa compartilhada (só leitura via can_inbox).
let targetEmail = email;
if (clinica && pool && userId) {
try {
const owner = await resolveOwner(pool, clinica);
if (owner && owner.email && owner.ownerId !== userId) {
const { rows } = await pool.query(
'SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND can_inbox = true LIMIT 1',
[clinica, userId]);
if (rows.length) targetEmail = owner.email;
}
} catch { /* mantém a própria conta */ }
}
const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) return fail('503 Service Unavailable');
let target;
try { target = new URL(cfg.motorUrl); }
catch { return fail('502 Bad Gateway'); }
const isHttps = target.protocol === 'https:';
const host = target.hostname;
const port = Number(target.port) || (isHttps ? 443 : 80);
const onConnect = () => {
// Reescreve o handshake: path EXATO + Host do motor + x-nw-key.
// Preserva os headers do WS (sec-websocket-*, upgrade, connection).
const headers = Object.entries(req.headers)
.filter(([k]) => !['host', 'x-nw-key', 'x-nw-email'].includes(k.toLowerCase()))
.map(([k, v]) => `${k}: ${v}`)
.join('\r\n');
proxySocket.write(
`${req.method} /api/ext/v1/stream HTTP/1.1\r\n` +
`Host: ${host}\r\n` +
`x-nw-key: ${cfg.integrationKey}\r\n` +
`x-nw-email: ${targetEmail}\r\n` +
`${headers}\r\n\r\n`
);
if (head && head.length) proxySocket.write(head);
proxySocket.pipe(clientSocket);
clientSocket.pipe(proxySocket);
};
const proxySocket = isHttps
? tls.connect({ host, port, servername: host }, onConnect)
: net.connect(port, host, onConnect);
proxySocket.on('error', (err) => {
console.error('[newwhats] túnel WS erro:', err.message);
clientSocket.destroy();
});
clientSocket.on('error', () => proxySocket.destroy());
clientSocket.on('close', () => proxySocket.destroy());
proxySocket.on('close', () => clientSocket.destroy());
};
}
+98
View File
@@ -0,0 +1,98 @@
// Rotas /nw/* — a Secretária IA do motor consulta os dados desta clínica.
// Adaptado do satélite mercado para o domínio ODONTO (scoreodonto).
// Todas as rotas exigem a integration_key (nwAuth).
import { Router } from 'express'
import { nwAuth } from './auth.js'
import { getConfig } from './config.js'
const money = (v) =>
v == null ? null : `R$ ${Number(v).toFixed(2).replace('.', ',')}`
// Resolve a clínica-alvo: query ?clinica_id= tem prioridade; senão a do config.
function resolveClinicaId(req) {
return String(req.query.clinica_id || getConfig().clinicaId || '').trim()
}
export function createRoutes(pool) {
const router = Router()
router.use(nwAuth)
const needClinica = (req, res) => {
const id = resolveClinicaId(req)
if (!id) res.status(400).json({ error: 'clinica_id ausente (configure NEWWHATS_CLINICA_ID ou passe ?clinica_id=).' })
return id
}
router.get('/ping', (_req, res) =>
res.json({ ok: true, service: 'scoreodonto', ts: new Date().toISOString() }))
// Dados da clínica
router.get('/clinica', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows } = await pool.query(
`SELECT nome_fantasia, telefone, cidade, estado, logradouro, numero, bairro, cep
FROM clinicas WHERE id = $1`, [id])
res.json(rows[0] || null)
} catch (e) { res.status(500).json({ error: e.message }) }
})
// Especialidades atendidas
router.get('/especialidades', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows } = await pool.query(
`SELECT nome, descricao, area FROM especialidades
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
res.json(rows)
} catch (e) { res.status(500).json({ error: e.message }) }
})
// Dentistas + horários de atendimento
router.get('/dentistas', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows: dentistas } = await pool.query(
`SELECT id, nome, especialidade FROM dentistas
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
for (const d of dentistas) {
const { rows: hr } = await pool.query(
`SELECT dia_semana, hora_inicio, hora_fim FROM dentistas_horarios
WHERE dentista_id = $1 AND ativo::int = 1 ORDER BY dia_semana, hora_inicio`, [d.id])
d.horarios = hr
delete d.id
}
res.json(dentistas)
} catch (e) { res.status(500).json({ error: e.message }) }
})
// Procedimentos + valor particular + valores por plano
router.get('/procedimentos', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows: procs } = await pool.query(
`SELECT id, nome, descricao, categoria, valorparticular FROM procedimentos
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
for (const p of procs) {
const { rows: vp } = await pool.query(
`SELECT planonome, valor FROM procedimentos_valores_planos WHERE procedimentoid = $1`, [p.id])
p.valor_particular = money(p.valorparticular)
p.valores_planos = vp.map((x) => ({ plano: x.planonome, valor: money(x.valor) }))
delete p.id; delete p.valorparticular
}
res.json(procs)
} catch (e) { res.status(500).json({ error: e.message }) }
})
// Planos / convênios aceitos
router.get('/planos', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows } = await pool.query(
`SELECT nome, tipo, descontopadrao FROM planos WHERE clinica_id = $1 ORDER BY nome`, [id])
res.json(rows)
} catch (e) { res.status(500).json({ error: e.message }) }
})
return router
}
+135
View File
@@ -0,0 +1,135 @@
// sync-knowledge.js (ODONTO) — monta o nó de conhecimento da Secretária IA com
// os dados reais da clínica e sincroniza com o motor NewWhats.
// Adaptado do satélite mercado. Fluxo:
// 1. Lê dados do banco (clínica, especialidades, dentistas, procedimentos, planos)
// 2. Monta texto estruturado do knowledge node
// 3. PUT/POST no motor (/api/secretaria/*) — rotas internas, sem auth de sessão
import { getConfig } from './config.js'
const money = (v) => v == null ? null : `R$ ${Number(v).toFixed(2).replace('.', ',')}`
const DIAS = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']
async function motorFetch(motorUrl, method, path, body) {
const res = await fetch(`${motorUrl}/api/secretaria${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) throw new Error(`Motor ${method} /api/secretaria${path}${res.status}: ${(await res.text()).slice(0, 200)}`)
return res.json()
}
export async function buildKnowledgeText(pool, clinicaId) {
const lines = []
const q = (sql, params) => pool.query(sql, params).then((r) => r.rows)
const [clinica] = await q(
`SELECT nome_fantasia, telefone, cidade, estado, logradouro, numero, bairro
FROM clinicas WHERE id = $1`, [clinicaId])
const nome = clinica?.nome_fantasia || 'Nossa clínica odontológica'
lines.push(`=== ${nome.toUpperCase()} ===`)
lines.push(`Clínica odontológica. Atendimento via WhatsApp.`)
if (clinica?.telefone) lines.push(`Telefone: ${clinica.telefone}`)
if (clinica?.logradouro) {
const end = [clinica.logradouro, clinica.numero, clinica.bairro, clinica.cidade, clinica.estado].filter(Boolean).join(', ')
lines.push(`Endereço: ${end}`)
}
lines.push('')
// Especialidades
const esp = await q(
`SELECT nome, descricao FROM especialidades WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
if (esp.length) {
lines.push(`=== ESPECIALIDADES ATENDIDAS ===`)
for (const e of esp) lines.push(`${e.nome}${e.descricao ? `${e.descricao}` : ''}`)
lines.push('')
}
// Dentistas + horários
const dentistas = await q(
`SELECT id, nome, especialidade FROM dentistas WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
if (dentistas.length) {
lines.push(`=== DENTISTAS ===`)
for (const d of dentistas) {
lines.push(`${d.nome}${d.especialidade ? ` (${d.especialidade})` : ''}`)
const hr = await q(
`SELECT dia_semana, hora_inicio, hora_fim FROM dentistas_horarios
WHERE dentista_id = $1 AND ativo::int = 1 ORDER BY dia_semana, hora_inicio`, [d.id])
if (hr.length) {
const fmt = hr.map((h) => `${DIAS[h.dia_semana] ?? h.dia_semana} ${String(h.hora_inicio).slice(0, 5)}-${String(h.hora_fim).slice(0, 5)}`)
lines.push(` Atende: ${fmt.join(' | ')}`)
}
}
lines.push('')
}
// Procedimentos + valores
const procs = await q(
`SELECT id, nome, categoria, valorparticular FROM procedimentos
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
if (procs.length) {
lines.push(`=== PROCEDIMENTOS E VALORES ===`)
for (const p of procs) {
const part = p.valorparticular != null ? ` — particular: ${money(p.valorparticular)}` : ''
lines.push(`${p.nome}${p.categoria ? ` [${p.categoria}]` : ''}${part}`)
const vp = await q(
`SELECT planonome, valor FROM procedimentos_valores_planos WHERE procedimentoid = $1`, [p.id])
const comValor = vp.filter((x) => x.valor != null)
if (comValor.length) lines.push(` Por plano: ${comValor.map((x) => `${x.planonome} ${money(x.valor)}`).join(', ')}`)
}
lines.push('')
}
// Planos / convênios
const planos = await q(
`SELECT nome, tipo, descontopadrao FROM planos WHERE clinica_id = $1 ORDER BY nome`, [clinicaId])
if (planos.length) {
lines.push(`=== PLANOS / CONVÊNIOS ACEITOS ===`)
for (const pl of planos) {
const desc = pl.descontopadrao ? ` — desconto padrão ${pl.descontopadrao}%` : ''
lines.push(`${pl.nome}${pl.tipo ? ` (${pl.tipo})` : ''}${desc}`)
}
lines.push('')
}
lines.push(`=== ATENDIMENTO ===`)
lines.push(`Para agendar, remarcar ou tirar dúvidas, fale pelo WhatsApp.`)
lines.push(`Dúvidas sobre valores, convênios ou disponibilidade: informe que vai encaminhar para a equipe.`)
return lines.join('\n')
}
export async function syncKnowledge(pool) {
const cfg = getConfig()
if (!cfg.motorUrl) throw new Error('NEWWHATS_URL não configurado')
if (!cfg.clinicaId) throw new Error('NEWWHATS_CLINICA_ID não configurado')
const knowledgeText = await buildKnowledgeText(pool, cfg.clinicaId)
const agents = await motorFetch(cfg.motorUrl, 'GET', '/agents')
if (!agents.length) throw new Error('Nenhum agente no motor. Crie um agente na Secretária IA.')
const agent = agents.find((a) => a.active) || agents[0]
const nodes = await motorFetch(cfg.motorUrl, 'GET', `/agents/${agent.id}/nodes`)
const kn = nodes.find((n) => n.type === 'knowledge')
let nodeId
const title = 'Base de Conhecimento — Clínica Odontológica'
if (kn) {
await motorFetch(cfg.motorUrl, 'PUT', `/nodes/${kn.id}`, {
title, content: knowledgeText, active: true, sort_order: kn.sort_order ?? 3,
})
nodeId = kn.id
} else {
const created = await motorFetch(cfg.motorUrl, 'POST', `/agents/${agent.id}/nodes`, {
type: 'knowledge', title, content: knowledgeText, sort_order: 3,
})
nodeId = created.id
}
return {
ok: true, agent_id: agent.id, agent: agent.name, node_id: nodeId,
chars: knowledgeText.length, preview: knowledgeText, synced_at: new Date().toISOString(),
}
}
+52
View File
@@ -0,0 +1,52 @@
// Receptor de webhooks do motor NewWhats (motor → satélite).
// POST /api/webhook/newwhats
// Header x-nw-signature: sha256=HMAC-SHA256(webhook_secret, rawBody)
// Eventos: message.new (nova mensagem WA), session.status (conexão da instância)
//
// Versão ENXUTA p/ scoreodonto: valida HMAC, registra o evento e deixa ganchos
// (TODO) para captura de lead / auto-resposta. NÃO porta o auto-responder do
// mercado (a Secretária do motor já responde via /secretaria/ask).
import { Router } from 'express'
import { createHmac, timingSafeEqual } from 'crypto'
import { getConfig } from './config.js'
function verifySignature(secret, rawBody, sigHeader) {
if (!sigHeader) return false
const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'))
const received = Buffer.from(String(sigHeader))
return expected.length === received.length && timingSafeEqual(expected, received)
}
export function createWebhookReceiver(_pool) {
const router = Router()
router.post('/api/webhook/newwhats', async (req, res) => {
try {
const secret = getConfig().webhookSecret
if (secret) {
const rawBody = req.rawBody ?? Buffer.from(JSON.stringify(req.body))
if (!verifySignature(secret, rawBody, req.headers['x-nw-signature'])) {
return res.status(401).json({ error: 'Assinatura inválida' })
}
} else {
console.warn('[nw-webhook] NEWWHATS_WEBHOOK_SECRET ausente — assinatura ignorada')
}
const { event, data } = req.body || {}
if (event === 'message.new') {
// TODO(odonto): registrar lead / acionar fluxo. Hoje só loga.
console.log(`[nw-webhook] message.new de ${data?.author ?? data?.pushName ?? '?'}: ${String(data?.body ?? '').slice(0, 60)}`)
} else if (event === 'session.status') {
console.log(`[nw-webhook] session.status → ${data?.instanceId}: ${data?.status}`)
} else {
console.log(`[nw-webhook] evento não tratado: ${event}`)
}
res.json({ ok: true })
} catch (e) {
console.error('[nw-webhook] erro:', e.message)
res.status(500).json({ error: e.message })
}
})
return router
}
+1766 -79
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.
+158
View File
@@ -0,0 +1,158 @@
# Análise — Área da Recepção / Coordenação de Tratamentos (Treatment Coordinator)
> Gatilho (23/jun/2026): `recep.consulttclinic@gmail.com` lançou uma prótese mas **não tinha
> a aba para acompanhá-la**. Análise do gap + recomendação fundamentada em modelos validados
> de software odontológico. Conecta com `FASE2-PROTETICO-AREA.md` (o outro lado do fluxo).
---
## 1. Diagnóstico (DEV `scoreodonto_dev`)
| Entidade | Achado |
|---|---|
| Dono | `consulttclinic@gmail.com`*Rui Cesar*, `donoclinica` |
| Funcionária | `recep.consulttclinic@gmail.com` (`u_1780962424310_cqsxn`), role `funcionario`, vínculo na **CONSULTT CLINIC LTDA** (`c_1780871001549_koqa3o`) |
| Cargo | função "Recepcionista" (`fnc_1780962382528_m28go`) |
| Permissões (antes) | `["agenda","ortodontia","contratos","tratamentos","pacientes","lancar-gto"]` |
| Prótese lançada | OS `pos_1782228267898_zgfzn` — Prótese Total, paciente ABIGAIR, R$600, protético "Zeca Andrade Fake", status `solicitado`, **via GTO** (`gto_item_id=QOJWR6D`) |
**Gap exato:** o cargo tinha `lancar-gto` (lançar) mas **não** `proteses` (acompanhar). O RBAC da
`Sidebar.tsx` é literal (`return wsPerms.includes(item.id)`), então a OS criada por ela ficava
**invisível** para ela própria. A Fase 1 (GTO→OS) funcionou; o problema era de **configuração de
cargo**, não de arquitetura.
---
## 2. Modelos validados (referência externa)
- **Papel = "Treatment Coordinator" + Front Desk.** É a ponte entre paciente e equipe clínica:
sequencia planos multi-etapa, conduz o aceite do caso (case acceptance), arranjo financeiro e
follow-up até a conclusão — exatamente "gerir tratamentos" de ponta a ponta.
- **RBAC por papel é o padrão.** Permissões mapeadas a papéis, não a indivíduos; o front desk
recebe agenda/cadastro/cobrança, tipicamente **sem notas clínicas**. O ScoreOdonto já faz isso
via `funcoes.permissoes[]`.
- **Prótese = "Lab Case", acompanhada pelo front desk.** No Dentrix Lab Case Manager: clínico cria
a prescrição → status *Sent***o front desk muda para *Received* quando volta do lab**
*Finished* ao assentar. Ou seja: acompanhar a OS de prótese é, por modelo consagrado, **atribuição
da recepção** — o acesso que faltava.
Fontes:
- The Role of a Treatment Coordinator — https://www.dentalmanagers.com/blog/role-of-treatment-coordinator-in-dental-practice/
- What Does a Treatment Plan Coordinator Do? — https://www.teero.com/blog/treatment-plan-coordinator
- Dental Software User Permissions (Curve) — https://www.curvedental.com/blog/dental-software-user-permissions
- RBAC in Healthcare — https://www.accountablehq.com/post/role-based-access-control-rbac-in-healthcare-benefits-examples-and-best-practices
- Manage Your Lab Cases in Dentrix — https://magazine.dentrix.com/manage-your-lab-cases-in-dentrix/
- Open Dental — Lab Cases — https://www.opendental.com/manual/labcasemanage.html
---
## 3. Ação aplicada — Nível 1 (configuração, sem código)
**Aplicado no DEV em 23/jun/2026.** Função "Recepcionista" da CONSULTT CLINIC LTDA atualizada:
```sql
UPDATE funcoes
SET permissoes = '["agenda","pacientes","tratamentos","ortodontia","proteses","lancar-gto","orcamentos","contratos"]'
WHERE id = 'fnc_1780962382528_m28go'; -- UPDATE 1
```
Acréscimos: **`proteses`** (acompanhar a OS que ela lança) e **`orcamentos`** (case acceptance —
coração do papel TC). Efeito imediato: a OS `pos_1782228267898_zgfzn` passa a aparecer para ela.
> Escopo: alteração **só no DEV**. Não há mudança de código nem migração — é dado de configuração.
> Para refletir em outras clínicas, ajustar o cargo equivalente de cada uma (ou rever o template
> de cargos padrão, se existir).
---
## 3b. Causa raiz (investigação 23/jun) — `proteses` não é concedível pela UI
A correção de dado acima trata o sintoma. A **causa raiz** é um bug de configuração de RBAC:
- Cargos **não** têm seed no backend (signup não cria funções). São criados manualmente pelo
dono em Gestão de Equipe (`POST → INSERT INTO funcoes`, `server.js:806`), partindo de 4
templates do frontend (`GestaoEquipeView.tsx:96`).
- **Nenhum** template inclui `proteses`: RECEPÇÃO (`dashboard,agenda,pacientes,leads,orcamentos`),
AUXILIAR (`...,ortodontia,tratamentos`), FINANCEIRO (`...,financeiro,relatorios,lancar-gto`),
GERENTE (`[...TODAS_PAGINAS]`).
- **Raiz:** `proteses` **não está em `PAGINAS_RBAC`** (`GestaoEquipeView.tsx:57`), a lista mestre
de páginas atribuíveis. Como `TODAS_PAGINAS = PAGINAS_RBAC.flatMap(...)`, **nem o GERENTE /
"MARCAR TODAS"** concede prótese. ⇒ **É impossível um dono dar `proteses` a um funcionário
pela interface** — só via UPDATE direto no banco (o que foi feito na §3).
- Também ausentes da lista atribuível, embora em uso: **`contratos`** (a função tinha — legado),
`comissoes`, `glosas`.
**Resposta à pergunta "o cargo Recepcionista padrão nasce sem proteses?":** Sim — e mais,
*nenhum* cargo consegue tê-la pela UI hoje.
### Fix de raiz (código) — ✅ aplicado e validado no DEV (23/jun)
Em `frontend/views/GestaoEquipeView.tsx`:
- `PAGINAS_RBAC`, grupo `ATENDIMENTO`: + `{ key: 'proteses', label: 'PRÓTESES' }` e
`{ key: 'contratos', label: 'CONTRATOS' }` → agora concedíveis pela UI (e entram em
`TODAS_PAGINAS`/GERENTE/"MARCAR TODAS").
- Templates: RECEPÇÃO ganhou `contratos, lancar-gto, proteses`; AUXILIAR ganhou `proteses`
(alinhado ao papel TC).
**Validação:** `vite build` transformou 2382 módulos OK; container DEV rebuildado e no ar; o
bundle servido pelo nginx DEV (`index-*.js`, alvo de `dev.scoreodonto.com`) contém
`key:"proteses",label:"PRÓTESES"` e a string `proteses` nos 3 pontos editados. Cargos padrão
passam a nascer coerentes — sem UPDATE manual por clínica.
> ⚠️ Pendente de commit + deploy à produção (a mudança está só no DEV). Os fixes de **dado**
> (§3, função da CONSULTT CLINIC) continuam sendo só-DEV e não se propagam por este fix de código.
## 4. Recomendação — Nível 2 (estrutural, "Fase 3")
Em vez de abas soltas (`tratamentos`, `proteses`, `ortodontia`), um **hub de Coordenação de
Tratamentos por paciente**: pipeline único com o status de cada frente (clínico / prótese-lab /
orto), espelhando o "lab case visível para toda a equipe" do Dentrix. View nova que **agrega o que
já existe** (`tratamentos` + `protese_os` + ortodontia por paciente) — sem dado novo.
## 5. Conexão com a Fase 2
Dois lados do mesmo fluxo, unidos pela mesma `protese_os`:
- **Fase 2** → casa do **protético** (quem produz; avança status na Bancada).
- **Esta análise** → **recepção/TC** (quem encomenda e acompanha; lê o status no hub).
O status que o lab avança é o mesmo que a recepção lê — o "Lab Case Manager" repartido entre os papéis.
---
## 6. Salas: acesso do funcionário corrigido + bloqueio de manutenção (23/jun)
Gatilho: a mesma recepcionista aparecia com **MINHAS SALAS** e **ALUGAR SALAS** (gestão de
locação, de titular) e deveria ter apenas a **agenda da sala** e o **bloqueio para manutenção**.
### 6a. Bug — vazamento dos menus de locação ✅ corrigido (`Sidebar.tsx`)
`MINHAS SALAS` (locador) e `ALUGAR SALAS` (locatário) eram empurrados a "todos menos
superadmin" (`Sidebar.tsx:42`), vazando para **funcionário e paciente** — papéis que não são
titulares de sala. Passaram a exibir apenas a `PAPEIS_LOCACAO`
(`donosala/donoclinica/donoconsultorio/admin/dentista/biomedico/protetico`). O backend já era
seguro por ownership; era vazamento só de UI.
### 6b. Feature — acesso operacional + manutenção ✅ implementada e validada no DEV
Não existia caminho para "funcionário vê só a agenda" nem "bloqueio para manutenção" — foram
criados (modelos de mercado: *Dentrix/Open Dental Lab... não; aqui é gestão de espaço* — o
padrão é **front desk opera a agenda da sala sem tocar no financeiro do locador**).
- **Acesso operacional** (`server.js` `listarWorkspaces`): sala cuja `clinica_id` é uma clínica
onde o usuário tem vínculo (e ele **não** é o dono) entra como workspace `tipo='sala'` com
`papel_sala='operador'`. O `SalaHomeView` detecta o modo operador e **esconde recebíveis,
relatório e baixa** — mostra só a **agenda** + botão de bloqueio.
- **Bloqueio de manutenção** (`POST /api/salas/:id/manutencao`): reserva `tipo='manutencao'`
(coluna nova, default `'locacao'`), `valor=0`, nasce `'confirmada'` (sem aprovação) e **não
gera recebível**. Acesso: dono **ou** membro com vínculo na clínica da sala. Reaproveita o
anti-conflito de horário (409) e o cancelamento por solicitante/unidade. A agenda renderiza
o bloqueio em **cinza** (legenda "Manutenção").
**Validação ponta a ponta (DEV, JWT real da recepcionista, sala de teste vinculada à clínica):**
| Caso | Resultado |
|---|---|
| Sala da clínica vira workspace operador dela | ✅ query retorna a sala |
| POST bloqueio | ✅ `status:confirmada, tipo:manutencao` |
| GET agenda mostra bloqueio + motivo | ✅ `valor:0, motivo:"Conserto do equipo"` |
| Conflito de horário | ✅ HTTP 409 |
| Não gera financeiro | ✅ 0 lançamentos |
Commits: `fix(salas)` (menu) + `feat(salas)` (operador + manutenção). Backend e frontend no ar
no DEV; dados de teste limpos.
> ⚠️ Pendente de push + deploy (produção segue em v1.0.19).
+217
View File
@@ -0,0 +1,217 @@
# Fase 2 — Detalhamento de implementação: passos 2.0 e 2.1
> ✅ **IMPLEMENTADO e validado em DEV (23/jun/2026).** Commits `feat(protese): Fase 2 backend`
> + `frontend`. Validações: registro de protético nasce `laboratorio`; backfill preenche
> `laboratorio_id`; bancada roteia por lab (fallback por pessoa); entrega gera **par espelhado**
> DESPESA(clínica)+RECEITA(lab) R$600, idempotente (409 na re-entrega); menu do lab no bundle.
>
> ⚠️ **Bug pré-existente achado e corrigido junto:** o INSERT da DESPESA de prótese (Fase 1, já
> em produção v1.0.20) usava `paciente_nome` — coluna inexistente em `financeiro` (é
> `pacientenome`). A entrega de OS com `custo>0` **abortava** em produção. Corrigido no mesmo
> commit de backend. **Recomenda-se priorizar o deploy** por causa disso.
> Companheiro de `FASE2-PROTETICO-AREA.md` (o modelo). Aqui está o **como**, a nível de
> arquivo/função, fiel ao código atual de `backend/server.js` e `frontend/`.
> Decisões aplicadas: **1 lab por protético**, **só terceirizado**, **receita espelhada na 2.1**.
> Disciplina herdada da Fase 1: migrações aditivas/idempotentes; blocos novos em `try/catch`
> que nunca derrubam o fluxo principal; `protetico_id` sempre preservado (fallback).
---
# PASSO 2.0 — Dados invisíveis (backfill `laboratorio_id`)
Nenhuma mudança de UI. Objetivo: todo protético tem um workspace `tipo='laboratorio'` e toda
OS/produto carrega `laboratorio_id`. Validar 100% em DEV (`pgdev`) antes de seguir.
## 2.0.a — Migrações (em `runMigrations()`, junto às da Fase 1, ~linha 5663)
```js
// ── FASE 2.0: Laboratório como workspace de 1ª classe ──────────────────────
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_lab_id TEXT`, // RECEITA espelhada (2.1)
`ALTER TABLE protese_produtos ADD COLUMN IF NOT EXISTS laboratorio_id TEXT`,
// laboratorio_id em protese_os JÁ EXISTE (coluna reservada na criação da tabela) — não recriar.
// re-tag: o workspace pessoal do protético passa a ser 'laboratorio'
`UPDATE clinicas SET tipo='laboratorio'
WHERE tipo='pessoal'
AND owner_id IN (SELECT id FROM usuarios WHERE role='protetico')`,
// backfill: cada OS herda o laboratorio do workspace do seu protético
`UPDATE protese_os o SET laboratorio_id = c.id
FROM clinicas c
WHERE o.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = o.protetico_id`,
// backfill: catálogo passa a ser do laboratório (mantém protetico_id p/ compat)
`UPDATE protese_produtos p SET laboratorio_id = c.id
FROM clinicas c
WHERE p.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = p.protetico_id`,
// índice por lab (o de protetico_id já existe; adiciona o do lab)
`CREATE INDEX IF NOT EXISTS idx_protese_os_labid ON protese_os (laboratorio_id, status)`,
```
> O `UPDATE especialidades … area='protese'` da Fase 1 já está logo acima — não duplicar.
## 2.0.b — Helper: resolver o laboratório de um protético
Novo helper perto de `proteseOsAccesso` (~linha 3772). 1 lab por protético ⇒ `LIMIT 1`.
```js
// O workspace 'laboratorio' do protético (1 por protético nesta fase).
async function labDoProtetico(proteticoId) {
if (!proteticoId) return null;
const { rows } = await pool.query(
"SELECT id FROM clinicas WHERE owner_id=$1 AND tipo='laboratorio' AND COALESCE(deleted_at IS NULL, true) LIMIT 1",
[proteticoId]);
return rows[0]?.id || null;
}
```
> Se `clinicas` não tiver `deleted_at`, usar só `WHERE owner_id=$1 AND tipo='laboratorio'`.
## 2.0.c — Gravar `laboratorio_id` na CRIAÇÃO da OS (2 pontos)
**(i) `POST /api/protese/os`** (~linha 3893): resolver o lab e incluí-lo no INSERT.
```js
const labId = await labDoProtetico(b.protetico_id); // antes do INSERT
// no INSERT: acrescentar a coluna laboratorio_id e o valor labId
// ... protetico_id, laboratorio_id, protetico_nome, ...
// VALUES ($1,$2,$3,$LAB,$4,...)
```
**(ii) Bloco GTO→protese_os (Fase 1)** em `PUT /api/gto-builder/:id/finalizar`
(o INSERT em `protese_os` que você acabou de escrever): mesma adição.
```js
const labId = await labDoProtetico(it.protetico_id); // dentro do for, antes do INSERT
// incluir laboratorio_id = labId no INSERT da OS gerada por GTO
```
> Assim, OS novas já nascem com `laboratorio_id`; as antigas foram cobertas pelo backfill.
## 2.0.d — Bancada roteia por laboratório (com fallback por pessoa)
`GET /api/protese/bancada` (~linha 3926). Hoje: `WHERE protetico_id=$1`. Passa a aceitar OS
do(s) laboratório(s) do usuário, mantendo fallback para OS ainda sem `laboratorio_id`:
```js
const uid = req.authUser.userId;
const labId = await labDoProtetico(uid); // o lab do protético logado
const vals = [uid, labId];
let where = `( (laboratorio_id IS NOT NULL AND laboratorio_id=$2)
OR (laboratorio_id IS NULL AND protetico_id=$1) ) AND deleted_at IS NULL`;
if (req.query.status && PROTESE_STATUS.includes(req.query.status)) {
vals.push(req.query.status); where += ` AND status=$${vals.length}`;
}
// resto idêntico (ORDER BY / LIMIT / map)
```
> `proteseOsAccesso` **não muda na 2.0** (1 lab por protético ⇒ `protetico_id===uid` ainda
> identifica o lab). O `OR por vínculo no lab` entra só na 2.3 (equipe/`tecnico_protese`).
## 2.0.e — Novos protéticos já nascem com `tipo='laboratorio'`
No `POST /api/register` (~linha 625), o INSERT do workspace usa `tipo='pessoal'` para todos.
Diferenciar o protético:
```js
const wsTipo = userRole === 'protetico' ? 'laboratorio' : 'pessoal';
`INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1,$2,$3,$4)` // $3 = wsTipo
```
> O seed de catálogo (`seedProteseProdutos(userId)`) continua por `protetico_id` (owner) —
> sem mudança. (O `laboratorio_id` do catálogo é resolvido pelo backfill / por leitura.)
## ✅ Critérios de aceite 2.0 (validar em DEV)
- `SELECT count(*) FROM protese_os WHERE laboratorio_id IS NULL`**0** (após backfill).
- Toda `clinicas` de protético tem `tipo='laboratorio'`.
- Criar OS nova (via Nova OS **e** via finalizar GTO) → `laboratorio_id` preenchido.
- Bancada continua mostrando exatamente as mesmas OS de antes (fallback cobre o legado).
- **Zero** mudança visível na UI.
---
# PASSO 2.1 — Casa própria + receita espelhada
Agora o protético entra numa **área de Laboratório** (menu próprio) e a entrega passa a
lançar a receita no workspace do lab.
## 2.1.a — BACKEND: receita espelhada na entrega
Em `POST /api/protese/os/:id/status`, dentro do `if (novo === 'entregue') { ... }`, logo após
o bloco que cria a **DESPESA** (`finId`). Mesma condição (`custo>0 && tipo_os≠'garantia'`):
```js
// Espelho: RECEITA no workspace do laboratório (clinica_id = laboratorio_id). Idempotente.
let finLabId = os.financeiro_lab_id || null;
try {
const labId = os.laboratorio_id || await labDoProtetico(os.protetico_id);
if (!finLabId && labId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') {
finLabId = `fin_lab_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
await pool.query(
`INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, paciente_id, paciente_nome, origem, centro_custo, sem_comissao)
VALUES ($1,$2,$3,$4,'RECEITA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,'protese','PRÓTESE',true)`,
[finLabId, labId, `PRÓTESE: ${os.tipo} (cliente ${clinicaNome || ''})`.trim(), Number(os.custo),
os.paciente_id || null, os.paciente_nome || null]);
}
} catch (e) { console.error('[protese entrega→receita lab]', e.message); }
```
E no `UPDATE protese_os` da entrega, persistir o id:
```js
// ...financeiro_id=COALESCE($3,financeiro_id), financeiro_lab_id=COALESCE($N,financeiro_lab_id)...
```
> `clinicaNome` = nome da clínica de origem (1 query opcional, ou usar `os.clinica_id`).
> **Estorno** (cancelar entrega) é tarefa à parte: reverter os dois `financeiro` — fica para
> a sub-fase de cancelamento; por ora a entrega é terminal (já é hoje: 409 se `entregue`).
## 2.1.b — FRONTEND: menu da área de Laboratório (`Sidebar.tsx`)
**(i)** Acrescentar os itens ao array `menuItems` (~linha 53):
```js
{ id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
```
(catálogo/clientes/agenda/financeiro entram na 2.2/2.3 — não criar itens mortos agora.)
**(ii)** Isolar o menu quando o workspace ativo é laboratório — **espelhar** o bloco que já
existe para `tipo==='sala'` (~linha 98). Inserir antes dele:
```js
// Contexto de LABORATÓRIO: área própria do protético (não é clínica).
if ((activeWorkspace as any)?.tipo === 'laboratorio')
return ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id);
```
> Como o protético tem **1** workspace e ele é `tipo='laboratorio'`, esse branch governa o
> menu dele. O antigo filtro `currentRole==='protetico'` (linha ~131) deixa de ser alcançado
> para o caso comum, mas **mantê-lo** como rede de segurança (protético sem workspace lab).
## 2.1.c — FRONTEND: roteamento das telas novas (`App.tsx`)
No switch/router de `view` do `App.tsx`, mapear:
```jsx
case 'lab-bancada': return <ProteseView />; // já renderiza modo 'bancada' p/ role protetico
case 'lab-home': return <LabHomeView />; // novo painel (abaixo)
```
> `ProteseView` já decide `modo='bancada'` por `getCurrentRole()==='protetico'` — reaproveita
> 100%. Só muda o **id de menu** que a abre.
## 2.1.d — FRONTEND: `LabHomeView` (painel mínimo)
Nova view `frontend/views/LabHomeView.tsx`. Cards a partir de dados que já existem:
- **Fila por status** e **atrasadas**: derivar de `getProteseBancada()` (já retorna `atrasada`).
- **Faturamento do mês**: somar `financeiro` RECEITA `origem='protese'` do workspace lab
(rota de financeiro existente, escopada por `clinicaId` = id do lab).
- **Prazo médio / entregues no mês**: agregação simples sobre a bancada.
Sem rota nova obrigatória na 2.1 (reusa `bancada` + `financeiro`); se quiser um resumo de 1
chamada, criar `GET /api/protese/lab/resumo` depois.
## ✅ Critérios de aceite 2.1
- Logado como protético, a Sidebar mostra **PAINEL DO LAB** + **BANCADA** (+ notificações/config).
- A clínica continua com a tela PRÓTESES (modo `clinica`) **intacta**.
- Entregar uma OS com `custo>0` (não-garantia) cria **2** lançamentos: DESPESA na clínica e
RECEITA no lab (mesmo valor `custo`); reentregar/reprocessar **não** duplica (`COALESCE`).
- OS de garantia entregue → **nenhum** lançamento (nos dois lados).
- `LabHomeView` mostra fila + faturamento do mês do laboratório.
---
# Ordem de execução e deploy
1. Branch a partir da `main` (que já tem a Fase 1 não-commitada — **commitar a Fase 1 antes**).
2. 2.0 (backend só) → subir em DEV (`dev.scoreodonto.com`/`pgdev`), rodar os SELECTs de aceite.
3. 2.1 (backend receita + front) → validar aceite em DEV.
4. Commit por sub-fase; push → CI builda por commit; `git tag vX.Y.Z` promove à produção.
> Migrações aditivas ⇒ rollback de código é seguro (padrão já validado no projeto).
+220
View File
@@ -0,0 +1,220 @@
# Fase 2 — Área do Protético (Laboratório como workspace de 1ª classe)
> Desenho de modelo. Continuação da **Fase 1 (GTO → Prótese)**, onde finalizar uma GTO
> abre automaticamente uma OS na Bancada (`protese_os.gto_item_id`).
> **Objetivo da Fase 2:** o protético deixa de ser "um papel que só vê a Bancada" e passa
> a ter uma **área de gestão própria** — o Laboratório — equivalente ao que a Clínica é para
> o dentista. Ele gere catálogo, equipe, clientes (clínicas), financeiro e agenda de entregas.
---
## 1. Onde estamos hoje (não reinventar)
O alicerce já existe — a Fase 2 **promove**, não cria do zero:
| Já existe | Onde |
|---|---|
| Role `protetico` (válido em `usuarios` e `vinculos`) | `server.js` VALID_ROLES / constraints |
| Workspace pessoal "LABORATÓRIO DE …" criado no signup | `clinicas (tipo='pessoal')` + `vinculos (role='protetico')` |
| Catálogo de produtos (preço fixo + garantia) | `protese_produtos (protetico_id)` |
| Bancada (fila de OS do protético) | `GET /api/protese/bancada` (roteia por `protetico_id`) |
| OS com eventos, fotos, status, garantia, financeiro | `protese_os`, `protese_os_evento`, `protese_os_foto` |
| `ProteseView` com `modo: 'bancada' | 'clinica'` por role | `frontend/views/ProteseView.tsx` |
| **`protese_os.laboratorio_id`** — reservado, hoje `null` | comentário: "workspace destino (futuro)" |
| Menu filtrado por role (protético = subset) | `Sidebar.tsx` linha ~131 |
**Lacuna que a Fase 2 fecha:** o Laboratório não é uma *entidade gerenciável*. O protético
roteia OS por `protetico_id` (a pessoa), não por um laboratório (a empresa). Não há painel,
equipe, visão de clientes, nem financeiro do lado do laboratório. A OS conhece a *pessoa*,
não a *bancada/empresa*.
---
## 2. Decisão de modelo: Laboratório = workspace `tipo='laboratorio'`
O sistema já trata **workspace** de forma polimórfica: `clinicas` é a tabela-base e `tipo`
distingue (`clinica` | `pessoal` | `sala`). A Fase 2 introduz **`tipo='laboratorio'`** em vez
de criar uma tabela nova. Isso reaproveita: vínculos, troca de workspace, RBAC por cargo,
financeiro, notificações e o seletor de workspace da Sidebar.
```
usuarios (role='protetico')
│ vinculos (role='protetico' | 'tecnico_protese')
clinicas (tipo='laboratorio') ◄── "LABORATÓRIO DE X" deixa de ser 'pessoal'
┌───────────┼─────────────────────────────┐
▼ ▼ ▼
protese_produtos protese_os (laboratorio_id) equipe (vinculos do lab)
(catálogo) ▲ roteamento por LAB,
│ não mais só por pessoa
clínicas-cliente enviam OS ──┘
```
### Migração do dado existente (aditiva, idempotente)
```sql
-- 1) re-tag do workspace pessoal do protético → laboratorio
UPDATE clinicas SET tipo='laboratorio'
WHERE tipo='pessoal'
AND owner_id IN (SELECT id FROM usuarios WHERE role='protetico');
-- 2) backfill: toda OS legada ganha o laboratorio_id do workspace do seu protetico
UPDATE protese_os o SET laboratorio_id = c.id
FROM clinicas c
WHERE o.laboratorio_id IS NULL
AND c.tipo='laboratorio'
AND c.owner_id = o.protetico_id;
-- 3) o catálogo passa a ser do laboratório (mantém protetico_id p/ compat)
ALTER TABLE protese_produtos ADD COLUMN IF NOT EXISTS laboratorio_id TEXT;
UPDATE protese_produtos p SET laboratorio_id = c.id
FROM clinicas c
WHERE p.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = p.protetico_id;
```
> `protetico_id` **continua** populado em todas as tabelas (compatibilidade da Fase 1 e do
> roteamento por pessoa). `laboratorio_id` passa a ser a **chave de escopo** da área.
---
## 3. A Área do Laboratório (navegação)
Quando o `activeWorkspace.tipo === 'laboratorio'`, a Sidebar troca para um menu próprio
(análogo ao `tipo==='sala'` que já isola o menu). Itens:
| Item | Conteúdo | Reaproveita |
|---|---|---|
| `lab-home` | **Painel** do laboratório: OS por status, atrasadas, faturamento do mês, prazo médio | novo dashboard + agregações de `protese_os` |
| `lab-bancada` | **Bancada** (fila de produção, kanban por status) | `ProteseView` modo bancada (já existe) |
| `lab-catalogo` | **Catálogo** de produtos (preço/garantia) — vira tela, não modal | `ProdutosModal` → view |
| `lab-clientes` | **Clínicas-cliente**: quem envia OS, volume, ticket, pendências | distinct de `protese_os.clinica_id` |
| `lab-agenda` | **Agenda de entregas** por `prazo_entrega` | AgendaView adaptada / nova |
| `lab-financeiro` | **A receber** das clínicas (a OS gera DESPESA na clínica = RECEITA no lab) | `protese_os.financeiro_id` (elo) |
| `lab-equipe` | **Técnicos** do laboratório (RBAC por cargo já existe) | `vinculos` + permissões de workspace |
| `notificacoes` / `configuracoes` | base | já existe |
A tela "PRÓTESES" da **clínica** (modo `clinica`) não muda — ela continua **enviando** OS.
O que muda é o **lado de quem recebe**, que ganha uma casa própria.
### Roteamento de OS: pessoa → laboratório
- Hoje: `bancada` filtra `WHERE protetico_id = me`.
- Fase 2: `bancada` filtra `WHERE laboratorio_id = activeWorkspace.id` (com fallback
`OR (laboratorio_id IS NULL AND protetico_id = me)` durante a transição).
- Ganho imediato: **mais de um técnico** pode atender a mesma bancada (equipe), e o protético
dono pode ter **mais de um laboratório**.
---
## 4. Modelo de dados — deltas da Fase 2
Tudo aditivo (migrações no padrão `ALTER TABLE … IF NOT EXISTS`, como a Fase 1):
```sql
-- workspace
clinicas.tipo -- novo valor possível: 'laboratorio'
-- escopo por laboratório (a empresa), além de protetico_id (a pessoa)
protese_os ADD laboratorio_id TEXT -- (já reservado; passa a ser populado/backfill)
protese_os ADD financeiro_lab_id TEXT -- RECEITA espelhada no lab (§9; idempotência)
protese_produtos ADD laboratorio_id TEXT
-- equipe do laboratório: novo role de vínculo (técnico não-dono)
vinculos.role -- incluir 'tecnico_protese' no CHECK constraint
-- relação clínica↔laboratório (opcional, p/ "meus laboratórios favoritos"/preços negociados)
CREATE TABLE IF NOT EXISTS lab_clientes (
id TEXT PRIMARY KEY,
laboratorio_id TEXT NOT NULL,
clinica_id TEXT NOT NULL,
apelido TEXT,
desconto_pct NUMERIC DEFAULT 0, -- preço negociado (futuro)
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (laboratorio_id, clinica_id)
);
```
Índices: `idx_protese_os_lab` passa a ser `(laboratorio_id, status)`.
---
## 5. Permissões (RBAC)
Reaproveita o RBAC por cargo que já existe na Sidebar (`wsRole`/`wsPerms`):
| Papel no laboratório | Pode |
|---|---|
| `protetico` (dono do lab) | tudo: catálogo, equipe, financeiro, status de qualquer OS |
| `tecnico_protese` (membro) | bancada + avançar status + fotos; **sem** financeiro/equipe/catálogo |
| clínica-cliente (`isClinic` em `proteseOsAccesso`) | criar OS, ver suas OS, entregar/cancelar, **sem** ver custo/bancada de outros |
`proteseOsAccesso(os, userId)` ganha o caso "membro do laboratório dono da OS"
(`vinculo em laboratorio_id`), além do dono atual.
---
## 6. Plano de implementação incremental
Cada passo é deployável e reversível (migrações aditivas → rollback de código é seguro).
- **2.0 — Dados (invisível):** migrações + backfill `laboratorio_id`; `bancada` lê por lab com
fallback por pessoa. Nada muda na UI. *Risco mínimo, valida o backfill em DEV (`pgdev`).*
- **2.1 — Casa própria + receita espelhada:** `tipo='laboratorio'` re-tagueado; Sidebar com
menu do laboratório; `lab-home` (painel) + `lab-bancada`; **espelho de RECEITA na entrega**
(§9) — o financeiro mínimo do lab já nesta fase, conforme decisão 3.
- **2.2 — Catálogo e clientes:** `ProdutosModal` vira `lab-catalogo`; tela `lab-clientes`.
- **2.3 — Financeiro completo e equipe:** `lab-financeiro` (extrato/a-receber por cliente,
sobre as receitas já lançadas na 2.1) + `tecnico_protese` (convite de membro reusando o
fluxo de equipe existente).
- **2.4 — Agenda de entregas + multi-laboratório** (expansão; modelo já suporta, sem migração).
---
## 7. O que **não** muda (contrato preservado)
- O lado **clínica** de `ProteseView` (modo `clinica`) e o envio de OS.
- A **Fase 1**: GTO finalizada continua abrindo OS (`gto_item_id`), agora já com
`laboratorio_id` preenchido na criação.
- `protetico_id` permanece em todas as tabelas — roteamento por pessoa segue funcionando
como fallback; nenhuma OS legada fica órfã.
---
## 8. Decisões de produto (fechadas em 23/jun/2026)
1. **Um laboratório por protético.** A UI assume 1 lab. O modelo de dados já suporta N
(`laboratorio_id` por OS/produto), então multi-lab é expansão futura **sem migração**.
2. **Laboratório terceirizado** (empresa separada que cobra da clínica). A flag `interno`
de `getProteseLaboratorios` fica como gancho futuro, mas **não** é tratada agora —
todo lab gera receita/despesa cruzada entre tenants.
3. **Financeiro espelhado já na 2.1.** A OS entregue, que hoje lança **DESPESA na clínica**
(`protese_os.financeiro_id`), passa a lançar simultaneamente uma **RECEITA no workspace
do laboratório**. Ver §9.
---
## 9. Financeiro espelhado (decisão 3) — DESPESA na clínica ↔ RECEITA no lab
Hoje a entrega da OS gera um lançamento de **DESPESA** na clínica de origem
(`protese_os.financeiro_id`), no valor de **`os.custo`** (o preço de catálogo do lab — o que a
clínica paga ao laboratório; `os.valor` é o preço ao paciente, markup da clínica, e **não**
entra aqui). Como o laboratório agora é um workspace de 1ª classe, o mesmo evento de entrega
lança um **par espelhado**, ambos sobre `os.custo`:
```
entrega da OS (status='entregue', custo>0, tipo_os≠'garantia')
├─► financeiro (DESPESA) clinica_id = os.clinica_id valor = os.custo [já existe]
└─► financeiro (RECEITA) clinica_id = os.laboratorio_id valor = os.custo [novo]
```
- Mesma **condição** da despesa já existente: só lança se `custo > 0` e `tipo_os ≠ 'garantia'`
(OS de garantia refaz sem cobrar). Sai no mesmo `if (novo === 'entregue')`.
- Idempotente, no padrão da Fase 1: novo `protese_os.financeiro_lab_id` guarda o id do
lançamento de receita; se já existe (`COALESCE`), não duplica.
- Isolado em `try/catch` para **nunca** derrubar a transição de status (mesma disciplina do
bloco GTO→protese_os da Fase 1).
- Estorno: cancelar/estornar a entrega deve reverter **os dois** lançamentos.
Por isso o **passo 2.1 já inclui o financeiro mínimo** (apenas o espelho de receita na
entrega); o item de menu `lab-financeiro` completo (extrato, a-receber por cliente) continua
na 2.3.
> ⚠️ Pré-requisito: `laboratorio_id` precisa estar populado **antes** de ligar o espelho
> (senão a receita nasce sem destino). Por isso a ordem **2.0 (backfill) → 2.1** é obrigatória.
+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).
+117
View File
@@ -0,0 +1,117 @@
# Modo 1 — Provedor de Prótese SEM conta (link seguro `/p/TOKEN`)
> Desenho (24/jun/2026). Evolução registrada como "fora de escopo" em `PROVEDOR-PROTESE-DECISAO.md`.
> Princípio (decisão arquitetural): **a clínica opera 100% da OS; o protético não precisa ter conta.**
> O link é a porta de entrada do ecossistema — acompanha hoje, vira conta amanhã.
## Objetivo
A clínica cria/gere a OS normalmente. O protético recebe um **link seguro** (WhatsApp/e-mail) e,
**sem login**, acompanha e atua na OS dele. Com CTA para criar conta grátis (conversão).
```
Clínica cria OS ──► gera link /p/TOKEN ──► WhatsApp/e-mail ──► Protético abre (sem conta)
├─ vê a OS (sem CPF/valores do paciente)
├─ confirma recebimento/devolução
├─ avança etapa / anexa foto
└─ responde ocorrência
"Crie sua conta grátis" ──► cadastro (card Protético)
```
## 1. Escopo do token: **por OS** (não por protético)
Escopo mínimo = menor superfície de risco. Um token dá acesso a **uma OS**. Vantagens: revogável
individualmente, expira por trabalho, não expõe a carteira inteira do protético. (Evolução: um token
"carteira" por protético-na-clínica que lista várias OS — fica para depois.)
## 2. Modelo de dados (revogável e auditável)
```sql
CREATE TABLE IF NOT EXISTS protese_os_link (
token TEXT PRIMARY KEY, -- aleatório longo (≥ 32 bytes base62), NÃO sequencial
os_id TEXT NOT NULL,
protetico_id TEXT, -- a quem se destina (rastreio)
created_by TEXT, -- quem na clínica gerou
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- opcional (ex.: +30 dias); null = sem expirar
revoked_at TIMESTAMPTZ, -- revogação manual
last_access_at TIMESTAMPTZ,
acessos INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_protese_os_link_os ON protese_os_link (os_id);
```
> Preferir tabela a JWT puro: permite **revogar** e **auditar acessos**. O token nunca contém dados.
## 3. Rotas públicas (sem `authGuard`; o token é a credencial)
Todas resolvem `os_id` pelo token (valida não-revogado, não-expirado) e aplicam a **mesma blindagem
do provedor**: sem `paciente_id`/CPF, sem valores do paciente (reusa a lógica `soLab`).
| Rota | Ação |
|---|---|
| `GET /api/p/:token` | OS: nº, clínica, dentista, paciente (nome, conforme permissão), prazo, status, observações, fotos, componentes, ocorrências, custódia. **Sem CPF/valores do paciente.** |
| `POST /api/p/:token/custodia` | confirmar recebimento / devolução (clínica↔lab) |
| `POST /api/p/:token/status` | avançar a produção (recebido→…→finalizado) |
| `POST /api/p/:token/foto` | anexar foto do trabalho (+ ocorrência) |
| `POST /api/p/:token/ocorrencias/:id/resposta` | responder ocorrência (contraditório) |
Cada ação registra no histórico da OS um **actor "Protético (link)"** com o `protetico_id` do token —
mantendo a auditoria. Mesmas validações de status/foto já existentes.
## 4. Geração e envio (lado clínica)
Na OS (modal, aba Etapas ou Monitoramento): botão **"Enviar ao protético"** →
`POST /api/protese/os/:id/link` gera/recupera o token e devolve a URL
`https://scoreodonto.com/p/TOKEN`. Atalhos: **WhatsApp** (`https://wa.me/?text=…`) e **e-mail**
(`mailto:`). Botão **"Revogar link"** para invalidar.
## 5. Frontend público `/p/:token`
Rota **fora do app autenticado** (sem Sidebar/login). Página enxuta que reusa as seções do
`OSDetailModal` em modo "público/lab" (etapas, fotos, componentes, custódia, ocorrências — sem
financeiro/clientes). Topo com a marca + rodapé fixo:
> *"Gostou de acompanhar por aqui? Crie sua conta grátis e gerencie todos os seus laboratórios."*
> → leva ao cadastro **já com o card Protético** (adicionado) e e-mail pré-preenchido.
## 6. Segurança (checklist)
- Token aleatório ≥ 32 bytes, base62, **não sequencial**; comparação em tempo constante.
- Escopo de **1 OS**; expiração opcional; **revogável**; rate-limit por token/IP.
- **Sem CPF e sem valores do paciente** (mesma blindagem `soLab` da Fase 1).
- Ações registram actor no histórico (auditoria); `last_access_at`/`acessos` para detectar abuso.
- OS `entregue`/`cancelado` → link vira **somente leitura**.
## 7. Conversão (o "porquê de negócio")
O link é aquisição: o protético experimenta o produto sem fricção e, ao criar conta, **herda o
histórico** (as OS daquela clínica já estão ligadas ao `protetico_id` dele). Liga direto ao
marketplace/score (Fase 5) — quanto mais clínicas o usam, mais OS e melhor o score do protético.
## 8. Faseamento sugerido
- **✅ 1a (núcleo) — IMPLEMENTADA 24/jun:** tabela `protese_os_link` (token aleatório 24 bytes
base64url, expira em 60 dias, revogável); `POST /api/protese/os/:id/link` (gera/recupera) +
`POST …/link/revogar`; `GET /api/p/:token` (leitura pública curada — sem CPF, sem valor do
paciente; conta acessos). Página pública `/p/:token` (`ProtesePublicView`, mobile-first 320px+,
interceptada no `index.tsx` antes do app autenticado); botão "Gerar link / WhatsApp / Copiar" na
aba Monitoramento (modo clínica). CTA "Criar conta grátis". Validado em DEV (leitura, blindagem,
token inválido→404, revogação→404).
- **✅ 1b (ação) — IMPLEMENTADA 24/jun:** o protético, pelo link, **confirma recebimento/devolução**
(`POST /api/p/:token/custodia`), **avança a produção** (`/status`, só `recebido…finalizado` — nunca
entregue/cancelado, que são da clínica) e **anexa foto** (`/foto`). Tudo auditado como
`"<nome> (link)"`. Página pública ganha a seção "Atualizar status" (botões de custódia, etapa e foto)
enquanto a OS não está finalizada. Validado em DEV: recebimento, avanço, foto, e `entregue` pelo link
→ 400 (bloqueado).
- **✅ 1c (gestão) — IMPLEMENTADA 24/jun:** UI da clínica (aba Monitoramento) gere o link:
`GET /api/protese/os/:id/link/status` traz **link ativo, nº de acessos, último acesso e expiração**;
botões **Copiar / WhatsApp / Revogar**. Validado em DEV: acessos contados, expiração 60 dias,
revogação → status inativo + leitura pública 404.
> Conversão por herança: nativa — a OS já aponta para o `protetico_id` do destinatário, então ao
> entrar/criar conta ele já é dono do histórico. (Vincular um cadastro NOVO com e-mail diferente às
> OS é caso de borda — fica para evolução, evitando duplicar usuário.)
**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.
+121
View File
@@ -0,0 +1,121 @@
# Decisão arquitetural — Provedor de Prótese (módulo de Prótese)
> Decisão de negócio/interface (24/jun/2026). Base: auditoria do código (`AUDITORIA-PROTESE.md`).
> Princípio-guia: **a Clínica controla 100% do fluxo, mesmo que o Protético nunca crie conta.**
> O login do Provedor é **alavanca de crescimento** (marketplace/ecossistema), não pré-requisito.
## Conceito
"**Provedor de Prótese**" é rótulo de **negócio/interface**. Internamente **NÃO se renomeia** o
role `protetico` (aparece 87× no backend + 15 arquivos no front — refatorar = risco alto, ganho
zero). As três formas são variações do **mesmo** workspace `tipo='laboratorio'`, diferenciadas por
metadados (futuros `pessoa_tipo`, `tem_equipe`), não por novos roles:
```
PROVEDOR DE PRÓTESE (role=protetico, workspace tipo='laboratorio')
├── Profissional Individual (PF)
├── Laboratório (PJ)
└── Laboratório com Equipe (PJ + técnicos)
```
## Modos de operação
- **Modo 1 — Protético SEM conta:** a Clínica opera tudo (OS, etapas, envio/recebimento/devolução,
pagamentos, componentes, ocorrências, encerramento). O Protético acompanha por **link seguro**
(`/p/TOKEN` via WhatsApp/e-mail), podendo confirmar recebimento/devolução, anexar fotos, responder
ocorrências. CTA: "crie sua conta ScoreOdonto".
- **Modo 2 — Protético COM conta:** Dashboard, Ordens, Produção, Entregas, Clientes, Financeiro,
Catálogo, Notificações, Perfil.
- **Modo 3 — Laboratório:** + Equipe, Relatórios, Marketplace, Configurações.
## Cadastro/Login
Card próprio **[ PROTÉTICO / LABORATÓRIO ]** com opção PF (Protético Individual) / PJ (Laboratório).
Campos futuros (não obrigatórios no MVP): Nome, Nome Fantasia, CPF, CNPJ, CROT, Responsável Técnico,
Cidade, Estado, Especialidades, Telefone, E-mail. (Campo `clinicas.documento` já existe para CPF/CNPJ.)
## Rastreabilidade de componentes (modelo)
Uma tabela (`protese_os_componente`) com eixo de direção/conferência, não duas:
`enviado pela clínica → recebido pelo protético → devolvido pelo protético → conferido pela clínica`.
A **ocorrência referencia o componente** (FK opcional).
## Ocorrências + Contraditório
Tabela própria. Categorias: não devolvido, danificado, incompatível, parafuso substituído/desgastado,
peça incorreta, ajuste inadequado, falha de fabricação, retrabalho, outro. Campos: autor, data,
categoria, descrição, fotos, evidências, anexos, responsável, status.
**Fluxo com contraditório** (proteção dos dois lados): `Aberta → Respondida → Em análise → Resolvida`
ou `Contestada`. **Só ocorrências resolvidas** alimentam indicadores — nunca automático.
## Abas da OS (alvo)
`Dados · Fotos · Componentes · Pagamentos · Custódia · Histórico · Ocorrências` (nova).
## Indicadores (POR ÚLTIMO)
Capturar primeiro (eventos, componentes, ocorrências, evidências, histórico); só depois derivar
Score/ranking/marketplace. Não codar indicador antes de existir dado.
---
## Roadmap (5 fases)
### ✅ FASE 1 — Origem da OS + LGPD (IMPLEMENTADA 24/jun)
- **Clínica de origem** na bancada e no detalhe (`clinica_nome` via subquery) — visível ao protético
(badge na lista e no modal). Destrava o cenário multi-clínica.
- **Blindagem de CPF:** confirmado que `pacientes.id` **É o CPF**. O protético (lab) deixa de receber
`paciente_id` (detalhe e bancada zeram), mantendo `paciente_nome`. A clínica continua vendo o CPF.
- Validado em DEV: protético vê `clinica_nome` + `paciente_id:null`; clínica vê tudo.
### ✅ FASE 2 — Rastreabilidade & Ocorrências (IMPLEMENTADA 24/jun)
- **Componentes** ganham `direcao` (enviado/devolvido) e `status_conferencia` (pendente/ok/divergente/
ausente, com `conferido_nome/_em`). Endpoint `POST .../componentes/:id/conferir`.
- **Ocorrências** (`protese_os_ocorrencia`): 10 categorias, descrição, responsável, autor, status.
Evidências via `protese_os_foto.ocorrencia_id` (reusa a infra de mídia).
- **Contraditório**: `aberta → respondida → em_analise → resolvida | contestada`; `resolvida` é final
(409 ao remexer). Endpoints `/ocorrencias`, `/ocorrencias/:id/resposta`, `/ocorrencias/:id/status`.
- **UI**: nova aba **Ocorrências** no modal (com contador) + conferência inline nos componentes.
- Validado em DEV: componente `divergente`; ocorrência `parafuso_substituido` percorrendo todo o
contraditório até `resolvida`; resolvida final (409). Indicadores ficam para a Fase 5.
### ✅ FASE 3 — Operação multi-clínica (IMPLEMENTADA 24/jun)
- **Clientes** (tela `lab-clientes`): tabela das clínicas que enviam OS, com OS totais, ativas,
atrasadas, entregues, faturado e a-receber por cliente. Endpoint `GET /protese/lab/clientes`.
- **Financeiro do lab** (tela `lab-financeiro`): KPIs (faturado/recebido/a-receber) + extrato de
recebimentos (pagamentos lado lab) com clínica/paciente/forma/data. Endpoint `GET /protese/lab/pagamentos`.
Regra: faturado = custo das OS entregues (exceto garantia); a-receber = faturado recebido.
- Menu do laboratório agora: Painel · Bancada · **Clientes** · **Financeiro** · Notificações · Config.
- Histórico completo: a Bancada já lista entregues/cancelados com filtro de status (mantida).
- Validado em DEV: agregação por clínica (faturado 600 / recebido 250 / a-receber 350) + extrato.
### ✅ FASE 4 — Empresa de verdade (IMPLEMENTADA 24/jun)
- **PF/PJ + documento**: `clinicas.pessoa_tipo` (PF=individual | PJ=laboratório); `documento` = CPF/CNPJ
(já existia, agora editável). Tela **Laboratório & Equipe** (`lab-equipe`): GET/PATCH `/protese/lab`.
- **Equipe/Técnicos**: novo `vinculos.role='tecnico_protese'`. O dono convida por e-mail
(`ensureUsuarioPorEmail` → credencial temporária); endpoints `GET/POST/DELETE /protese/lab/equipe`.
- **Acesso estendido**: `proteseOsAccesso` reconhece o técnico (vínculo no `laboratorio_id`); a bancada
usa `labDoUsuario` (dono **ou** técnico). Técnico opera a produção; **não** vê clientes/financeiro
(menu reduzido + endpoints retornam vazio/403). Gestão é exclusiva do dono.
- Validado em DEV: dados PJ+CNPJ; técnico convidado acessa a bancada (vê clínica de origem), não vê
clientes (0) e leva 403 ao tentar gerenciar equipe.
### ✅ FASE 5 — Indicadores & Score (IMPLEMENTADA 24/jun)
- **Indicadores derivados** (`GET /protese/lab/indicadores`, só dono) calculados sobre a captura das
fases 2-4: entregues, tempo médio (solicitado→entregue), atraso %, retrabalho % (garantia),
ocorrências resolvidas %, componentes ausentes. Tela `lab-indicadores`.
- **Nota ScoreOdonto** (0-10): `10 (atraso%×3 + retrabalho%×4 + ocorrência%×3)`, só com **mínimo de
3 entregas** (senão "score em formação"). Só ocorrências **resolvidas** entram (contraditório respeitado).
- **Ranking no marketplace**: `getProteseLaboratorios` passa a devolver a `nota` por protético e ordena
internos→maior nota→nome; a clínica vê `★ nota` no dropdown de Nova OS e do Lançar GTO.
- Validado em DEV: 4 entregas (1 atrasada, 1 garantia) → atraso 25% / retrab 25% / **nota 8.3**; a
clínica vê a nota no dropdown; "score em formação" abaixo do mínimo.
> Acoplamentos respeitados: PF/PJ veio com a equipe (Fase 4); Score só após a captura (Fases 2-4).
### 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.
+4 -4
View File
@@ -1,6 +1,6 @@
# Qual é a verdade hoje? — Auditoria de infra ScoreOdonto
> Resposta direta às 6 perguntas essenciais, **verificada em 17/jun/2026**. Somente fatos do estado atual.
> Resposta direta às 6 perguntas essenciais, **verificada em 23/jun/2026**. Somente fatos do estado atual.
> **Documento principal: em caso de divergência entre docs, este prevalece.**
> Como operar → `DEPLOY-PRODUCAO.md` (runbook). Para onde vamos → `deploy.md` (arquitetura-alvo).
@@ -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.16 · commit `471c2c2`**.
- **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.16 · `471c2c2` · 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.16**), 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)
+1
View File
@@ -35,6 +35,7 @@ services:
- "./backend/.env"
environment:
- BAILEYS_ENGINE=infinite
- TZ=America/Sao_Paulo
- PORT=8018
- NODE_ENV=production
# Senha do banco vem do ambiente (.env raiz em DEV; provisionado fora do git em PROD).
+28 -15
View File
@@ -1,8 +1,8 @@
# Status — Segurança & Produção (ScoreOdonto)
> Resumo do que foi feito e do que falta. Atualizado em **19/jun/2026**.
> Produção no ar: **v1.0.18** (`fc00622`). **Todos os commits de código estão LOCAIS (sem `git push`)** —
> exceto a rotação do `JWT_SECRET`, que **já foi aplicada e verificada em produção**.
> Resumo do que foi feito e do que falta. Atualizado em **22/jun/2026**.
> Produção no ar: **v1.0.19** (`715b2dc`) — fila **A** concluída (push → CI → tag → deploy). **Commits
> publicados (`git push` feito).** A rotação do `JWT_SECRET` segue aplicada e verificada em produção.
---
@@ -48,20 +48,28 @@ do frontend, via 3 helpers: `requireCapability`, `requireCapabilityRow`, `requir
### 5. Pré-requisito de produção
- **`SCOREO_DB_PASS` provisionado na VPS1** (`/home/deploy/stack/scoreodonto.com/.env`, 0600), valores
extraídos do container em execução, `.gitignore` da VPS1 ajustado, conexão validada (`SELECT 1` = ok).
**Produção não foi tocada.**
### 6. Fila A concluída — **PRODUÇÃO EM `v1.0.19`** (22/jun/2026)
- **`git push` do lote** (RBAC 99 rotas + segredos fora do compose/imagem + correções de frontend).
- **CI desbloqueada:** o build falhou na 1ª vez (`SCOREO_DB_PASS` com `:?` quebrava a interpolação do
compose no `docker compose build` do runner) → corrigido com placeholder runtime-only no `build.yml`
(commit `715b2dc`). Build verde → imagens `:715b2dc` (back+front, atômicas) publicadas no registry.
- **Tag `v1.0.19``715b2dc`:** job `promote` re-tagueou `:715b2dc``:v1.0.19` **sem rebuild**
(digests idênticos conferidos) e rodou `deploy-prod.sh v1.0.19` na VPS1.
- **Backup do banco antes do deploy:** `~/backups/scoreodonto_20260622_0005.dump` na VPS1 (PG18).
- **Verificado em produção:** `/api/version` = `v1.0.19` (`715b2dc`, PROD); `/api/health` = ok
(DB ok, cache ok). DEV (8020) realinhado ao mesmo commit via `./dev-build.sh` (mostra `v1.0.19`).
- **Rollback trivial:** `./deploy-prod.sh v1.0.18` (+ dump do banco, se necessário).
---
## ⏳ O QUE FALTA
### A) Levar o trabalho a produção (fila)
1. **Imagem limpa `v1.0.19`** (remove segredos embutidos na imagem):
`git push` → CI builda `:<sha>` com `.dockerignore``git tag v1.0.19``deploy-prod.sh v1.0.19`
validar `/api/version` + `/api/health`. **Rollback trivial:** `deploy-prod.sh v1.0.18`.
- Leva junto: RBAC (99 rotas), correções de frontend, etc.
### A) Levar o trabalho a produção (fila) — ✅ CONCLUÍDA
1. ~~**Imagem limpa `v1.0.19`**~~**FEITO** (ver "Fila A concluída" acima). Produção em `v1.0.19`/`715b2dc`.
2. **Compose novo na VPS1 (opcional, passo 2b):** `deploy-prod.sh` **não faz `git pull`** → o
`docker-compose.yml` da VPS1 segue o antigo. Para tirar a senha hardcoded do compose local, copiar o
novo `docker-compose.yml` (scp) + recreate. O root `.env` já provisionado cobre isso.
novo `docker-compose.yml` (scp) + recreate. O root `.env` já provisionado cobre isso. **Ainda pendente.**
### B) Rotação dos demais segredos (ainda vivos no histórico/artefatos antigos)
3. **Senha do banco** (`scoreodonto_user`) — contida ao scoreodonto; exige refactor já feito no compose +
@@ -88,11 +96,12 @@ do frontend, via 3 helpers: `requireCapability`, `requireCapabilityRow`, `requir
---
## Commits locais da sessão (nenhum `push`, exceto JWT já aplicado em prod)
> **13 commits desta sessão** (incluindo este documento) ainda **locais**. O repositório está
> `ahead 14` de `origin/main` — o 14º (`7c5b199 docs(LEIA)`) é anterior a esta sessão.
## Commits da sessão — **PUBLICADOS** (`git push` feito; produção em `v1.0.19`)
> Todos empurrados para `origin/main`. Promovidos a produção via tag `v1.0.19` (commit `715b2dc`).
> A rotação do `JWT_SECRET` **não** está nesta lista (foi ação operacional na VPS, sem commit).
```
715b2dc ci(build): desbloqueia build — SCOREO_DB_PASS placeholder na interpolação
8a27f3b docs+infra: corrige status (JWT/contagem) + versão em DEV (dev-build.sh)
6adf353 docs: status geral de segurança & produção (este documento)
f22e30e docs(skills): completa as 62 skills
e83af04 docs(skills): biblioteca operacional
@@ -110,7 +119,11 @@ fcf2fb1 security(infra): .env fora do git + .dockerignore
```
## Como retomar
- **Para publicar:** seguir a fila **A** (push → `v1.0.19` → deploy → validar health/version).
- **Fila A:** ✅ concluída — produção em **`v1.0.19`** (`715b2dc`). Próximo release: `git push` → CI
builda `:<sha>``git tag vX.Y.Z` → job `promote` deploya na VPS1.
- **Antes de abrir a usuários reais:** concluir **B** (rotações) — sobretudo as API keys (você) e a senha do banco.
- Tudo de código testado em **DEV** (`dev.scoreodonto.com`, banco `pgdev`); produção segue **v1.0.18** intacta.
- **Pendência pontual:** passo **A.2** (compose novo na VPS1 via scp + recreate) para tirar a senha
hardcoded do compose local.
- Código rodando em **DEV** (`dev.scoreodonto.com`) e **PROD** no mesmo commit `715b2dc`; rollback de
prod: `./deploy-prod.sh v1.0.18` (+ dump `~/backups/scoreodonto_20260622_0005.dump` se necessário).
</content>
+114 -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';
@@ -31,6 +36,12 @@ import { ReportsView } from './views/ReportsView.tsx';
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';
import { OrcamentosView } from './views/OrcamentosView.tsx';
import { ContratosView } from './views/ContratosView.tsx';
@@ -80,10 +91,21 @@ 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'
| 'criar-clinica'
| 'rx-config'
@@ -99,6 +121,7 @@ export type ViewKey =
| 'superadmin-logs'
| 'superadmin-financeiro'
| 'superadmin-notificacoes'
| 'superadmin-secretaria'
| 'diretorio-profissionais';
// ------- URL <-> VIEW mapping -------
@@ -130,10 +153,21 @@ 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',
'/criar-clinica': 'criar-clinica',
'/rx-config': 'rx-config',
@@ -148,6 +182,7 @@ const ROUTE_MAP: Record<string, ViewKey> = {
'/superadmin/logs': 'superadmin-logs',
'/superadmin/financeiro': 'superadmin-financeiro',
'/superadmin/notificacoes': 'superadmin-notificacoes',
'/superadmin/secretaria': 'superadmin-secretaria',
'/aguardando-convite': 'aguardando-convite',
'/diretorio-profissionais': 'diretorio-profissionais',
'/proteses': 'proteses',
@@ -185,10 +220,21 @@ 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',
'criar-clinica': '/criar-clinica',
'rx-config': '/rx-config',
@@ -204,6 +250,7 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
'superadmin-logs': '/superadmin/logs',
'superadmin-financeiro': '/superadmin/financeiro',
'superadmin-notificacoes': '/superadmin/notificacoes',
'superadmin-secretaria': '/superadmin/secretaria',
'diretorio-profissionais': '/diretorio-profissionais',
proteses: '/proteses',
};
@@ -271,6 +318,21 @@ const MobileBottomNav: React.FC<{
const App: React.FC = () => {
const ver = useAppVersion();
const [isSidebarOpen, setSidebarOpen] = useState(false);
// Evita que a sidebar ANIME (slide-in) a cada reload no desktop: a transição só liga
// após o 1º paint, então no load ela já nasce posicionada; toggles do usuário animam.
const [uiMounted, setUiMounted] = useState(false);
useEffect(() => { setUiMounted(true); }, []);
// Gaveta de controles da Agenda no MOBILE (título/AGENDAR/ações) — comporta-se como
// a sidebar: some por padrão p/ dar o máximo de espaço ao calendário; abre pelo ⋯.
const [agendaControlsOpen, setAgendaControlsOpen] = useState(false);
// Recolher a sidebar no DESKTOP (persistente) — dá largura ao conteúdo (ex.: agenda).
const [isSidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
try { return localStorage.getItem('scoreodonto:sidebar-collapsed') === '1'; } catch { return false; }
});
const toggleSidebarCollapsed = (v: boolean) => {
setSidebarCollapsed(v);
try { localStorage.setItem('scoreodonto:sidebar-collapsed', v ? '1' : '0'); } catch { /* ignore */ }
};
const [, forcePwRerender] = useState(0); // re-avalia o modal de troca obrigatória de senha
const currentRole = HybridBackend.getCurrentRole();
const activeWorkspace = HybridBackend.getActiveWorkspace();
@@ -285,8 +347,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-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.
@@ -305,7 +372,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', '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'],
};
@@ -316,6 +383,8 @@ const App: React.FC = () => {
if (role === 'superadmin') return 'superadmin-overview';
// Contexto de SALA (workspace tipo='sala') sempre cai no painel da sala.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return 'sala-home';
// Contexto de LABORATÓRIO (workspace tipo='laboratorio') cai no painel do lab.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return 'lab-home';
if (role === 'paciente') return 'tratamentos';
if (role === 'donosala') return 'minhas-salas';
return 'dashboard';
@@ -449,7 +518,7 @@ const App: React.FC = () => {
case 'leads': return <LeadsView />;
case 'public': return <PublicContactForm />;
case 'pacientes': return <PatientsView />;
case 'agenda': return <AgendaView onNavigate={handleNavigate} />;
case 'agenda': return <AgendaView onNavigate={handleNavigate} sidebarCollapsed={isSidebarCollapsed} onToggleSidebar={() => toggleSidebarCollapsed(!isSidebarCollapsed)} controlsOpen={agendaControlsOpen} onCloseControls={() => setAgendaControlsOpen(false)} />;
case 'ortodontia': return <OrthoView />;
case 'financeiro': return <FinanceiroView />;
case 'dentistas': return <DentistasView />;
@@ -472,10 +541,21 @@ 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 />;
case 'criar-clinica': return <CreateClinicView onCreated={() => { window.location.reload(); }} />;
case 'rx-config': return <RxConfigView />;
@@ -491,6 +571,7 @@ const App: React.FC = () => {
case 'superadmin-logs': return <SuperAdminView tab="logs" />;
case 'superadmin-financeiro': return <SuperAdminView tab="financeiro" />;
case 'superadmin-notificacoes': return <SuperAdminView tab="notificacoes" />;
case 'superadmin-secretaria': return <SuperAdminView tab="secretaria" />;
// Plugin Profissionais ativo absorve o diretório antigo (mesma rota, view nova).
case 'diretorio-profissionais': return isPluginActive('profissionais-marketplace') ? <ProfissionaisPlugin /> : <DiretorioProfissionaisView />;
case 'login':
@@ -499,7 +580,11 @@ const App: React.FC = () => {
}
};
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite'].includes(currentView);
// Views do plugin NewWhats rodam em tela cheia (sem sidebar/barra do
// scoreodonto) — cada uma tem seu próprio "Voltar" (onNavigate) para sair.
// /wa-sessions (gestão de sessões WhatsApp) roda COM a sidebar do scoreodonto
// (é área de administração). wa-inbox/wa-secretaria seguem em tela cheia.
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite', 'wa-inbox', 'wa-secretaria'].includes(currentView);
return (
<>
@@ -525,7 +610,7 @@ const App: React.FC = () => {
></div>
)}
<div className={`fixed inset-y-0 left-0 z-30 transform transition-transform duration-300 md:relative md:translate-x-0 ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<div className={`fixed inset-y-0 left-0 z-30 transform ${uiMounted ? 'transition-transform duration-300' : ''} ${isSidebarCollapsed ? 'md:hidden' : 'md:relative md:translate-x-0'} ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<Sidebar
activeTab={currentView}
setActiveTab={(t) => handleNavigate(t as ViewKey)}
@@ -534,6 +619,18 @@ const App: React.FC = () => {
</>
)}
{/* Botão flutuante para reexibir a sidebar (desktop, quando recolhida).
Na agenda NÃO aparece — ela já tem o próprio toggle ao lado do título. */}
{!isStandaloneView && isSidebarCollapsed && currentView !== 'agenda' && (
<button
onClick={() => toggleSidebarCollapsed(false)}
title="Mostrar menu"
className="hidden md:flex fixed top-3 left-3 z-40 items-center justify-center w-9 h-9 bg-white border border-gray-200 rounded-lg text-gray-500 hover:text-gray-800 hover:border-gray-300 shadow-sm transition-colors"
>
<PanelLeftOpen size={18} />
</button>
)}
<main className={`flex-1 h-full relative flex flex-col min-w-0`}>
{HybridBackend.isPreviewMode() && (
<div className="bg-red-600 text-white px-4 py-2 flex items-center justify-between gap-3 shadow-md z-30">
@@ -566,7 +663,18 @@ const App: React.FC = () => {
</div>
<span className="font-bold text-gray-800 text-sm tracking-tight uppercase">SCOREODONTO</span>
</div>
<div className="w-8"></div>
{/* ⋯ controles da Agenda (só nesta view) — abre a gaveta; senão, espaçador */}
{currentView === 'agenda' ? (
<button
onClick={() => setAgendaControlsOpen(true)}
className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
title="Controles da agenda"
>
<MoreHorizontal size={24} />
</button>
) : (
<div className="w-8"></div>
)}
</div>
</>
)}
+9 -1
View File
@@ -6,7 +6,7 @@ import { AvaliacaoEditor } from './AvaliacaoEditor.tsx';
import {
User, Stethoscope, Clock, CalendarRange, AlertCircle, CheckCircle2,
History, RotateCcw, CalendarClock, Ban, Users, Pencil, ClipboardCheck, ShieldAlert,
Search, UserPlus, Link2, Trash2, ClipboardList
Search, UserPlus, Link2, Trash2, ClipboardList, MessageCircle
} from 'lucide-react';
interface Props {
@@ -17,6 +17,7 @@ interface Props {
onChanged: () => void; // recarrega a agenda
onEditar: () => void; // abre o modal de edição/reagendar (mover de horário)
onAtender?: () => void;
onVerWhats?: (info: { pacienteId: string; pacienteNome: string }) => void; // abre o drawer de WhatsApp do paciente
podeAtender?: boolean;
dentistaRestrito?: boolean; // dentista linkado por clínica: modal simples, sem dados sensíveis/agendamento
}
@@ -492,6 +493,13 @@ const DetailPage: React.FC<Props> = (p) => {
{/* Navegação / ações principais */}
<div className="space-y-2">
{p.onVerWhats && pacienteId && (
<button onClick={() => p.onVerWhats?.({ pacienteId, pacienteNome: nome })}
className="w-full flex items-center justify-between px-4 py-2.5 rounded-xl bg-emerald-50 hover:bg-emerald-100 transition-colors text-xs font-black uppercase text-emerald-700">
<span className="flex items-center gap-2"><MessageCircle size={15} /> Ver WhatsApp</span>
<span className="text-emerald-300"></span>
</button>
)}
<button onClick={() => push({ title: 'HISTÓRICO', render: () => <HistoryPage appointmentId={ag.id} /> })}
className="w-full flex items-center justify-between px-4 py-2.5 rounded-xl bg-gray-50 hover:bg-gray-100 transition-colors text-xs font-black uppercase text-gray-600">
<span className="flex items-center gap-2"><History size={15} /> Histórico</span>
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { HorariosConfig } from './HorariosConfig.tsx';
import { X, ChevronLeft, ChevronRight, Save, Mail, Calendar, Settings as GearIcon, CheckCircle2 } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { Settings, Dentista } from '../types.ts';
@@ -22,6 +23,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
});
const [isSaving, setIsSaving] = useState(false);
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
const [showHorarios, setShowHorarios] = useState(false);
const toast = useToast();
// Escopo: tudo (status Google, convites, settings) é da clínica/workspace ativa.
const clinicaId = HybridBackend.getActiveWorkspace()?.id;
@@ -84,6 +86,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
const adminAccount = connectedAccounts.find(a => a.owner_id && a.owner_id.includes('@'));
return (
<>
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-md p-0">
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-[2.5rem] flex flex-col overflow-hidden border border-gray-100 animate-in fade-in zoom-in-95 duration-300">
@@ -135,6 +138,15 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
/>
<p className="text-[10px] text-gray-400 italic ml-1">* Este e-mail será usado para alertas de sistema e envio de comprovantes.</p>
</div>
<button onClick={() => setShowHorarios(true)}
className="w-full flex items-center justify-between bg-teal-50 hover:bg-teal-100 border-2 border-teal-100 rounded-2xl p-4 transition-colors group">
<div className="text-left">
<p className="text-sm font-black text-teal-800 uppercase tracking-tight">Horários de Funcionamento</p>
<p className="text-[10px] font-bold text-teal-500 uppercase tracking-widest mt-0.5">Dias/horas da clínica · feriados · horários dos dentistas</p>
</div>
<span className="text-teal-600 font-black text-lg group-hover:translate-x-1 transition-transform"></span>
</button>
</div>
</div>
@@ -275,5 +287,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
}
`}</style>
</div>
<HorariosConfig isOpen={showHorarios} onClose={() => setShowHorarios(false)} clinicaId={clinicaId} />
</>
);
};
+333
View File
@@ -0,0 +1,333 @@
// Configuração de horários de funcionamento (clínica) + feriados/fechamentos e
// horários dos dentistas. Consome /api/nw/agenda-config (o backend garante o
// ownership: horário/feriado da clínica = dono; horário do dentista = ele ou dono).
import React, { useState, useEffect, useCallback } from 'react';
import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy, AlertTriangle } from 'lucide-react';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
const BASE = `${API}/nw/agenda-config`;
const DIAS = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado'];
async function req(path: string, opts: RequestInit = {}) {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const res = await fetch(`${BASE}${path}`, {
...opts,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) },
});
const txt = await res.text();
const data = txt ? JSON.parse(txt) : {};
if (!res.ok) throw new Error(data.error || `Erro ${res.status}`);
return data;
}
interface Faixa { dia_semana: number; hora_inicio: string; hora_fim: string; hora_inicio_flex?: string | null; hora_fim_flex?: string | null }
interface Dentista { id: string; nome: string; usuario_id: string | null; eh_voce: boolean }
// Editor de grade semanal (7 dias, cada um com 0..n faixas). Reusado p/ clínica e dentista.
const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void; disabled?: boolean; withFlex?: boolean }> = ({ faixas, onChange, disabled, withFlex }) => {
const porDia = (d: number) => faixas.filter((f) => f.dia_semana === d);
const setDia = (d: number, novas: Faixa[]) => onChange([...faixas.filter((f) => f.dia_semana !== d), ...novas]);
// Copia as faixas do dia `d` para TODOS os outros dias.
const duplicarParaTodos = (d: number) => {
const base = porDia(d).map((f) => ({ hora_inicio: f.hora_inicio, hora_fim: f.hora_fim }));
onChange(DIAS.flatMap((_, x) => base.map((f) => ({ dia_semana: x, ...f }))));
};
return (
<div className="space-y-2">
{DIAS.map((nome, d) => {
const fs = porDia(d);
return (
<div key={d} className="flex items-start gap-3 py-2 border-b border-gray-50">
<div className="w-24 shrink-0 pt-2 text-xs font-black text-gray-700 uppercase tracking-tight">{nome}</div>
<div className="flex-1 space-y-2">
{fs.length === 0 && <span className="text-[11px] text-gray-400 italic">Fechado</span>}
{fs.map((f, i) => {
const upd = (patch: Partial<Faixa>) => { const n = [...fs]; n[i] = { ...f, ...patch }; setDia(d, n); };
return (
<div key={i} className="space-y-1">
<div className="flex items-center gap-2">
<input type="time" disabled={disabled} value={f.hora_inicio} onChange={(e) => upd({ hora_inicio: e.target.value })}
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" />
<span className="text-gray-400 text-xs font-bold">até</span>
<input type="time" disabled={disabled} value={f.hora_fim} onChange={(e) => upd({ hora_fim: e.target.value })}
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" />
{withFlex && <span className="text-[9px] font-black text-teal-500 uppercase tracking-wide">IA agenda livre</span>}
{!disabled && <button onClick={() => setDia(d, fs.filter((_, j) => j !== i))} className="text-gray-300 hover:text-red-500 p-1"><Trash2 size={15} /></button>}
</div>
{withFlex && (
<div className="flex items-center gap-2 flex-wrap pl-1 text-[11px] text-gray-500">
<span className="font-bold text-amber-600">Aceito confirmar também:</span>
<span>antes, a partir de</span>
<input type="time" disabled={disabled} value={f.hora_inicio_flex ?? ''} onChange={(e) => upd({ hora_inicio_flex: e.target.value || null })}
className="bg-amber-50 border-2 border-transparent focus:border-amber-500 rounded-lg px-2 py-1 text-xs font-bold text-gray-800 outline-none w-24" />
<span>e depois, até</span>
<input type="time" disabled={disabled} value={f.hora_fim_flex ?? ''} onChange={(e) => upd({ hora_fim_flex: e.target.value || null })}
className="bg-amber-50 border-2 border-transparent focus:border-amber-500 rounded-lg px-2 py-1 text-xs font-bold text-gray-800 outline-none w-24" />
</div>
)}
</div>
);
})}
{!disabled && (
<button onClick={() => setDia(d, [...fs, { dia_semana: d, hora_inicio: '08:00', hora_fim: '18:00' }])}
className="text-[11px] font-black text-teal-600 hover:text-teal-800 uppercase tracking-wide flex items-center gap-1">
<Plus size={13} /> {fs.length ? 'Outra faixa' : 'Abrir neste dia'}
</button>
)}
{!disabled && fs.length > 0 && (
<button onClick={() => duplicarParaTodos(d)} title="Aplicar este mesmo horário a todos os dias da semana"
className="text-[10px] font-black text-gray-400 hover:text-teal-600 uppercase tracking-wide flex items-center gap-1">
<Copy size={11} /> Copiar p/ todos os dias
</button>
)}
</div>
</div>
);
})}
</div>
);
};
export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas' | 'situacoes'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => {
const [aba, setAba] = useState<'clinica' | 'dentistas' | 'situacoes'>(initialTab || 'clinica');
useEffect(() => { if (isOpen && initialTab) setAba(initialTab); }, [isOpen, initialTab]);
const [erro, setErro] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Clínica
const [clinFaixas, setClinFaixas] = useState<Faixa[]>([]);
const [feriados, setFeriados] = useState<{ excecoes: any[]; nacionais: any[] }>({ excecoes: [], nacionais: [] });
const [novo, setNovo] = useState({ data: '', nome: '', fecha: true });
// Dentistas
const [dentistas, setDentistas] = useState<Dentista[]>([]);
const [dentSel, setDentSel] = useState<string>('');
const [dentFaixas, setDentFaixas] = useState<Faixa[]>([]);
const [folgas, setFolgas] = useState<any[]>([]);
const [novaFolga, setNovaFolga] = useState({ data_inicio: '', data_fim: '', motivo: '' });
// Situações (regras que a Secretária IA segue)
const [situacoes, setSituacoes] = useState<string[]>([]);
const flash = (setter: (v: string | null) => void, msg: string) => { setter(msg); setTimeout(() => setter(null), 3000); };
const carregarClinica = useCallback(async () => {
if (!clinicaId) return;
try {
const h = await req(`/clinica/${clinicaId}/horarios`);
setClinFaixas((h.horarios || []).map((r: any) => ({ dia_semana: r.dia_semana, hora_inicio: r.hora_inicio, hora_fim: r.hora_fim })));
const f = await req(`/clinica/${clinicaId}/feriados?ano=${new Date().getFullYear()}`);
setFeriados({ excecoes: f.excecoes || [], nacionais: f.nacionais || [] });
} catch (e: any) { flash(setErro, e.message); }
}, [clinicaId]);
const carregarDentistas = useCallback(async () => {
if (!clinicaId) return;
try {
const d = await req(`/clinica/${clinicaId}/dentistas`);
let list: Dentista[] = d.dentistas || [];
if (soDentista) { const meus = list.filter((x) => x.eh_voce); if (meus.length) list = meus; } // dentista só vê o próprio
setDentistas(list);
const first = list.find((x: Dentista) => x.eh_voce) || list[0];
if (first) setDentSel(first.id);
} catch (e: any) { flash(setErro, e.message); }
}, [clinicaId, soDentista]);
const carregarSituacoes = useCallback(async () => {
if (!clinicaId) return;
try { const s = await req(`/clinica/${clinicaId}/situacoes`); setSituacoes((s.situacoes || []).map((r: any) => r.texto)); }
catch (e: any) { flash(setErro, e.message); }
}, [clinicaId]);
useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); if (!soDentista) carregarSituacoes(); } }, [isOpen, carregarClinica, carregarDentistas, carregarSituacoes, soDentista]);
const carregarFolgas = useCallback((id: string) => {
req(`/dentista/${id}/folgas`).then((f) => setFolgas(f.folgas || [])).catch(() => setFolgas([]));
}, []);
useEffect(() => {
if (!dentSel) { setDentFaixas([]); setFolgas([]); return; }
req(`/dentista/${dentSel}/horarios`).then((h) =>
setDentFaixas((h.horarios || []).map((r: any) => ({ dia_semana: r.dia_semana, hora_inicio: r.hora_inicio, hora_fim: r.hora_fim, hora_inicio_flex: r.hora_inicio_flex, hora_fim_flex: r.hora_fim_flex })))
).catch((e) => flash(setErro, e.message));
carregarFolgas(dentSel);
}, [dentSel, carregarFolgas]);
const addFolga = async () => {
if (!novaFolga.data_inicio) { flash(setErro, 'Informe a data.'); return; }
try { await req(`/dentista/${dentSel}/folgas`, { method: 'POST', body: JSON.stringify(novaFolga) }); setNovaFolga({ data_inicio: '', data_fim: '', motivo: '' }); carregarFolgas(dentSel); flash(setOk, 'Folga adicionada!'); }
catch (e: any) { flash(setErro, e.message); }
};
const delFolga = async (id: string) => {
try { await req(`/dentista/${dentSel}/folgas/${id}`, { method: 'DELETE' }); carregarFolgas(dentSel); } catch (e: any) { flash(setErro, e.message); }
};
const salvarClinica = async () => {
setSaving(true); setErro(null);
try { await req(`/clinica/${clinicaId}/horarios`, { method: 'PUT', body: JSON.stringify({ horarios: clinFaixas }) }); flash(setOk, 'Horário da clínica salvo!'); }
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
};
const salvarDentista = async () => {
setSaving(true); setErro(null);
try { await req(`/dentista/${dentSel}/horarios`, { method: 'PUT', body: JSON.stringify({ horarios: dentFaixas }) }); flash(setOk, 'Horário do dentista salvo!'); }
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
};
const addFeriado = async () => {
if (!novo.data || !novo.nome) { flash(setErro, 'Informe data e nome.'); return; }
try { await req(`/clinica/${clinicaId}/feriados`, { method: 'POST', body: JSON.stringify(novo) }); setNovo({ data: '', nome: '', fecha: true }); carregarClinica(); flash(setOk, 'Fechamento adicionado!'); }
catch (e: any) { flash(setErro, e.message); }
};
const delFeriado = async (id: string) => {
try { await req(`/clinica/${clinicaId}/feriados/${id}`, { method: 'DELETE' }); carregarClinica(); }
catch (e: any) { flash(setErro, e.message); }
};
const salvarSituacoes = async () => {
setSaving(true); setErro(null);
try { await req(`/clinica/${clinicaId}/situacoes`, { method: 'PUT', body: JSON.stringify({ situacoes: situacoes.map((s) => s.trim()).filter(Boolean) }) }); flash(setOk, 'Situações salvas!'); }
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-4xl md:h-[94vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
{/* Header */}
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-teal-600 rounded-2xl shadow-lg shadow-teal-200"><Clock size={22} className="text-white" /></div>
<div>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Horários de Funcionamento</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">A Secretária IA usa isto para atender</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
</div>
{/* Tabs */}
<div className="flex gap-1 px-8 pt-4 border-b border-gray-50">
{(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas', 'situacoes'] as const)).map((t) => (
<button key={t} onClick={() => setAba(t)}
className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide rounded-t-xl transition-colors ${aba === t ? 'bg-teal-600 text-white' : 'text-gray-400 hover:text-gray-600'}`}>
{t === 'clinica' ? 'Clínica' : t === 'dentistas' ? 'Dentistas' : 'Situações'}
</button>
))}
</div>
{(erro || ok) && (
<div className={`px-8 py-2 text-xs font-bold ${erro ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>{erro || ok}</div>
)}
<div className="flex-1 overflow-y-auto p-8">
{aba === 'clinica' ? (
<div className="space-y-10">
<section>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2"><Clock size={16} className="text-teal-600" /> Dias e horários que a clínica funciona</h3>
<button onClick={salvarClinica} disabled={saving} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
</button>
</div>
<GradeSemanal faixas={clinFaixas} onChange={setClinFaixas} />
<p className="text-[11px] text-gray-400 italic mt-3">Sem horário configurado, assume-se segsex 08h18h. o dono edita.</p>
</section>
<section>
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2 mb-4"><CalendarOff size={16} className="text-teal-600" /> Feriados e fechamentos</h3>
<div className="flex flex-wrap items-end gap-2 mb-4 bg-gray-50 p-4 rounded-2xl">
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Data</label>
<input type="date" value={novo.data} onChange={(e) => setNovo({ ...novo, data: e.target.value })} className="bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
<div className="flex-1 min-w-[160px]"><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Motivo</label>
<input type="text" placeholder="Ex: Recesso, ponte" value={novo.nome} onChange={(e) => setNovo({ ...novo, nome: e.target.value })} className="w-full bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
<button onClick={addFeriado} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Adicionar</button>
</div>
{feriados.excecoes.length > 0 && (
<div className="space-y-1 mb-4">
{feriados.excecoes.map((f) => (
<div key={f.id} className="flex items-center justify-between bg-red-50 rounded-xl px-3 py-2">
<span className="text-xs font-bold text-red-700">{f.data.split('-').reverse().join('/')} · {f.nome}</span>
<button onClick={() => delFeriado(f.id)} className="text-red-300 hover:text-red-600"><Trash2 size={15} /></button>
</div>
))}
</div>
)}
<details className="text-xs">
<summary className="cursor-pointer font-black text-gray-400 uppercase tracking-wide">Feriados nacionais (automáticos)</summary>
<div className="mt-2 grid grid-cols-2 gap-1">
{feriados.nacionais.map((f) => <div key={f.data} className="text-[11px] text-gray-500">{f.data.split('-').reverse().join('/')} · {f.nome}</div>)}
</div>
</details>
</section>
</div>
) : aba === 'dentistas' ? (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User size={16} className="text-teal-600" />
<select value={dentSel} onChange={(e) => setDentSel(e.target.value)} className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-black text-gray-800 outline-none">
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}{d.eh_voce ? ' (você)' : ''}</option>)}
</select>
</div>
<button onClick={salvarDentista} disabled={saving || !dentSel} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
</button>
</div>
{dentSel ? <GradeSemanal faixas={dentFaixas} onChange={setDentFaixas} withFlex /> : <p className="text-xs text-gray-400">Nenhum dentista.</p>}
<p className="text-[11px] text-gray-400 italic">A IA agenda LIVRE na janela principal. Na janela "aceito confirmar" (mais cedo/mais tarde), ela não confirma sozinha cria um pedido para a secretária humana decidir.</p>
{dentSel && (
<section className="pt-4 border-t border-gray-100">
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2 mb-3"><CalendarOff size={16} className="text-teal-600" /> Folgas e férias (não atende nestas datas)</h3>
<div className="flex flex-wrap items-end gap-2 mb-3 bg-gray-50 p-4 rounded-2xl">
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">De</label>
<input type="date" value={novaFolga.data_inicio} onChange={(e) => setNovaFolga({ ...novaFolga, data_inicio: e.target.value })} className="bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Até (opcional)</label>
<input type="date" value={novaFolga.data_fim} onChange={(e) => setNovaFolga({ ...novaFolga, data_fim: e.target.value })} className="bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
<div className="flex-1 min-w-[140px]"><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Motivo</label>
<input type="text" placeholder="Ex: Férias, congresso" value={novaFolga.motivo} onChange={(e) => setNovaFolga({ ...novaFolga, motivo: e.target.value })} className="w-full bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
<button onClick={addFolga} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Adicionar</button>
</div>
{folgas.length > 0 ? (
<div className="space-y-1">
{folgas.map((f) => (
<div key={f.id} className="flex items-center justify-between bg-amber-50 rounded-xl px-3 py-2">
<span className="text-xs font-bold text-amber-700">
{f.data_inicio.split('-').reverse().join('/')}{f.data_fim !== f.data_inicio ? ` até ${f.data_fim.split('-').reverse().join('/')}` : ''}{f.motivo ? ` · ${f.motivo}` : ''}
</span>
<button onClick={() => delFolga(f.id)} className="text-amber-300 hover:text-amber-600"><Trash2 size={15} /></button>
</div>
))}
</div>
) : <p className="text-[11px] text-gray-400 italic">Nenhuma folga futura cadastrada.</p>}
</section>
)}
</div>
) : (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2"><AlertTriangle size={16} className="text-teal-600" /> Situações e regras da Secretária IA</h3>
<button onClick={salvarSituacoes} disabled={saving} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
</button>
</div>
<p className="text-[11px] text-gray-500 leading-relaxed bg-amber-50 rounded-2xl p-4">
Escreva regras que a Secretária IA deve seguir ao atender uma por caixa. Ex.: <span className="italic">"Pelo plano X não temos profissional para canal em dente posterior; não agende direto — ofereça uma avaliação para o dentista examinar melhor o dente."</span> Quando uma situação exigir decisão humana, a IA encaminha o caso e pede para o paciente aguardar (aparece em Pedidos pendentes).
</p>
<div className="space-y-2">
{situacoes.map((s, i) => (
<div key={i} className="flex items-start gap-2">
<textarea value={s} rows={2} onChange={(e) => setSituacoes(situacoes.map((x, j) => j === i ? e.target.value : x))}
placeholder="Descreva a situação e o que a IA deve fazer…"
className="flex-1 bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-medium text-gray-800 outline-none resize-y" />
<button onClick={() => setSituacoes(situacoes.filter((_, j) => j !== i))} className="mt-2 text-gray-300 hover:text-red-600"><Trash2 size={16} /></button>
</div>
))}
<button onClick={() => setSituacoes([...situacoes, ''])} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Nova situação</button>
</div>
{situacoes.length === 0 && <p className="text-[11px] text-gray-400 italic">Nenhuma situação cadastrada. A IA seguirá apenas o roteiro padrão.</p>}
</div>
)}
</div>
</div>
</div>
);
};
+141
View File
@@ -0,0 +1,141 @@
// Fila da secretária humana: pedidos de horário da ZONA CINZA que a Secretária IA
// registrou ("vou confirmar e retorno"). A humana confirma (vira agendamento) ou
// recusa. Consome /api/nw/agenda-config/clinica/:id/pedidos*.
import React, { useState, useEffect, useCallback } from 'react';
import { X, CalendarClock, Check, Ban, Loader2, Phone, User, HelpCircle } from 'lucide-react';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
const BASE = `${API}/nw/agenda-config`;
async function req(path: string, opts: RequestInit = {}) {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const res = await fetch(`${BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) } });
const t = await res.text(); const d = t ? JSON.parse(t) : {};
if (!res.ok) throw new Error(d.error || `Erro ${res.status}`);
return d;
}
const dm = (s: string) => s ? s.split('-').reverse().join('/') : s;
export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onChange?: () => void }> = ({ isOpen, onClose, clinicaId, onChange }) => {
const [pedidos, setPedidos] = useState<any[]>([]);
const [dentistas, setDentistas] = useState<any[]>([]);
const [sel, setSel] = useState<Record<string, string>>({}); // pedido_id → dentista_id escolhido
const [busy, setBusy] = useState<string | null>(null);
const [erro, setErro] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const carregar = useCallback(async () => {
if (!clinicaId) return;
setLoading(true); setErro(null);
try {
const p = await req(`/clinica/${clinicaId}/pedidos?status=pendente`);
setPedidos(p.pedidos || []);
const d = await req(`/clinica/${clinicaId}/dentistas`);
setDentistas(d.dentistas || []);
} catch (e: any) { setErro(e.message); } finally { setLoading(false); }
}, [clinicaId]);
useEffect(() => { if (isOpen) carregar(); }, [isOpen, carregar]);
const confirmar = async (p: any) => {
const dentistaId = p.dentista_id || sel[p.id];
if (!dentistaId) { setErro('Escolha o dentista para confirmar.'); return; }
setBusy(p.id); setErro(null);
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/confirmar`, { method: 'POST', body: JSON.stringify({ dentista_id: dentistaId }) }); await carregar(); onChange?.(); }
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
};
const recusar = async (p: any) => {
setBusy(p.id); setErro(null);
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/recusar`, { method: 'POST' }); await carregar(); onChange?.(); }
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
};
const resolver = async (p: any) => {
setBusy(p.id); setErro(null);
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/resolver`, { method: 'POST' }); await carregar(); onChange?.(); }
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-3xl md:h-[90vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-amber-500 rounded-2xl shadow-lg shadow-amber-200"><CalendarClock size={22} className="text-white" /></div>
<div>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Pedidos aguardando confirmação</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Horários fora do padrão que a Secretária IA encaminhou</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
</div>
{erro && <div className="px-8 py-2 text-xs font-bold bg-red-50 text-red-600">{erro}</div>}
<div className="flex-1 overflow-y-auto p-6 space-y-3">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div>
) : pedidos.length === 0 ? (
<p className="text-center text-sm text-gray-400 py-16">Nenhum pedido pendente. 🎉</p>
) : pedidos.map((p) => p.tipo === 'duvida' ? (
<div key={p.id} className="border-2 border-indigo-100 rounded-2xl p-4 bg-indigo-50/40">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="space-y-1.5 max-w-[75%]">
<div className="flex items-center gap-1.5 text-[10px] font-black text-indigo-600 uppercase tracking-widest"><HelpCircle size={13} /> Dúvida p/ decidir</div>
<div className="text-sm font-bold text-gray-800 leading-snug whitespace-pre-wrap">{p.resumo}</div>
<div className="flex items-center gap-2 text-xs text-gray-600 font-bold"><User size={13} className="text-gray-400" /> {p.paciente_nome || 'Cliente'}
{p.paciente_telefone && <span className="flex items-center gap-1 text-gray-400"><Phone size={12} /> {p.paciente_telefone}</span>}</div>
</div>
<div className="flex gap-2">
<button onClick={() => resolver(p)} disabled={busy === p.id}
className="bg-indigo-600 hover:bg-indigo-700 text-white text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 disabled:opacity-50">
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Resolvida
</button>
<button onClick={() => recusar(p)} disabled={busy === p.id}
className="bg-gray-100 hover:bg-red-50 text-gray-500 hover:text-red-600 text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
<Ban size={13} /> Descartar
</button>
</div>
</div>
</div>
) : (
<div key={p.id} className="border border-gray-200 rounded-2xl p-4 bg-white">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="space-y-1">
<div className="text-sm font-black text-gray-800">
{dm(p.data)} às {p.hora_inicio}{p.hora_fim ? `${p.hora_fim}` : ''}
</div>
<div className="flex items-center gap-2 text-xs text-gray-600 font-bold"><User size={13} className="text-gray-400" /> {p.paciente_nome || 'Cliente'}
{p.paciente_telefone && <span className="flex items-center gap-1 text-gray-400"><Phone size={12} /> {p.paciente_telefone}</span>}</div>
{p.procedimento && <div className="text-[11px] text-gray-500">{p.procedimento}</div>}
</div>
<div className="flex flex-col items-end gap-2">
{p.dentista_id ? (
<span className="text-[11px] font-bold text-teal-600">{p.dentista_nome}</span>
) : (
<select value={sel[p.id] || ''} onChange={(e) => setSel({ ...sel, [p.id]: e.target.value })}
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-xs font-bold text-gray-800 outline-none">
<option value="">Escolher dentista</option>
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}</option>)}
</select>
)}
<div className="flex gap-2">
<button onClick={() => confirmar(p)} disabled={busy === p.id}
className="bg-emerald-600 hover:bg-emerald-700 text-white text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 disabled:opacity-50">
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Confirmar
</button>
<button onClick={() => recusar(p)} disabled={busy === p.id}
className="bg-gray-100 hover:bg-red-50 text-gray-500 hover:text-red-600 text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
<Ban size={13} /> Recusar
</button>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
};
+165
View File
@@ -0,0 +1,165 @@
// Área de PENDÊNCIAS DO DONO do workspace (só o dono vê). Engloba tudo:
// 1) Checklist da clínica — perguntas Sim/Não que o dono responde;
// 2) Pendências de configuração — quando a resposta não bate com o banco
// (ex.: marcou "mais de um dentista" mas só há 1 cadastrado);
// 3) Fila operacional — resumo dos pedidos/dúvidas aguardando a secretária humana.
// Consome /api/nw/agenda-config/clinica/:id/{checklist,pendencias}.
import React, { useState, useEffect, useCallback } from 'react';
import { X, ClipboardCheck, AlertTriangle, Check, Ban, Loader2, Save, CalendarClock, HelpCircle, ChevronRight } from 'lucide-react';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
const BASE = `${API}/nw/agenda-config`;
async function req(path: string, opts: RequestInit = {}) {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const res = await fetch(`${BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) } });
const t = await res.text(); const d = t ? JSON.parse(t) : {};
if (!res.ok) throw new Error(d.error || `Erro ${res.status}`);
return d;
}
type Questao = { chave: string; pergunta: string; ajuda?: string; resposta: number | null };
type PendConfig = { chave: string; severidade: string; titulo: string; detalhe: string };
export const PendenciasDono: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onOpenFila?: () => void }> = ({ isOpen, onClose, clinicaId, onOpenFila }) => {
const [questoes, setQuestoes] = useState<Questao[]>([]);
const [config, setConfig] = useState<PendConfig[]>([]);
const [oper, setOper] = useState<{ horarios: number; duvidas: number; total: number }>({ horarios: 0, duvidas: 0, total: 0 });
const [contagens, setContagens] = useState<{ dentistas: number; pacientes: number }>({ dentistas: 0, pacientes: 0 });
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [erro, setErro] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
const flash = (setter: (v: string | null) => void, m: string) => { setter(m); setTimeout(() => setter(null), 3000); };
const carregar = useCallback(async () => {
if (!clinicaId) return;
setLoading(true); setErro(null);
try {
const c = await req(`/clinica/${clinicaId}/checklist`);
setQuestoes(c.questoes || []);
const p = await req(`/clinica/${clinicaId}/pendencias`);
setConfig(p.config || []);
setOper(p.operacionais || { horarios: 0, duvidas: 0, total: 0 });
setContagens(p.contagens || { dentistas: 0, pacientes: 0 });
} catch (e: any) { setErro(e.message); } finally { setLoading(false); }
}, [clinicaId]);
useEffect(() => { if (isOpen) carregar(); }, [isOpen, carregar]);
const setResposta = (chave: string, resposta: number | null) =>
setQuestoes((qs) => qs.map((q) => q.chave === chave ? { ...q, resposta } : q));
const salvar = async () => {
setSaving(true); setErro(null);
try {
const respostas: Record<string, number | null> = {};
questoes.forEach((q) => { respostas[q.chave] = q.resposta; });
await req(`/clinica/${clinicaId}/checklist`, { method: 'PUT', body: JSON.stringify({ respostas }) });
flash(setOk, 'Checklist salvo!');
await carregar();
} catch (e: any) { setErro(e.message); } finally { setSaving(false); }
};
if (!isOpen) return null;
const sevColor = (s: string) => s === 'alta' ? 'border-red-200 bg-red-50' : 'border-amber-200 bg-amber-50';
const sevText = (s: string) => s === 'alta' ? 'text-red-700' : 'text-amber-700';
return (
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-3xl md:h-[92vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-indigo-600 rounded-2xl shadow-lg shadow-indigo-200"><ClipboardCheck size={22} className="text-white" /></div>
<div>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Pendências do workspace</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Config da clínica + o que a Secretária precisa que você resolva</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
</div>
{(erro || ok) && <div className={`px-8 py-2 text-xs font-bold ${erro ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>{erro || ok}</div>}
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div>
) : (
<>
{/* Fila operacional */}
<section>
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3">Aguardando a secretária humana</h3>
<button onClick={() => onOpenFila?.()} disabled={!onOpenFila}
className="w-full flex items-center justify-between gap-3 border-2 border-amber-100 bg-amber-50/50 rounded-2xl px-4 py-3.5 text-left hover:bg-amber-50 disabled:cursor-default disabled:hover:bg-amber-50/50">
<div className="flex items-center gap-3">
<CalendarClock size={20} className="text-amber-600" />
<div>
<div className="text-sm font-black text-gray-800">{oper.total} pedido(s) na fila</div>
<div className="text-[11px] text-gray-500 font-bold">{oper.horarios} horário(s) · {oper.duvidas} dúvida(s)</div>
</div>
</div>
{onOpenFila && <ChevronRight size={18} className="text-amber-400" />}
</button>
</section>
{/* Pendências de configuração */}
<section>
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3">Configuração a resolver</h3>
{config.length === 0 ? (
<p className="text-sm text-gray-400 flex items-center gap-2 py-2"><Check size={16} className="text-emerald-500" /> Nenhuma pendência de configuração. 🎉</p>
) : (
<div className="space-y-2">
{config.map((p, i) => (
<div key={i} className={`border-2 rounded-2xl p-4 ${sevColor(p.severidade)}`}>
<div className={`flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest ${sevText(p.severidade)}`}><AlertTriangle size={13} /> {p.severidade === 'alta' ? 'Importante' : 'Atenção'}</div>
<div className="text-sm font-black text-gray-800 mt-1">{p.titulo}</div>
<div className="text-[12px] text-gray-600 mt-0.5 leading-snug">{p.detalhe}</div>
</div>
))}
</div>
)}
<p className="text-[10px] text-gray-400 mt-2">No sistema: {contagens.dentistas} dentista(s) · {contagens.pacientes} paciente(s) cadastrado(s).</p>
</section>
{/* Checklist */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest">Checklist da clínica</h3>
<button onClick={salvar} disabled={saving} className="bg-indigo-600 hover:bg-indigo-700 text-white text-[11px] font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
{saving ? <Loader2 size={13} className="animate-spin" /> : <Save size={13} />} Salvar
</button>
</div>
<p className="text-[11px] text-gray-500 bg-gray-50 rounded-2xl p-3 mb-3 leading-relaxed">
Responda sobre a clínica. A Secretária usa isto para saber quando pode agendar sozinha quando algo não bate com o cadastro, ela evita agendar e encaminha ao humano, e aparece aqui em cima como pendência.
</p>
<div className="space-y-2">
{questoes.map((q) => (
<div key={q.chave} className="border border-gray-200 rounded-2xl p-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<div className="text-sm font-black text-gray-800 flex items-center gap-1.5"><HelpCircle size={14} className="text-gray-300" /> {q.pergunta}</div>
{q.ajuda && <div className="text-[11px] text-gray-400 mt-1 leading-snug">{q.ajuda}</div>}
</div>
<div className="flex gap-1.5">
<button onClick={() => setResposta(q.chave, q.resposta === 1 ? null : 1)}
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 transition-colors ${q.resposta === 1 ? 'bg-emerald-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-emerald-50'}`}>
<Check size={13} /> Sim
</button>
<button onClick={() => setResposta(q.chave, q.resposta === 0 ? null : 0)}
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 transition-colors ${q.resposta === 0 ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-red-50'}`}>
<Ban size={13} /> Não
</button>
</div>
</div>
</div>
))}
</div>
</section>
</>
)}
</div>
</div>
</div>
);
};
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Search, X, Star, Check, ShieldCheck, UserPlus, Building2 } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
// Seletor de protético em modal: busca por nome/e-mail, favorito (★), padrão (pré-selecionado)
// e "adicionar por e-mail" (vincula como interno — resolve o protético que não está no diretório).
export const ProteticoPicker: React.FC<{ value: string; onChange: (id: string, lab?: any) => void; className?: string }> = ({ value, onChange, className }) => {
const toast = useToast();
const [open, setOpen] = useState(false);
const [labs, setLabs] = useState<any[]>([]);
const [busca, setBusca] = useState('');
const [addEmail, setAddEmail] = useState('');
const [showAdd, setShowAdd] = useState(false);
const [busy, setBusy] = useState(false);
const preselected = useRef(false);
const carregar = useCallback(async (): Promise<any[]> => {
try { const r = await HybridBackend.getProteseLaboratorios(); const arr = Array.isArray(r) ? r : []; setLabs(arr); return arr; }
catch { setLabs([]); return []; }
}, []);
useEffect(() => { carregar(); }, [carregar]);
// Pré-seleciona o protético PADRÃO uma única vez, quando nada foi escolhido ainda.
useEffect(() => {
if (preselected.current || value) return;
const pad = labs.find(l => l.padrao);
if (pad) { preselected.current = true; onChange(pad.id, pad); }
}, [labs, value]); // eslint-disable-line react-hooks/exhaustive-deps
const sel = labs.find(l => l.id === value);
const q = busca.toLowerCase();
const filtrados = labs.filter(l => !q || (l.nome || '').toLowerCase().includes(q) || (l.email || '').toLowerCase().includes(q));
const toggleFav = async (l: any, e: React.MouseEvent) => {
e.stopPropagation();
try { await HybridBackend.setProteticoPref(l.id, { favorito: !l.favorito }); await carregar(); }
catch { toast.error('ERRO'); }
};
const tornarPadrao = async (l: any, e: React.MouseEvent) => {
e.stopPropagation();
try { await HybridBackend.setProteticoPref(l.id, { padrao: !l.padrao }); await carregar(); toast.success(l.padrao ? 'PADRÃO REMOVIDO.' : 'DEFINIDO COMO PADRÃO.'); }
catch { toast.error('ERRO'); }
};
const selecionar = (l: any) => { onChange(l.id, l); setOpen(false); setBusca(''); };
const adicionar = async () => {
if (!addEmail.trim()) return;
setBusy(true);
try {
const r = await HybridBackend.vincularProteticoEmail(addEmail.trim());
if (r?.error) throw new Error(r.error);
toast.success('PROTÉTICO VINCULADO.');
setAddEmail(''); setShowAdd(false);
const novos = await carregar();
const novo = novos.find((x: any) => x.id === r.protetico?.id);
if (novo) selecionar(novo);
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
return (
<>
<button type="button" onClick={() => setOpen(true)}
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm text-left flex items-center justify-between gap-2 uppercase focus:border-teal-400 outline-none transition-all'}>
<span className="truncate flex items-center gap-2">
{sel ? (<><Building2 size={16} className="text-teal-600 shrink-0" /> {sel.nome}{sel.padrao ? ' · PADRÃO' : ''}</>) : 'Escolha o laboratório / protético...'}
</span>
<Search size={16} className="text-gray-400 shrink-0" />
</button>
{open && (
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div className="bg-white rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="font-black text-gray-800 uppercase text-sm">Escolher protético</h3>
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-4 border-b border-gray-50">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar por nome ou e-mail…"
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{filtrados.length === 0 ? (
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nenhum protético encontrado.</p>
) : filtrados.map(l => (
<div key={l.id} onClick={() => selecionar(l)}
className={`flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer ${l.id === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
<button onClick={e => toggleFav(l, e)} title="Favorito" className="shrink-0">
<Star size={16} className={l.favorito ? 'text-amber-400 fill-amber-400' : 'text-gray-300'} />
</button>
<div className="min-w-0 flex-1">
<p className="font-black text-gray-800 text-sm truncate uppercase flex items-center gap-1.5">
{l.nome}{l.verificado && <ShieldCheck size={13} className="text-blue-500 shrink-0" />}
</p>
<p className="text-[10px] font-bold text-gray-400 uppercase truncate flex items-center gap-1.5">
<span className={l.interno ? 'text-teal-600' : ''}>{l.interno ? 'Interno' : 'Diretório'}</span>
{l.nota != null && <span>· {l.nota}</span>}
{l.padrao && <span className="text-violet-600">· Padrão</span>}
</p>
</div>
<button onClick={e => tornarPadrao(l, e)} title="Definir como padrão"
className={`shrink-0 text-[9px] font-black uppercase px-2 py-1 rounded-lg border ${l.padrao ? 'border-violet-300 bg-violet-50 text-violet-700' : 'border-gray-200 text-gray-400'}`}>Padrão</button>
{l.id === value && <Check size={16} className="text-teal-600 shrink-0" />}
</div>
))}
</div>
<div className="p-4 border-t border-gray-100">
{showAdd ? (
<div className="flex gap-2">
<input value={addEmail} onChange={e => setAddEmail(e.target.value)} type="email" placeholder="e-mail do protético"
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400" />
<button disabled={busy} onClick={adicionar} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50">Vincular</button>
</div>
) : (
<button onClick={() => setShowAdd(true)} className="w-full py-2.5 rounded-xl border border-dashed border-gray-300 text-gray-500 text-xs font-black uppercase flex items-center justify-center gap-2">
<UserPlus size={14} /> Não encontrou? Adicionar por e-mail
</button>
)}
</div>
</div>
</div>
)}
</>
);
};
+66
View File
@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { Search, X, Check, ChevronDown } from 'lucide-react';
export interface SelectOption { value: string; label: string; hint?: string }
// Dropdown genérico que abre um modal com busca — substitui <select> em listas longas.
export const SearchSelect: React.FC<{
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
title?: string;
className?: string;
disabled?: boolean;
}> = ({ value, onChange, options, placeholder = 'Selecione...', title, className, disabled }) => {
const [open, setOpen] = useState(false);
const [busca, setBusca] = useState('');
const sel = options.find(o => o.value === value);
const q = busca.trim().toLowerCase();
const filtrados = q ? options.filter(o => o.label.toLowerCase().includes(q) || (o.hint || '').toLowerCase().includes(q)) : options;
const pick = (v: string) => { onChange(v); setOpen(false); setBusca(''); };
return (
<>
<button type="button" disabled={disabled} onClick={() => setOpen(true)}
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase disabled:opacity-50'}>
<span className="flex items-center justify-between gap-2">
<span className={`truncate ${sel ? '' : 'text-gray-400'}`}>{sel ? sel.label : placeholder}</span>
<ChevronDown size={16} className="text-gray-400 shrink-0" />
</span>
</button>
{open && (
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div className="bg-white rounded-2xl w-full max-w-md max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="font-black text-gray-800 uppercase text-sm">{title || placeholder}</h3>
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-3 border-b border-gray-50">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar…"
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{filtrados.length === 0 ? (
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nada encontrado.</p>
) : filtrados.map(o => (
<button key={o.value} onClick={() => pick(o.value)}
className={`w-full text-left flex items-center gap-2 px-3 py-2.5 rounded-xl ${o.value === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-800 text-sm truncate uppercase">{o.label}</p>
{o.hint && <p className="text-[11px] text-gray-400 truncate">{o.hint}</p>}
</div>
{o.value === value && <Check size={16} className="text-teal-600 shrink-0" />}
</button>
))}
</div>
</div>
</div>
)}
</>
);
};
+57 -8
View File
@@ -1,12 +1,12 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import {
Users, Calendar as CalendarIcon, LayoutDashboard,
DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown
DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown, MessageCircle, Bot, Smartphone, Power
} from 'lucide-react';
import { ToothIcon } from './ToothIcon.tsx';
import { HybridBackend } from '../services/backend.ts';
import { getActivePlugins } from '../views/plugins/pluginRegistry.ts';
import { getActivePlugins, isPluginActive } from '../views/plugins/pluginRegistry.ts';
interface SidebarProps {
activeTab: string;
@@ -25,6 +25,19 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}').is_tutor === true; } catch { return false; }
})();
// Acesso do usuário às áreas do WhatsApp (Inbox/Secretária). null = ainda carregando
// (fail-open: mostra até saber). Dono e autorizados veem; os demais não.
const [waAccess, setWaAccess] = useState<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean } | null>(null);
useEffect(() => {
let alive = true;
if (isPluginActive('newwhats')) {
HybridBackend.getWaAccess().then(a => { if (alive) setWaAccess(a); }).catch(() => {});
}
return () => { alive = false; };
}, [activeWorkspace?.id]);
const canSeeInbox = !waAccess || waAccess.isOwner || waAccess.inbox;
const canSeeSecretaria = !waAccess || waAccess.isOwner || waAccess.secretaria;
const pluginMenuItems: Array<{ id: string; label: string; icon: any; color: string }> = [];
if (activePluginIds.includes('rx-scoreodonto')) {
// Radiografias são ODONTOLÓGICAS: só papéis odonto (dentista + donos/funcionário).
@@ -37,14 +50,27 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
pluginMenuItems.push({ id: 'rx-config', label: 'DISPOSITIVOS RX', icon: Monitor, color: '#7c3aed' });
}
}
// Locação de Salas: capability SEMPRE ativa (sem desativar) p/ todos menos superadmin.
// Dois menus: administrar as próprias salas (locador) e alugar salas de terceiros (locatário).
if (currentRole !== 'superadmin') {
// 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
// a locação. Por isso restringimos a papéis que podem ser titulares de sala.
const PAPEIS_LOCACAO = ['donosala', 'donoclinica', 'donoconsultorio', 'admin', 'dentista', 'biomedico', 'protetico'];
if (PAPEIS_LOCACAO.includes(currentRole)) {
pluginMenuItems.push({ id: 'minhas-salas', label: 'MINHAS SALAS', icon: DoorOpen, color: '#0d9488' });
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).
@@ -57,7 +83,16 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
{ id: 'superadmin-logs', label: 'ACESSOS', icon: Activity },
{ id: 'superadmin-financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'superadmin-notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
{ id: 'superadmin-secretaria', label: 'SECRETÁRIA IA', icon: Power },
{ id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen },
{ id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
{ 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 },
{ id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard },
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
@@ -94,11 +129,23 @@ 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-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.)
if ((activeWorkspace as any)?.tipo === 'sala') return ['sala-home', 'notificacoes', 'configuracoes'].includes(item.id);
// Contexto de LABORATÓRIO (workspace tipo='laboratorio'): área própria do protético —
// painel do lab + bancada de produção, sem as telas clínicas da clínica.
if ((activeWorkspace as any)?.tipo === 'laboratorio') {
// Técnico (vínculo tecnico_protese) só opera a produção; gestão é do dono.
const ehTecnico = (activeWorkspace as any)?.role === 'tecnico_protese';
return ehTecnico
? ['lab-home', 'lab-bancada', '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
if (item.id === 'plugins' || item.id === 'admin-gestao-tutores') return false;
@@ -128,7 +175,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 {
+4 -1
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import { ProtesePublicView } from './views/ProtesePublicView.tsx';
import { ErrorBoundary } from './components/ErrorBoundary.tsx';
import { ToastProvider } from './contexts/ToastContext.tsx';
import { ConfirmProvider } from './contexts/ConfirmContext.tsx';
@@ -34,13 +35,15 @@ window.addEventListener('vite:preloadError', () => {
const rootElement = document.getElementById('root');
if (rootElement) {
const root = createRoot(rootElement);
// Modo 1 — link seguro: /p/TOKEN é página PÚBLICA (sem login), fora do app autenticado.
const publicMatch = window.location.pathname.match(/^\/p\/(.+)$/);
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<ToastProvider>
<ConfirmProvider>
<App />
{publicMatch ? <ProtesePublicView token={decodeURIComponent(publicMatch[1])} /> : <App />}
</ConfirmProvider>
</ToastProvider>
</ErrorBoundary>
+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",
+321 -1
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 });
@@ -778,13 +799,88 @@ export const HybridBackend = {
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
return await res.json();
},
uploadProteseFoto: async (id: string, file: File) => {
uploadProteseFoto: async (id: string, file: File, legenda?: string, ocorrenciaId?: string) => {
const fd = new FormData();
fd.append('foto', file);
if (legenda) fd.append('legenda', legenda);
if (ocorrenciaId) fd.append('ocorrencia_id', ocorrenciaId);
const res = await apiFetch(`${API_URL}/protese/os/${id}/foto`, { method: 'POST', body: fd });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao anexar foto');
return await res.json();
},
// Conferência de componente (status: ok | divergente | ausente | pendente)
conferirProteseComponente: async (compId: string, status_conferencia: string) => {
const res = await apiFetch(`${API_URL}/protese/os/componentes/${compId}/conferir`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_conferencia }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao conferir');
return await res.json();
},
// Ocorrências (contraditório)
abrirProteseOcorrencia: async (osId: string, body: { categoria: string; descricao: string; responsavel?: string; componente_id?: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/ocorrencias`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao abrir ocorrência');
return await res.json();
},
responderProteseOcorrencia: async (ocId: string, resposta: string) => {
const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/resposta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resposta }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao responder');
return await res.json();
},
statusProteseOcorrencia: async (ocId: string, status: string) => {
const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/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 mudar status');
return await res.json();
},
deleteProteseFoto: async (fotoId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/fotos/${fotoId}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover foto');
return await res.json();
},
// Componentes enviados ao laboratório (direcao: 'enviado' clínica→lab | 'devolvido' lab→clínica)
addProteseComponente: async (osId: string, body: { nome: string; quantidade?: number; observacao?: string; direcao?: 'enviado' | 'devolvido' }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/componentes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar componente');
return await res.json();
},
deleteProteseComponente: async (compId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/componentes/${compId}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover componente');
return await res.json();
},
// Pagamentos/recebimentos da OS (lado: 'lab' | 'clinica')
addProtesePagamento: async (osId: string, body: { lado: 'lab' | 'clinica'; valor: number; forma?: string; observacao?: string; data?: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/pagamentos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar pagamento');
return await res.json();
},
deleteProtesePagamento: async (pagId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/pagamentos/${pagId}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover pagamento');
return await res.json();
},
// Custódia: registra que o trabalho mudou de mãos (para_local: 'clinica' | 'laboratorio')
moverProteseCustodia: async (osId: string, para_local: 'clinica' | 'laboratorio', observacao?: string) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/custodia`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ para_local, observacao }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar movimentação');
return await res.json();
},
// Editar campos do trabalho (cor, material, dentes, observações)
editarProteseOS: async (osId: string, campos: { cor?: string; material?: string; dentes?: string; observacoes?: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(campos) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao editar');
return await res.json();
},
// Ajuste do valor cobrado do paciente (delta + ou )
ajustarValorProtese: async (osId: string, delta: number, observacao?: string) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/ajuste-valor`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ delta, observacao }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao ajustar valor');
return await res.json();
},
// Trocar o trabalho por outro (produto do catálogo ou tipo livre) + motivo
trocarTrabalhoProtese: async (osId: string, body: { produto_id?: string; tipo?: string; motivo: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/trocar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao trocar trabalho');
return await res.json();
},
deleteProteseOS: async (id: string) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
await apiFetch(`${API_URL}/protese/os/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' });
@@ -1011,6 +1107,184 @@ export const HybridBackend = {
return await res.json().catch(() => ({ feitas: [], recebidas: [] }));
},
// Laboratório (Fase 3): clientes (clínicas) com métricas + extrato de recebimentos.
getLabClientes: async (): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/protese/lab/clientes`);
return await res.json().catch(() => []);
},
getLabPagamentos: async (): Promise<any[]> => {
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`);
return await res.json().catch(() => ({}));
},
updateLabDados: async (body: { nome_fantasia: string; documento?: string; pessoa_tipo?: 'PF' | 'PJ' }) => {
const res = await apiFetch(`${API_URL}/protese/lab`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao salvar');
return await res.json();
},
getLabEquipe: async (): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/protese/lab/equipe`);
return await res.json().catch(() => []);
},
addLabTecnico: async (body: { email: string; nome?: string }) => {
const res = await apiFetch(`${API_URL}/protese/lab/equipe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar técnico');
return await res.json();
},
removeLabTecnico: async (userId: string) => {
const res = await apiFetch(`${API_URL}/protese/lab/equipe/${userId}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover');
return await res.json();
},
// Fase 5: indicadores do laboratório (Nota ScoreOdonto + KPIs derivados)
getLabIndicadores: async (): Promise<any> => {
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' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao gerar link');
return await res.json();
},
getProteseLinkStatus: async (osId: string) => {
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 }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar.');
return await res.json();
},
statusPublic: async (token: string, status: string) => {
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/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 avançar etapa.');
return await res.json();
},
uploadFotoPublic: async (token: string, file: File) => {
const fd = new FormData();
fd.append('foto', file);
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/foto`, { method: 'POST', body: fd });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao anexar foto.');
return await res.json();
},
// Agenda de uma sala (reservas + bloqueios de manutenção) — usado pelo operador da unidade.
getSalaReservas: async (salaId: string): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/salas/${salaId}/reservas`);
return await res.json().catch(() => []);
},
// Bloqueio de sala para manutenção (sem cobrança; tira a sala da agenda no intervalo).
criarManutencaoSala: async (salaId: string, body: { inicio: string; fim: string; motivo?: string }) => {
const res = await apiFetch(`${API_URL}/salas/${salaId}/manutencao`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || 'Falha ao bloquear a sala.');
return data;
},
saveFinanceiro: async (item: Partial<FinanceiroItem>) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const data = enforceUppercase(item);
@@ -1207,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>
+160 -54
View File
@@ -3,9 +3,12 @@ import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download } from 'lucide-react';
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
import { HorariosConfig } from '../components/HorariosConfig.tsx';
import { PedidosPendentes } from '../components/PedidosPendentes.tsx';
import { AgendaDetailModal, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx';
import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx';
import { AgendaPresence } from '../components/AgendaPresence.tsx';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import { HybridBackend } from '../services/backend.ts';
@@ -14,7 +17,6 @@ import { Dentista, Agendamento, Paciente, DentistaHorario } from '../types.ts';
import { useGTOStore } from './LancarGTO.tsx';
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
// --- Reusable Debounce Hook ---
function useDebounce(value: string, delay: number) {
@@ -343,16 +345,37 @@ const AppointmentModal: React.FC<{
);
};
export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({ onNavigate }) => {
export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebarCollapsed?: boolean; onToggleSidebar?: () => void; controlsOpen?: boolean; onCloseControls?: () => void }> = ({ onNavigate, sidebarCollapsed, onToggleSidebar, controlsOpen = false, onCloseControls }) => {
const { data: agendamentos, refresh } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
const { data: dentists, refresh: refreshDentists } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend<any[]>(HybridBackend.getGoogleEvents);
const [events, setEvents] = useState<any[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [showMeusHorarios, setShowMeusHorarios] = useState(false);
const [showPedidos, setShowPedidos] = useState(false);
const [pedidosCount, setPedidosCount] = useState(0);
// Pedidos da zona cinza aguardando a secretária humana — badge + poll a cada 30 min.
useEffect(() => {
const cid = (HybridBackend.getActiveWorkspace() as any)?.id;
const role = HybridBackend.getCurrentRole();
if (!cid || !['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(role)) return;
let alive = true;
const poll = async () => {
try {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const r = await fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
if (r.ok && alive) { const j = await r.json(); setPedidosCount(j.total || (j.pedidos || []).length || 0); }
} catch { /* silencioso */ }
};
poll();
const t = setInterval(poll, 30 * 60 * 1000); // 30 min
return () => { alive = false; clearInterval(t); };
}, []);
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null);
const [showDetails, setShowDetails] = useState(false);
const [whatsTarget, setWhatsTarget] = useState<{ pacienteId: string; pacienteNome: string } | null>(null);
const [interesses, setInteresses] = useState<any[]>([]);
const [showInteresses, setShowInteresses] = useState(false);
const [pendencias, setPendencias] = useState<any[]>([]);
@@ -457,14 +480,15 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
const inativo = st === 'cancelado' || st === 'remarcar';
// Prioridade: status (falta/cancelado) > cor própria do agendamento (ex.: importada do Google) > cor do dentista.
const cor = faltou ? '#ef4444' : inativo ? '#cbd5e1' : ((ag as any).cor || (dentist ? dentist.corAgenda : '#94a3b8'));
const nomePac = (ag as any).pacienteNome || (ag as any).pacientenome || '';
return {
id: ag.id,
title: `${ag.pacienteNome} - ${ag.procedimento}`,
title: `${nomePac} - ${ag.procedimento}`,
start: ag.start,
end: ag.end,
backgroundColor: cor,
borderColor: cor,
extendedProps: { ...ag, isGoogle: false, dentistaNome: dentist?.nome }
extendedProps: { ...ag, pacienteNome: nomePac, isGoogle: false, dentistaNome: dentist?.nome }
};
});
@@ -597,13 +621,107 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
});
return (
<div className="space-y-6">
<PageHeader title="AGENDA CLÍNICA">
<div className="flex gap-2 sm:gap-3 items-center flex-wrap justify-end">
<div className="flex flex-col lg:flex-row lg:gap-4 h-full">
{/* Backdrop da gaveta de controles (mobile) — fecha ao tocar fora. Como a sidebar. */}
{controlsOpen && (
<div className="fixed inset-0 bg-black/50 z-20 lg:hidden backdrop-blur-sm transition-opacity" onClick={onCloseControls} />
)}
{/* Coluna de controles desktop: lateral esquerda fixa; mobile: gaveta deslizante
(oculta por padrão p/ dar o máximo de espaço ao calendário; abre pelo do header). */}
<aside className={`
fixed inset-y-0 right-0 z-30 w-72 max-w-[85%] bg-white shadow-2xl overflow-y-auto p-4 transform transition-transform duration-300
${controlsOpen ? 'translate-x-0' : 'translate-x-full'}
lg:static lg:inset-auto lg:z-auto lg:w-56 lg:max-w-none lg:translate-x-0 lg:transform-none lg:transition-none lg:shadow-none lg:bg-transparent lg:p-0 lg:overflow-y-auto lg:pr-3 lg:border-r lg:border-gray-100
shrink-0 lg:mb-0`}>
{/* Fechar a gaveta (só mobile) */}
<button onClick={onCloseControls} title="Fechar"
className="lg:hidden absolute top-3 right-3 p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors z-10">
<X size={20} />
</button>
{/* Título + recolher menu (desktop, ao lado). No mobile o AGENDAR e as
ações ficam empilhados abaixo (dentro da gaveta). */}
<div className="flex items-center gap-2 mb-3 pr-8 lg:pr-0">
{onToggleSidebar && (
<button onClick={onToggleSidebar} title={sidebarCollapsed ? 'Mostrar menu lateral' : 'Recolher menu lateral'}
className="hidden lg:flex items-center justify-center w-8 h-8 shrink-0 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors">
{sidebarCollapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
</button>
)}
<h2 className="text-xl lg:text-base font-bold text-gray-900 uppercase flex-1 min-w-0 leading-tight break-words">AGENDA CLÍNICA</h2>
</div>
<div className="flex gap-2 sm:gap-3 items-center flex-wrap lg:flex-col lg:items-stretch">
{/* AGENDAR (full-width no topo da coluna/gaveta — mobile e desktop) */}
{(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); onCloseControls?.(); }} title="Agendar"
className="flex w-full bg-teal-600 text-white px-4 py-2.5 rounded-lg text-sm font-bold uppercase items-center justify-center gap-2 hover:bg-teal-700 shadow-sm transition-colors">
<Plus size={18} /> AGENDAR
</button>
)}
{/* Ações — mobile: empilhadas, ícone + nome, largura cheia; desktop: fileira de ícones */}
<div className="flex flex-col gap-2 w-full">
{(currentRole !== 'paciente') && (
<button onClick={() => { setShowAtividade(true); loadAtividade(); onCloseControls?.(); }} title="Atividade da equipe hoje"
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<History size={20} className="shrink-0" />
<span className="text-sm font-bold">Atividade da equipe</span>
</button>
)}
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
<button onClick={handleImportarGoogle} disabled={importando} title="Importar agendamentos do Google para o sistema"
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-emerald-600 border border-gray-200 rounded-xl transition-all hover:border-emerald-200 hover:shadow-sm disabled:opacity-50">
{importando ? <Loader2 size={20} className="animate-spin shrink-0" /> : <Download size={20} className="shrink-0" />}
<span className="text-sm font-bold">Importar do Google</span>
</button>
)}
{['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(currentRole) && (
<button onClick={() => { setShowPedidos(true); onCloseControls?.(); }} title="Pedidos de horário aguardando confirmação (encaminhados pela Secretária IA)"
className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
<CalendarClock size={20} className="shrink-0" />
<span className="text-sm font-bold">Pedidos pendentes</span>
{pedidosCount > 0 && <span className="absolute -top-1 -right-1 bg-amber-500 text-white text-[10px] font-black rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 shadow">{pedidosCount}</span>}
</button>
)}
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
<button onClick={() => { setIsSettingsOpen(true); onCloseControls?.(); }} title="Configurações da agenda"
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<GearIcon size={20} className="shrink-0" />
<span className="text-sm font-bold">Configurações</span>
</button>
)}
{souDentista && (
<button onClick={() => { setShowMeusHorarios(true); onCloseControls?.(); }} title="Configurar meus dias e horários de atendimento"
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<Clock size={20} className="shrink-0" />
<span className="text-sm font-bold">Meus Horários</span>
</button>
)}
{souDentista && (
<button onClick={() => { setShowInteresses(true); loadInteresses(); onCloseControls?.(); }} title="Pedidos de interesse na sua agenda"
className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<Bell size={20} className="shrink-0" />
<span className="text-sm font-bold">Interesses na agenda</span>
{interessesAguardando > 0 && (
<span className="absolute top-1.5 left-6 bg-red-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{interessesAguardando}</span>
)}
</button>
)}
{(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setShowPendencias(true); loadPendencias(); onCloseControls?.(); }} title="Pacientes a reagendar"
className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
<CalendarClock size={20} className="shrink-0" />
<span className="text-sm font-bold">Pacientes a reagendar</span>
{pendencias.length > 0 && (
<span className="absolute top-1.5 left-6 bg-amber-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{pendencias.length}</span>
)}
</button>
)}
</div>
<div className="w-full"><AgendaPresence /></div>
{/* Legenda de dentistas (desktop, no rodapé da coluna) */}
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="dentists-legend" direction="horizontal">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="hidden lg:flex gap-2 items-center">
<div {...provided.droppableProps} ref={provided.innerRef} className="hidden lg:flex lg:flex-wrap gap-2 items-center lg:w-full lg:mt-1">
{filteredDentists?.map((d, index) => (
<Draggable key={d.id} draggableId={d.id} index={index} isDragDisabled={currentRole === 'dentista'}>
{(provided, snapshot) => (
@@ -625,50 +743,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
)}
</Droppable>
</DragDropContext>
<AgendaPresence />
{(currentRole !== 'paciente') && (
<button onClick={() => { setShowAtividade(true); loadAtividade(); }} title="Atividade da equipe hoje"
className="p-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<History size={20} />
</button>
)}
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
<button onClick={handleImportarGoogle} disabled={importando} title="Importar agendamentos do Google para o sistema"
className="p-2.5 bg-white text-gray-400 hover:text-emerald-600 border border-gray-200 rounded-xl transition-all hover:border-emerald-200 hover:shadow-sm disabled:opacity-50">
{importando ? <Loader2 size={20} className="animate-spin" /> : <Download size={20} />}
</button>
)}
{(currentRole === 'admin' || currentRole === 'donoclinica') && (
<button onClick={() => setIsSettingsOpen(true)} className="p-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<GearIcon size={20} />
</button>
)}
{souDentista && (
<button onClick={() => { setShowInteresses(true); loadInteresses(); }} title="Pedidos de interesse na sua agenda"
className="relative p-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<Bell size={20} />
{interessesAguardando > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{interessesAguardando}</span>
)}
</button>
)}
{(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setShowPendencias(true); loadPendencias(); }} title="Pacientes a reagendar"
className="relative p-2.5 bg-white text-gray-400 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
<CalendarClock size={20} />
{pendencias.length > 0 && (
<span className="absolute -top-1 -right-1 bg-amber-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{pendencias.length}</span>
)}
</button>
)}
{(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} title="Novo agendamento" className="bg-teal-600 text-white px-3 sm:px-4 py-2 rounded-lg text-sm font-bold uppercase flex items-center gap-2 hover:bg-teal-700 shadow-sm transition-colors">
<Plus size={18} /> <span className="hidden sm:inline">NOVO AGENDAMENTO</span>
</button>
)}
</div>
</PageHeader>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-2 sm:p-4 relative h-[calc(100dvh-220px)] sm:h-[750px] min-h-[420px]">
</aside>
<div className="flex-1 min-w-0 bg-white rounded-xl shadow-sm border border-gray-200 p-2 sm:p-4 relative min-h-[420px] lg:min-h-0">
<FullCalendar
key={isMobile ? 'mobile' : 'desktop'}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
@@ -681,6 +758,8 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
buttonText={{ today: 'HOJE', month: 'MÊS', week: 'SEMANA', day: 'DIA' }}
slotMinTime="08:00:00"
slotMaxTime="19:00:00"
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
allDaySlot={false}
events={events}
selectable={true}
@@ -720,10 +799,23 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
onChanged={() => { refresh(); refreshGoogle(); loadPendencias(); }}
onEditar={() => { setShowDetails(false); setIsModalOpen(true); }}
onAtender={handleAtender}
podeAtender={!!onNavigate && currentRole !== 'paciente'}
onVerWhats={(info) => setWhatsTarget(info)}
// "Atender" (abre Lançar GTO) é ação CLÍNICA — atende quem trata: dentista,
// biomédico, dono da clínica/consultório, dentista locatário, admin. A recepção
// (funcionario) NÃO atende; ela só remarca/desmarca/agenda.
podeAtender={!!onNavigate && currentRole !== 'paciente' && currentRole !== 'funcionario'}
dentistaRestrito={souDentista}
/>
{/* Drawer de WhatsApp do paciente — clone focado da área de mensagens */}
<WhatsChatDrawer
isOpen={!!whatsTarget}
onClose={() => setWhatsTarget(null)}
pacienteId={whatsTarget?.pacienteId}
pacienteNome={whatsTarget?.pacienteNome}
onNavigate={onNavigate}
/>
{/* Lista "A REAGENDAR" — pendências de falta/cancelamento/remarcação */}
{showPendencias && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowPendencias(false)}>
@@ -797,6 +889,12 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: #f1f1f1; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
/* FullCalendar — toolbar/controles compactos (todas as telas) */
.fc .fc-toolbar.fc-header-toolbar { margin-bottom: .6rem; }
.fc .fc-toolbar-title { font-size: 1.05rem; font-weight: 700; }
.fc .fc-button { padding: .3rem .65rem; font-size: .8rem; box-shadow: none !important; }
.fc .fc-button-group { gap: 0; }
.fc .fc-col-header-cell-cushion { font-size: .72rem; padding: 4px 2px; }
/* FullCalendar — responsividade mobile */
@media (max-width: 639px) {
.fc .fc-toolbar.fc-header-toolbar { margin-bottom: .5rem; flex-wrap: wrap; gap: .4rem; }
@@ -806,8 +904,13 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
.fc .fc-col-header-cell-cushion { font-size: .65rem; padding: 2px; }
.fc .fc-timegrid-slot-label-cushion, .fc .fc-timegrid-axis-cushion { font-size: .6rem; }
.fc .fc-timegrid-event .fc-event-main { padding: 1px 2px; }
.fc-footer-toolbar.fc-toolbar { margin-top: .5rem; }
}
/* Linha da MEIA-HORA (:30): divide a hora em 2, fraca/tracejada como no Google
(a linha cheia da hora vem do border padrão das células). */
.fc .fc-timegrid-slot-minor {
border-top: 1px dashed #eef1f4 !important;
}
.fc-footer-toolbar.fc-toolbar { margin-top: .5rem; }
`}</style>
<AppointmentModal
isOpen={isModalOpen}
@@ -819,6 +922,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
initialPatient={reagendarPatient}
/>
<AgendaSettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} dentists={dentists} />
<HorariosConfig isOpen={showMeusHorarios} onClose={() => setShowMeusHorarios(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id} initialTab="dentistas" soDentista />
<PedidosPendentes isOpen={showPedidos} onClose={() => setShowPedidos(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id}
onChange={() => { const cid = (HybridBackend.getActiveWorkspace() as any)?.id; const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); if (cid) fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }).then(r => r.ok ? r.json() : { total: 0 }).then(j => setPedidosCount(j.total || 0)).catch(() => {}); }} />
{showInteresses && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowInteresses(false)}>
+94 -1
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Building2, ArrowRight, Shield, MapPin, BadgeCheck, Palette, Save, Loader2 } from 'lucide-react';
import { Building2, ArrowRight, Shield, MapPin, BadgeCheck, Palette, Save, Loader2, Plus, X } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { PageHeader } from '../components/PageHeader.tsx';
import { EmptyState } from '../components/EmptyState.tsx';
@@ -24,9 +24,43 @@ export const ClinicasView: React.FC = () => {
const currentRole = HybridBackend.getCurrentRole();
const isAdmin = currentRole === 'admin' || currentRole === 'donoclinica';
// Papel da CONTA (não da unidade ativa) — é o que o backend usa para decidir
// se a nova unidade nasce como 'clinica' ou 'consultorio'.
const accountRole = (() => {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}').role as string | undefined; }
catch { return undefined; }
})();
const isConsultorioAccount = accountRole === 'donoconsultorio';
const unitLabel = isConsultorioAccount ? 'CONSULTÓRIO' : 'CLÍNICA';
const canCreate = ['donoclinica', 'donoconsultorio', 'admin'].includes(accountRole || '');
const [isEditingColor, setIsEditingColor] = useState<string | null>(null);
const [savingColor, setSavingColor] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [newNome, setNewNome] = useState('');
const [newDoc, setNewDoc] = useState('');
const [creating, setCreating] = useState(false);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newNome.trim()) return;
setCreating(true);
try {
const result = await HybridBackend.createClinica(newNome.trim(), newDoc.trim() || undefined);
if (!result.success) { toast.error(result.message || 'ERRO AO CRIAR UNIDADE.'); return; }
// Recarrega a lista COMPLETA do servidor (acrescenta a nova unidade às existentes,
// em vez de sobrescrever). A tela relê os workspaces no reload.
await HybridBackend.refreshWorkspaces();
toast.success(`${unitLabel} CRIADA!`);
setTimeout(() => window.location.reload(), 900);
} catch {
toast.error('ERRO DE CONEXÃO.');
} finally {
setCreating(false);
}
};
const handleSwitch = (workspaceId: string) => {
if (activeWorkspace?.id === workspaceId) {
toast.info("VOCÊ JÁ ESTÁ NESTA UNIDADE.");
@@ -81,6 +115,17 @@ export const ClinicasView: React.FC = () => {
description="Configure a identidade visual e selecione a clínica para gerenciar atendimentos."
/>
{canCreate && (
<div className="flex justify-end -mt-4">
<button
onClick={() => { setNewNome(''); setNewDoc(''); setShowCreate(true); }}
className="inline-flex items-center gap-2 px-6 py-3 rounded-2xl bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-widest shadow-lg transition-colors"
>
<Plus size={16} /> Nova {unitLabel.toLowerCase()}
</button>
</div>
)}
{workspaces.length === 0 && (
<EmptyState icon={Building2} title="Nenhuma unidade ainda" description="Você ainda não tem clínicas ou consultórios vinculados a esta conta." />
)}
@@ -208,6 +253,54 @@ export const ClinicasView: React.FC = () => {
))}
</div>
</div>
{showCreate && (
<div
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm flex items-center justify-center p-4"
onClick={() => { if (!creating) setShowCreate(false); }}
>
<div className="bg-white rounded-3xl w-full max-w-md p-8 shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-gray-900 uppercase tracking-tight">Nova {unitLabel.toLowerCase()}</h3>
<button
onClick={() => { if (!creating) setShowCreate(false); }}
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl transition-colors"
>
<X size={18} />
</button>
</div>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">Nome do {unitLabel.toLowerCase()} *</label>
<input
autoFocus
required
value={newNome}
onChange={e => setNewNome(e.target.value.toUpperCase())}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-teal-500 outline-none text-sm font-medium uppercase"
placeholder={isConsultorioAccount ? 'EX: DR. SILVA - CONSULTÓRIO' : 'EX: CLÍNICA SORRISO'}
/>
</div>
<div>
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">CNPJ / CPF (opcional)</label>
<input
value={newDoc}
onChange={e => setNewDoc(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-teal-500 outline-none text-sm font-medium"
placeholder="00.000.000/0000-00"
/>
</div>
<button
type="submit"
disabled={creating || !newNome.trim()}
className="w-full bg-teal-600 hover:bg-teal-700 text-white py-3 rounded-xl font-black uppercase flex items-center justify-center gap-2 transition-all shadow-lg disabled:opacity-60 disabled:cursor-not-allowed"
>
{creating ? <><Loader2 className="animate-spin" size={18} /> Criando...</> : <><Plus size={18} /> Criar {unitLabel.toLowerCase()}</>}
</button>
</form>
</div>
</div>
)}
</div>
);
};
+3 -12
View File
@@ -10,17 +10,6 @@ function getStoredUser(): { nome?: string; role?: string } {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}'); } catch { return {}; }
}
function applyWorkspace(result: any, userRole: string) {
const ws = {
id: result.clinica.id,
nome: result.clinica.nome_fantasia,
cor: '#2563eb',
role: userRole,
};
localStorage.setItem('SCOREODONTO_WORKSPACES', JSON.stringify([ws]));
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(ws));
}
export const CreateClinicView: React.FC<CreateClinicViewProps> = ({ onCreated }) => {
const storedUser = getStoredUser();
const userRole = storedUser.role || 'donoclinica';
@@ -37,7 +26,9 @@ export const CreateClinicView: React.FC<CreateClinicViewProps> = ({ onCreated })
const doCreate = async (nomeFinal: string, doc?: string) => {
const result = await HybridBackend.createClinica(nomeFinal.trim(), doc?.trim() || undefined);
if (!result.success) return result;
applyWorkspace(result, userRole);
// Recarrega a lista do servidor (acrescenta a nova unidade às existentes em vez de
// sobrescrever). No onboarding o usuário tem só esta unidade, então vira a ativa.
await HybridBackend.refreshWorkspaces();
return result;
};
+4 -2
View File
@@ -62,7 +62,9 @@ const PAGINAS_RBAC: Array<{ grupo: string; itens: Array<{ key: string; label: st
{ key: 'pacientes', label: 'PACIENTES' },
{ key: 'ortodontia', label: 'ORTODONTIA' },
{ key: 'tratamentos', label: 'TRATAMENTOS' },
{ key: 'proteses', label: 'PRÓTESES' },
{ key: 'orcamentos', label: 'ORÇAMENTOS' },
{ key: 'contratos', label: 'CONTRATOS' },
{ key: 'rx-radiografias', label: 'RX / RADIOGRAFIAS' },
],
},
@@ -94,8 +96,8 @@ const TODAS_PAGINAS = PAGINAS_RBAC.flatMap(g => g.itens.map(i => i.key));
// Modelos de cargo prontos (acelera a configuração).
const TEMPLATES_CARGO: Array<{ nome: string; nivel: number; permissoes: string[] }> = [
{ nome: 'RECEPÇÃO', nivel: 10, permissoes: ['dashboard', 'agenda', 'pacientes', 'leads', 'orcamentos'] },
{ nome: 'AUXILIAR / ASB', nivel: 20, permissoes: ['dashboard', 'agenda', 'pacientes', 'ortodontia', 'tratamentos'] },
{ nome: 'RECEPÇÃO', nivel: 10, permissoes: ['dashboard', 'agenda', 'pacientes', 'leads', 'orcamentos', 'contratos', 'lancar-gto', 'proteses'] },
{ nome: 'AUXILIAR / ASB', nivel: 20, permissoes: ['dashboard', 'agenda', 'pacientes', 'ortodontia', 'tratamentos', 'proteses'] },
{ nome: 'FINANCEIRO', nivel: 30, permissoes: ['dashboard', 'pacientes', 'financeiro', 'relatorios', 'lancar-gto'] },
{ nome: 'GERENTE', nivel: 50, permissoes: [...TODAS_PAGINAS] },
];
+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>
);
};
+158
View File
@@ -0,0 +1,158 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Building2, Wallet, Clock, RefreshCw, Inbox, CheckCircle2, Phone, CalendarDays, Stethoscope, Briefcase, User } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { PageHeader } from '../components/PageHeader.tsx';
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; };
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, 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]);
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">
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
</div>
);
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 shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
<div className="flex border-b border-gray-100">
{(['clientes', 'financeiro'] as const).map(t => (
<button key={t} onClick={() => setAba(t)} className={`px-4 py-2.5 text-xs font-black uppercase border-b-2 -mb-px transition-colors ${aba === t ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t === 'clientes' ? 'Clientes (CRM)' : 'Financeiro'}</button>
))}
</div>
{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> : (
<>
{/* 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>
</>
)
)}
{aba === 'financeiro' && (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<KPI titulo="Faturado" valor={fmtBRL(totFaturado)} cor="text-gray-700" Icon={CheckCircle2} />
<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"><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>
{/* 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>
</>
)}
</>
)}
</div>
);
};
+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>
);
};
+117
View File
@@ -0,0 +1,117 @@
import React, { useState, useEffect, useCallback } from 'react';
import { UsersRound, Building2, RefreshCw, Trash2, Plus, KeyRound, Save } 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';
// Área do Laboratório (Fase 4): dados PF/PJ (CPF/CNPJ) + equipe de técnicos.
export const LabEquipeView: React.FC = () => {
const toast = useToast();
const confirm = useConfirm();
const [dados, setDados] = useState<any>(null);
const [equipe, setEquipe] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [email, setEmail] = useState('');
const [nome, setNome] = useState('');
const [cred, setCred] = useState<{ email: string; senha: string } | null>(null);
const carregar = useCallback(async () => {
setLoading(true);
try {
const [d, e] = await Promise.all([HybridBackend.getLabDados(), HybridBackend.getLabEquipe()]);
setDados(d || {});
setEquipe(Array.isArray(e) ? e : []);
} catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
const salvarDados = async () => {
if (!dados?.nome_fantasia?.trim()) { toast.error('INFORME O NOME.'); return; }
setBusy(true);
try { await HybridBackend.updateLabDados({ nome_fantasia: dados.nome_fantasia, documento: dados.documento || '', pessoa_tipo: dados.pessoa_tipo }); toast.success('DADOS SALVOS.'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const addTecnico = async () => {
if (!email.trim()) { toast.error('INFORME O E-MAIL.'); return; }
setBusy(true);
try {
const r = await HybridBackend.addLabTecnico({ email: email.trim(), nome: nome.trim() || undefined });
toast.success('TÉCNICO ADICIONADO.');
if (r?.created && r?.tempPassword) setCred({ email: email.trim().toLowerCase(), senha: r.tempPassword });
setEmail(''); setNome(''); carregar();
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const remover = async (u: any) => {
if (!(await confirm(`REMOVER ${u.nome || u.email} DA EQUIPE?`))) return;
try { await HybridBackend.removeLabTecnico(u.id); toast.success('REMOVIDO.'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
};
if (loading || !dados) return <div className="flex justify-center py-20"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>;
const ehPJ = dados.pessoa_tipo === 'PJ';
return (
<div className="space-y-6 pb-10 max-w-2xl">
<PageHeader title="LABORATÓRIO & EQUIPE">
<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>
{/* Dados do provedor */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 space-y-4">
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><Building2 size={16} className="text-teal-600" /> Dados do provedor</h3>
<div className="flex gap-2">
{(['PF', 'PJ'] as const).map(t => (
<button key={t} onClick={() => setDados((d: any) => ({ ...d, pessoa_tipo: t }))} className={`flex-1 py-2.5 rounded-xl font-black text-xs uppercase border-2 transition-colors ${dados.pessoa_tipo === t ? 'border-teal-500 bg-teal-50 text-teal-700' : 'border-gray-100 text-gray-400'}`}>
{t === 'PF' ? 'Protético Individual (PF)' : 'Laboratório (PJ)'}
</button>
))}
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{ehPJ ? 'Nome Fantasia' : 'Nome'}</label>
<input value={dados.nome_fantasia || ''} onChange={e => setDados((d: any) => ({ ...d, nome_fantasia: e.target.value }))} className="w-full p-3 bg-gray-50 border-2 border-gray-100 rounded-xl text-sm font-bold text-gray-700 outline-none focus:border-teal-400 uppercase" />
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{ehPJ ? 'CNPJ' : 'CPF'}</label>
<input value={dados.documento || ''} onChange={e => setDados((d: any) => ({ ...d, documento: e.target.value }))} placeholder={ehPJ ? '00.000.000/0000-00' : '000.000.000-00'} className="w-full p-3 bg-gray-50 border-2 border-gray-100 rounded-xl text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
</div>
<button disabled={busy} onClick={salvarDados} className="bg-teal-600 text-white px-4 py-2.5 rounded-xl font-black text-sm uppercase disabled:opacity-50 flex items-center gap-2"><Save size={15} /> Salvar dados</button>
</div>
{/* Equipe */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 space-y-4">
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><UsersRound size={16} className="text-violet-600" /> Equipe de técnicos</h3>
<p className="text-[11px] text-gray-400 font-bold uppercase">Técnicos veem a bancada e operam a produção sem acesso a clientes, financeiro ou valores do paciente.</p>
{cred && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 text-xs">
<p className="font-black text-amber-700 uppercase flex items-center gap-1.5"><KeyRound size={13} /> Credencial criada</p>
<p className="text-amber-800 mt-1">{cred.email} · senha temporária: <b>{cred.senha}</b></p>
<button onClick={() => setCred(null)} className="text-[10px] font-black text-amber-600 uppercase mt-1">OK, anotei</button>
</div>
)}
<div className="space-y-1.5">
{equipe.map(u => (
<div key={u.id} className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2 text-xs">
<div className="w-7 h-7 rounded-lg bg-violet-100 flex items-center justify-center text-violet-600 font-black shrink-0">{(u.nome || u.email || '?')[0].toUpperCase()}</div>
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-700 truncate uppercase">{u.nome || '—'}</p>
<p className="text-[10px] text-gray-400 truncate">{u.email}</p>
</div>
<button onClick={() => remover(u)} className="text-gray-300 hover:text-red-500"><Trash2 size={15} /></button>
</div>
))}
{equipe.length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum técnico na equipe.</p>}
</div>
<div className="flex gap-2">
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Nome (opcional)" className="w-32 p-2.5 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="E-mail do técnico" className="flex-1 p-2.5 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<button disabled={busy} onClick={addTecnico} className="px-3 rounded-lg bg-violet-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={14} /></button>
</div>
</div>
</div>
);
};
+279
View File
@@ -0,0 +1,279 @@
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, 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: 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">
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
{sub && <p className="mt-2 text-[10px] font-bold text-gray-400 uppercase">{sub}</p>}
</div>
);
// Distribuição por status (fila de produção).
const STATUS_ORD = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
const STATUS_LABEL: Record<string, string> = { solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Produção', prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado' };
const porStatus = STATUS_ORD.map(s => ({ s, n: ativas.filter(o => o.status === s).length })).filter(x => x.n > 0);
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>
</PageHeader>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm px-6 py-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-white shrink-0" style={{ backgroundColor: '#2d6a4f' }}><FlaskConical size={20} /></div>
<div className="min-w-0">
<p className="font-black text-gray-900 uppercase truncate">{ws?.nome || 'Laboratório'}</p>
<p className="text-[11px] text-gray-400 font-bold uppercase">Área de gestão do protético</p>
</div>
</div>
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KPI titulo="Na bancada" valor={String(ativas.length)} cor="text-teal-600" Icon={Inbox} sub="OS em produção" />
<KPI titulo="Atrasadas" valor={String(atrasadas)} cor="text-red-500" Icon={AlertTriangle} sub="Prazo vencido" />
<KPI titulo="Entregues no mês" valor={String(entreguesMes)} cor="text-green-600" Icon={CheckCircle2} sub="Concluídas" />
<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">
<Clock size={16} className="text-teal-600" />
<h3 className="text-sm font-black text-gray-800 uppercase">Fila de produção</h3>
</div>
{porStatus.length === 0 ? (
<div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhuma OS em produção.</div>
) : (
<div className="p-4 flex flex-wrap gap-3">
{porStatus.map(({ s, n }) => (
<div key={s} className="px-4 py-3 rounded-xl bg-gray-50 border border-gray-100 min-w-[120px]">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{STATUS_LABEL[s]}</p>
<p className="text-2xl font-black text-gray-800">{n}</p>
</div>
))}
</div>
)}
</div>
{/* Próximas entregas */}
<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">
<h3 className="text-sm font-black text-gray-800 uppercase">Próximas entregas</h3>
</div>
{ativas.filter(o => o.prazo_entrega).length === 0 ? (
<div className="py-10 text-center text-gray-400 text-sm font-bold uppercase">Sem prazos agendados.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Paciente', 'Tipo', 'Prazo', 'Status', 'Valor'].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">
{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">{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>
<td className="px-4 py-3 font-black text-gray-800">{fmtBRL(o.custo)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</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>
);
};
+72
View File
@@ -0,0 +1,72 @@
import React, { useState, useEffect, useCallback } from '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';
// Área do Laboratório (Fase 5): indicadores derivados da captura + Nota ScoreOdonto.
export const LabIndicadoresView: React.FC = () => {
const [ind, setInd] = useState<any>(null);
const [loading, setLoading] = useState(false);
const carregar = useCallback(async () => {
setLoading(true);
try { setInd(await HybridBackend.getLabIndicadores()); } catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
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">
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
{sub && <p className="mt-2 text-[10px] font-bold text-gray-400 uppercase">{sub}</p>}
</div>
);
if (loading || !ind) return <div className="flex justify-center py-20"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>;
const temNota = ind.nota !== null && ind.nota !== undefined;
const notaCor = ind.nota >= 9 ? 'text-green-600' : ind.nota >= 7 ? 'text-teal-600' : ind.nota >= 5 ? 'text-amber-500' : 'text-red-500';
return (
<div className="space-y-6 pb-10">
<PageHeader title="INDICADORES 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 shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
{/* Nota ScoreOdonto */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 flex items-center gap-5">
<div className={`w-20 h-20 rounded-2xl border-4 flex flex-col items-center justify-center shrink-0 ${temNota ? 'border-current ' + notaCor : 'border-gray-200 text-gray-300'}`}>
<Award size={18} />
<span className="text-2xl font-black leading-none mt-0.5">{temNota ? ind.nota.toFixed(1) : '—'}</span>
</div>
<div className="min-w-0">
<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">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>
)}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KPI titulo="Entregues" valor={String(ind.entregues)} cor="text-green-600" Icon={CheckCircle2} sub={`${ind.total} OS no total`} />
<KPI titulo="Tempo médio" valor={`${ind.dias_medio} d`} cor="text-blue-600" Icon={Clock} sub="Solicitado → entregue" />
<KPI titulo="Atrasos" valor={`${ind.atraso_pct}%`} cor="text-amber-500" Icon={AlertTriangle} sub={`${ind.atrasados} entrega(s)`} />
<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">
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2 mb-2"><BarChart3 size={16} className="text-teal-600" /> Como a nota é calculada</h3>
<p className="text-xs text-gray-500">Base 10, descontando proporcionalmente: <b>atraso</b> (peso 3), <b>retrabalho/garantia</b> (peso 4) e <b>ocorrências resolvidas</b> (peso 3). ocorrências <b>resolvidas</b> (após contraditório) entram abertas/contestadas não pesam. A nota aparece para as clínicas no marketplace ao escolherem o laboratório.</p>
</div>
</div>
);
};
+189 -52
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';
@@ -32,6 +34,9 @@ interface GTOItem {
valorUnitario: number;
valorTotal: number;
observacao?: string;
// Prótese (Fase 1): item ligado ao catálogo do protético → gera OS ao finalizar.
produtoId?: string;
proteticoId?: string;
}
interface GTOStore {
@@ -119,6 +124,13 @@ export const LancarGTO: React.FC = () => {
const [patientSearchOpen, setPatientSearchOpen] = useState(false);
const [patientSearchTerm, setPatientSearchTerm] = useState('');
// --- PRÓTESE (Fase 1): catálogo do protético entra como "procedimentos" do GTO ---
const [selectedLab, setSelectedLab] = useState<string>('');
const [labProdutos, setLabProdutos] = useState<any[]>([]);
const [selectedProdutoId, setSelectedProdutoId] = useState<string>('');
const [protValor, setProtValor] = useState<string>('');
const [protObs, setProtObs] = useState<string>('');
useEffect(() => {
const load = async () => {
const p = await HybridBackend.getPacientes();
@@ -140,14 +152,49 @@ export const LancarGTO: React.FC = () => {
return procedimentos.filter(p => p.especialidadeId === selectedEsp);
}, [selectedEsp, procedimentos]);
const filteredPatients = useMemo(() => {
if (!patientSearchTerm) return pacientes.slice(0, 30);
const term = normalize(patientSearchTerm);
return pacientes.filter(p =>
normalize(p.nome).includes(term) ||
(p.cpf && p.cpf.includes(patientSearchTerm.replace(/\D/g, '')))
).slice(0, 30);
}, [pacientes, patientSearchTerm]); // eslint-disable-line react-hooks/exhaustive-deps
// Especialidade de prótese → o "procedimento" vem do catálogo do protético (não da
// tabela procedimentos). Detecta pela área da especialidade (re-tagueada no backend).
const isProtese = useMemo(
() => (especialidades.find(e => e.id === selectedEsp)?.area || '') === 'protese',
[especialidades, selectedEsp]
);
const selectedProduto = useMemo(
() => labProdutos.find(p => p.id === selectedProdutoId) || null,
[labProdutos, selectedProdutoId]
);
// Catálogo do laboratório selecionado.
useEffect(() => {
if (!selectedLab) { setLabProdutos([]); return; }
let active = true;
HybridBackend.getLaboratorioProdutos(selectedLab)
.then(p => { if (active) setLabProdutos(Array.isArray(p) ? p : []); })
.catch(() => { if (active) setLabProdutos([]); });
return () => { active = false; };
}, [selectedLab]);
// Busca de pacientes é SERVER-SIDE: a clínica pode ter milhares e a rota sem termo
// devolve só as 50 primeiras. Filtrar essas 50 no cliente fazia a busca "não achar"
// quem estava fora da 1ª página. Sem termo, mostra a lista inicial (recentes).
const [patientResults, setPatientResults] = useState<Paciente[]>([]);
const [searchingPatients, setSearchingPatients] = useState(false);
useEffect(() => {
const term = patientSearchTerm.trim();
if (!term) { setPatientResults(pacientes.slice(0, 30)); setSearchingPatients(false); return; }
let active = true;
setSearchingPatients(true);
const t = setTimeout(async () => {
try {
const r: any = await HybridBackend.getPacientes(term);
const list: Paciente[] = Array.isArray(r) ? r : (r?.data ?? []);
if (active) setPatientResults(list);
} finally {
if (active) setSearchingPatients(false);
}
}, 300);
return () => { active = false; clearTimeout(t); };
}, [patientSearchTerm, pacientes]);
useEffect(() => {
const { pendingSearch } = store;
@@ -250,6 +297,31 @@ export const LancarGTO: React.FC = () => {
setArcoObs('');
};
// Adiciona um item de prótese (catálogo do protético) ao rascunho do GTO.
const handleAddProtese = () => {
if (!selectedLab) { toast.error("SELECIONE O LABORATÓRIO."); return; }
if (!selectedProduto) { toast.error("SELECIONE O PRODUTO DE PRÓTESE."); return; }
if (store.items.length >= 20) { toast.error("LIMITE DE 20 ITENS ATINGIDO."); return; }
const valor = parseFloat(protValor) || Number(selectedProduto.preco) || 0;
store.addItem({
id: Math.random().toString(36).substring(7),
procedimentoId: '',
codigoTUSS: '',
codigoInterno: '',
descricao: selectedProduto.nome,
quantidade: 1,
valorUnitario: valor,
valorTotal: valor,
observacao: protObs || undefined,
produtoId: selectedProduto.id,
proteticoId: selectedLab,
});
toast.success("PRÓTESE ADICIONADA AO RASCUNHO.");
setSelectedProdutoId('');
setProtValor('');
setProtObs('');
};
const handleFinalize = async () => {
if (!store.paciente || !store.dentista || store.items.length === 0) {
toast.error("PACIENTE, DENTISTA E PELO MENOS 1 ITEM SÃO OBRIGATÓRIOS.");
@@ -276,9 +348,12 @@ export const LancarGTO: React.FC = () => {
credenciadaId: store.credenciada,
});
for (const it of store.items) {
const { arco, id: _localId, ...rest } = it as any;
const { arco, id: _localId, produtoId, proteticoId, ...rest } = it as any;
void arco; void _localId;
await HybridBackend.addGtoItem(id, rest);
const payload: any = { ...rest };
// Prótese: envia produto/protético (snake_case) → o backend abre a OS na finalização.
if (produtoId) { payload.produto_id = produtoId; payload.protetico_id = proteticoId; }
await HybridBackend.addGtoItem(id, payload);
}
const fin = await HybridBackend.finalizarGto(id);
toast.success(fin?.financeiro_id
@@ -383,37 +458,104 @@ 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);
setSelectedProdutoId('');
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 && (
{selectedEsp && isProtese && (
<div className="space-y-3">
<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">
<p className="text-[11px] font-black text-amber-700 uppercase tracking-wide">Este laboratório não tem produtos ativos</p>
</div>
) : (
<>
<SearchSelect
value={selectedProdutoId}
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"
/>
{selectedProduto && (
<>
<div>
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5">Valor (R$)</label>
<input
type="number"
step="0.01"
value={protValor}
onChange={e => setProtValor(e.target.value)}
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-teal-400 outline-none transition-all"
placeholder="0,00"
/>
</div>
<textarea
value={protObs}
onChange={e => setProtObs(e.target.value)}
rows={2}
placeholder="OBSERVAÇÃO PARA O PROTÉTICO (cor, material, dentes...)"
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl text-[11px] focus:border-amber-400 outline-none transition-all resize-none"
/>
<div className="bg-teal-50 p-3 rounded-xl border border-teal-100">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-800">
<ChevronRight size={10} /> AO FINALIZAR A GTO, UMA OS DE PRÓTESE SERÁ ABERTA NA BANCADA DO LABORATÓRIO.
</div>
</div>
<button
onClick={handleAddProtese}
className="w-full bg-[#2d6a4f] text-white py-4 rounded-2xl font-black text-sm uppercase shadow-lg shadow-green-100 hover:bg-[#1b4332] active:scale-95 transition-all flex items-center justify-center gap-2 group"
>
ADICIONAR PRÓTESE <ChevronRight size={16} className="group-hover:translate-x-1 transition-transform" />
</button>
</>
)}
</>
))}
</div>
)}
{selectedEsp && !isProtese && (
<div className="space-y-2">
<select
value={selectedProc?.id || ''}
onChange={(e) => {
const pr = procedimentos.find(p => p.id === e.target.value);
setSelectedProc(pr || null);
setSelectedDentes([]);
setSelectedArco(null);
setArcoFace('');
setArcoObs('');
}}
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>
{filteredProcedimentos.length === 0 ? (
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
<p className="text-[11px] font-black text-amber-700 uppercase tracking-wide">Nenhum procedimento cadastrado nesta especialidade</p>
<p className="text-[10px] font-bold text-amber-500 uppercase mt-1">Cadastre em Procedimentos para usá-lo aqui</p>
</div>
) : (
<SearchSelect
value={selectedProc?.id || ''}
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"
/>
)}
{selectedProc && (
<div className="bg-teal-50 p-3 rounded-xl border border-teal-100">
@@ -443,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>
);
@@ -854,12 +991,12 @@ export const LancarGTO: React.FC = () => {
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-teal-500 outline-none transition-all uppercase"
/>
<div className="max-h-72 overflow-y-auto space-y-1">
{filteredPatients.length === 0 ? (
{patientResults.length === 0 ? (
<div className="py-10 text-center text-gray-300 font-bold uppercase text-xs">
{patientSearchTerm ? 'NENHUM PACIENTE ENCONTRADO' : 'CARREGANDO...'}
{searchingPatients ? 'BUSCANDO...' : (patientSearchTerm ? 'NENHUM PACIENTE ENCONTRADO' : 'CARREGANDO...')}
</div>
) : (
filteredPatients.map(p => (
patientResults.map(p => (
<button
key={p.id}
onClick={() => {
+2 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Building2, Briefcase, Stethoscope, Sparkles, DoorOpen,
Building2, Briefcase, Stethoscope, Sparkles, DoorOpen, FlaskConical,
ArrowRight, ArrowLeft, ChevronLeft, Loader2, Send, Check, CheckCircle2, User as UserIcon
} from 'lucide-react';
@@ -17,6 +17,7 @@ const SLIDES: { icon: any; accent: string; title: string; desc: string; cta: str
{ icon: Briefcase, accent: '#0d9488', title: 'Consultórios', desc: 'Para quem voa solo: agenda inteligente e cobrança sem complicação.', cta: 'Cadastrar meu consultório', pre: { perfil: 'consultorio' } },
{ icon: Stethoscope, accent: '#7c3aed', title: 'Dentistas', desc: 'Sua agenda única — sem overbooking, mesmo atendendo em vários lugares.', cta: 'Sou dentista', pre: { perfil: 'profissional', profissao: 'dentista' } },
{ icon: Sparkles, accent: '#db2777', title: 'Biomédicos', desc: 'Harmonização orofacial e estética com prontuário e fotos por procedimento.', cta: 'Sou biomédico(a)', pre: { perfil: 'profissional', profissao: 'biomedico' } },
{ icon: FlaskConical, accent: '#2d6a4f', title: 'Protéticos', desc: 'Laboratório completo: bancada, ordens, etapas, componentes, financeiro e score.', cta: 'Sou protético(a)', pre: { perfil: 'profissional', profissao: 'protetico' } },
{ icon: DoorOpen, accent: '#0d9488', title: 'Salas', desc: 'Alugue ou anuncie salas por hora, dia ou mês. Valor fixo ou negociado.', cta: 'Tenho salas para alugar', pre: { perfil: 'sala' } },
];
+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>
+283
View File
@@ -0,0 +1,283 @@
import React, { useState, useEffect, useCallback } from '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';
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 dataHoraBR = (d: string) => { if (!d) return ''; const x = new Date(d); return isNaN(x.getTime()) ? dataBR(d) : x.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }); };
const STATUS_LABEL: Record<string, string> = { solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção', prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado', entregue: 'Entregue', cancelado: 'Cancelado' };
const STATUS_COR: Record<string, string> = { solicitado: 'bg-gray-100 text-gray-600', recebido: 'bg-blue-100 text-blue-700', producao: 'bg-amber-100 text-amber-700', prova: 'bg-indigo-100 text-indigo-700', ajuste: 'bg-orange-100 text-orange-700', finalizado: 'bg-teal-100 text-teal-700', entregue: 'bg-green-100 text-green-700', cancelado: 'bg-red-100 text-red-600' };
const FLUXO = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue'];
const OCOR_CATS: Record<string, string> = { nao_devolvido: 'Componente não devolvido', danificado: 'Componente danificado', incompativel: 'Componente incompatível', parafuso_substituido: 'Parafuso substituído', parafuso_desgastado: 'Parafuso desgastado', peca_incorreta: 'Peça incorreta', ajuste_inadequado: 'Ajuste inadequado', falha_fabricacao: 'Falha de fabricação', retrabalho: 'Retrabalho', outro: 'Outro' };
const Secao: React.FC<{ titulo: string; Icon: any; children: React.ReactNode }> = ({ titulo, Icon, children }) => (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-1.5"><Icon size={12} /> {titulo}</p>
{children}
</div>
);
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);
const [erro, setErro] = useState('');
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);
try { await fn(); toast.success(ok); await recarregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const receber = () => act(() => HybridBackend.custodiaPublic(token, 'laboratorio'), 'RECEBIMENTO CONFIRMADO.');
const devolver = () => act(() => HybridBackend.custodiaPublic(token, 'clinica'), 'DEVOLUÇÃO REGISTRADA.');
const avancar = (s: string) => act(() => HybridBackend.statusPublic(token, s), 'ETAPA ATUALIZADA.');
const anexar = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]; if (!f) return;
act(() => HybridBackend.uploadFotoPublic(token, f), 'FOTO ANEXADA.'); e.target.value = '';
};
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50"><Loader2 className="animate-spin text-teal-600" size={32} /></div>;
if (erro || !os) return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6 text-center">
<AlertTriangle size={40} className="text-amber-400" />
<p className="mt-3 font-black text-gray-700 uppercase text-sm">{erro || 'Link inválido'}</p>
<p className="text-xs text-gray-400 mt-1">Peça um novo link à clínica.</p>
</div>
);
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
const idxAtual = FLUXO.indexOf(os.status);
const cancelado = os.status === 'cancelado';
return (
<div className="min-h-screen bg-gray-50">
{/* Topo / marca */}
<header className="bg-[#2d6a4f] text-white px-4 py-3 flex items-center gap-2 sticky top-0 z-10">
<FlaskConical size={18} />
<div className="min-w-0 flex-1">
<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>
{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>
{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">
<div className="min-w-0">
<h1 className="font-black text-gray-800 text-base leading-tight">{os.tipo}</h1>
<p className="text-xs text-gray-400 mt-0.5">OS {os.id}</p>
</div>
<span className={`text-[9px] font-black px-2 py-1 rounded-full uppercase shrink-0 ${STATUS_COR[os.status] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[os.status] || os.status}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{os.clinica_nome && <span className="text-[10px] font-black text-blue-700 bg-blue-50 px-2 py-1 rounded-lg uppercase flex items-center gap-1"><Building2 size={11} /> {os.clinica_nome}</span>}
<span className={`text-[10px] font-black px-2 py-1 rounded-lg uppercase flex items-center gap-1 ${noLab ? 'bg-violet-50 text-violet-700' : 'bg-blue-50 text-blue-700'}`}><MapPin size={11} /> {noLab ? 'Com o laboratório' : 'Na clínica'}</span>
{os.tipo_os === 'garantia' && <span className="text-[10px] font-black text-emerald-700 bg-emerald-50 px-2 py-1 rounded-lg uppercase flex items-center gap-1"><ShieldCheck size={11} /> Garantia</span>}
</div>
</div>
{/* Ações do protético (sem conta) */}
{!os.finalizada && (
<Secao titulo="Atualizar status" Icon={Zap}>
<div className="space-y-2">
{noLab
? <button disabled={busy} onClick={devolver} className="w-full py-2.5 rounded-xl bg-blue-600 text-white font-black text-xs uppercase disabled:opacity-50">Devolvi à clínica</button>
: <button disabled={busy} onClick={receber} className="w-full py-2.5 rounded-xl bg-[#2d6a4f] text-white font-black text-xs uppercase disabled:opacity-50">Recebi o trabalho</button>}
<div>
<p className="text-[9px] font-black text-gray-400 uppercase mb-1">Etapa de produção</p>
<div className="flex flex-wrap gap-1.5">
{PROD_FLUXO.map(s => (
<button key={s} disabled={busy || os.status === s} onClick={() => avancar(s)}
className={`px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase disabled:opacity-40 ${os.status === s ? 'bg-[#2d6a4f] text-white' : 'border border-gray-200 text-gray-500'}`}>{STATUS_LABEL[s]}</button>
))}
</div>
</div>
<label className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border border-dashed border-gray-200 text-gray-500 text-xs font-bold uppercase cursor-pointer ${busy ? 'opacity-50 pointer-events-none' : ''}`}>
<ImagePlus size={15} /> Anexar foto do trabalho
<input type="file" accept="image/*" className="hidden" onChange={anexar} />
</label>
</div>
</Secao>
)}
{/* Etapas */}
<Secao titulo="Etapas" Icon={Clock}>
{cancelado ? (
<div className="bg-red-50 rounded-xl px-3 py-2 text-xs font-black text-red-600 uppercase">OS cancelada</div>
) : (
<div className="flex items-start overflow-x-auto pb-1 -mx-1 px-1">
{FLUXO.map((s, i) => {
const done = i < idxAtual, atual = i === idxAtual;
const ev = (os.eventos || []).find((e: any) => e.status === s);
return (
<React.Fragment key={s}>
<div className="flex flex-col items-center min-w-[52px]">
{done ? <CheckCircle2 size={18} className="text-teal-600" /> : atual ? <div className="w-4 h-4 rounded-full bg-[#2d6a4f] ring-4 ring-green-100" /> : <Circle size={18} className="text-gray-200" />}
<span className={`text-[7px] font-black uppercase mt-1 text-center leading-tight ${atual ? 'text-[#2d6a4f]' : done ? 'text-teal-700' : 'text-gray-300'}`}>{STATUS_LABEL[s]}</span>
<span className="text-[7px] text-gray-300">{ev ? dataBR(ev.created_at) : ''}</span>
</div>
{i < FLUXO.length - 1 && <div className={`flex-1 h-0.5 mt-2 min-w-[8px] ${i < idxAtual ? 'bg-teal-400' : 'bg-gray-100'}`} />}
</React.Fragment>
);
})}
</div>
)}
</Secao>
{/* Dados */}
<Secao titulo="Dados do trabalho" Icon={FlaskConical}>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
{os.paciente_nome && <div className="col-span-2"><span className="text-gray-400 font-bold uppercase text-[9px] block">Paciente</span>{os.paciente_nome}</div>}
{os.dentista_nome && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentista</span>{os.dentista_nome}</div>}
{os.dentes && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentes</span>{os.dentes}</div>}
{os.material && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Material</span>{os.material}</div>}
{os.cor && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Cor</span>{os.cor}</div>}
{os.prazo_entrega && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Prazo</span>{dataBR(os.prazo_entrega)}</div>}
{Number(os.custo) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Valor do lab</span>{fmtBRL(os.custo)}</div>}
</div>
{os.observacoes && <p className="text-xs text-gray-600 bg-gray-50 rounded-xl p-3 mt-3">{os.observacoes}</p>}
{os.garantia_ate && <div className="bg-emerald-50 rounded-xl px-3 py-2 mt-3 flex items-center gap-2 text-xs text-emerald-700 font-bold"><ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)}</div>}
</Secao>
{/* Componentes */}
{(os.componentes || []).length > 0 && (
<Secao titulo="Componentes" Icon={Package}>
<div className="space-y-1.5">
{os.componentes.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.direcao === 'devolvido' ? 'bg-blue-100 text-blue-700' : 'bg-teal-100 text-teal-700'}`}>{c.direcao === 'devolvido' ? 'Devolvido' : 'Enviado'}</span>
<span className="font-black text-gray-700">{c.quantidade}×</span>
<span className="font-bold text-gray-700 flex-1 truncate">{c.nome}</span>
</div>
))}
</div>
</Secao>
)}
{/* Fotos */}
{(os.fotos || []).length > 0 && (
<Secao titulo="Fotos do trabalho" Icon={Camera}>
<div className="grid grid-cols-2 gap-2">
{os.fotos.map((f: any) => (
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" className="block rounded-lg overflow-hidden border border-gray-100">
<img src={f.url} alt="" className="w-full h-24 object-cover" />
{f.legenda && <p className="text-[10px] font-bold text-gray-600 truncate px-2 py-1">{f.legenda}</p>}
</a>
))}
</div>
</Secao>
)}
{/* Ocorrências */}
{(os.ocorrencias || []).length > 0 && (
<Secao titulo="Ocorrências" Icon={AlertTriangle}>
<div className="space-y-2">
{os.ocorrencias.map((o: any) => (
<div key={o.id} className="border border-gray-100 rounded-xl p-2.5 text-xs">
<div className="flex items-center gap-2">
<span className="text-[8px] font-black px-1.5 py-0.5 rounded uppercase bg-gray-100 text-gray-600">{(o.status || '').replace('_', ' ')}</span>
<span className="font-black text-gray-700 flex-1">{OCOR_CATS[o.categoria] || o.categoria}</span>
</div>
<p className="text-gray-600 mt-1">{o.descricao}</p>
{o.resposta && <p className="text-gray-500 mt-1"><b className="uppercase text-[9px]">Resposta:</b> {o.resposta}</p>}
</div>
))}
</div>
</Secao>
)}
{/* Histórico */}
<Secao titulo="Histórico" Icon={History}>
<div className="space-y-2">
{(os.eventos || []).map((ev: any, i: number) => (
<div key={i} className="flex items-start gap-2 text-xs">
<span className={`w-2 h-2 rounded-full mt-1 shrink-0 ${ev.status === 'cancelado' ? 'bg-red-400' : ev.status === 'entregue' ? 'bg-green-500' : 'bg-[#2d6a4f]'}`} />
<div className="min-w-0">
<span className="font-bold text-gray-700">{ev.observacao || STATUS_LABEL[ev.status] || ev.status}</span>
<span className="text-gray-400 block text-[10px]">{dataHoraBR(ev.created_at)}{ev.actor_nome ? ` · ${ev.actor_nome}` : ''}</span>
</div>
</div>
))}
</div>
</Secao>
{/* CTA de conversão */}
<a href="/" className="block bg-[#2d6a4f] text-white rounded-2xl p-4 text-center active:scale-[0.99] transition-transform">
<p className="font-black text-sm uppercase">Gostou de acompanhar por aqui?</p>
<p className="text-[11px] text-white/80 mt-1">Crie sua conta grátis e gerencie seu laboratório: bancada, etapas, financeiro e score.</p>
<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>}
</div>
);
};
File diff suppressed because it is too large Load Diff
+102 -14
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { DoorOpen, Wallet, Clock, CalendarClock, CheckCircle, RefreshCw, MapPin, Inbox, BarChart3, Filter, ChevronLeft, ChevronRight, CalendarDays } from 'lucide-react';
import { DoorOpen, Wallet, Clock, CalendarClock, CheckCircle, RefreshCw, MapPin, Inbox, BarChart3, Filter, ChevronLeft, ChevronRight, CalendarDays, Wrench, X } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
@@ -14,7 +14,7 @@ const H_INI = 7, H_FIM = 22, ROW_H = 40; // 07h22h, 40px/hora
const segOf = (d: Date) => { const x = new Date(d); const dow = (x.getDay() + 6) % 7; x.setHours(0, 0, 0, 0); x.setDate(x.getDate() - dow); return x; };
const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
const AgendaSala: React.FC<{ reservas: any[]; onBloquear?: () => void }> = ({ reservas, onBloquear }) => {
const [weekStart, setWeekStart] = useState(() => segOf(new Date()));
const dias = Array.from({ length: 7 }, (_, i) => { const d = new Date(weekStart); d.setDate(d.getDate() + i); return d; });
const ativas = reservas.filter(r => r.status === 'pendente' || r.status === 'confirmada');
@@ -26,12 +26,17 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
if (!sameDay(ini, dia)) return null;
const top = Math.max(0, (ini.getHours() + ini.getMinutes() / 60 - H_INI) * ROW_H);
const height = Math.max(16, ((fim.getTime() - ini.getTime()) / 3600000) * ROW_H);
const manut = r.tipo === 'manutencao';
const conf = r.status === 'confirmada';
const cls = manut ? 'bg-gray-200 border-gray-400 text-gray-700'
: conf ? 'bg-teal-100 border-teal-300 text-teal-800'
: 'bg-amber-100 border-amber-300 text-amber-800';
const legenda = manut ? (r.motivo || 'Manutenção') : (r.solicitante_nome || r.clinica_nome || 'Reserva');
return (
<div key={r.id} className={`absolute left-0.5 right-0.5 rounded-md px-1 py-0.5 overflow-hidden border ${conf ? 'bg-teal-100 border-teal-300 text-teal-800' : 'bg-amber-100 border-amber-300 text-amber-800'}`}
style={{ top, height }} title={`${r.solicitante_nome || r.clinica_nome || ''} ${ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}${fim.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`}>
<div key={r.id} className={`absolute left-0.5 right-0.5 rounded-md px-1 py-0.5 overflow-hidden border ${cls}`}
style={{ top, height }} title={`${legenda} ${ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}${fim.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`}>
<p className="text-[9px] font-black leading-tight truncate">{ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}</p>
<p className="text-[9px] font-bold leading-tight truncate uppercase">{r.solicitante_nome || r.clinica_nome || 'Reserva'}</p>
<p className="text-[9px] font-bold leading-tight truncate uppercase">{legenda}</p>
</div>
);
});
@@ -41,6 +46,11 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between gap-3">
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><CalendarDays size={16} className="text-teal-600" /> Agenda da sala</h3>
<div className="flex items-center gap-2">
{onBloquear && (
<button onClick={onBloquear} className="flex items-center gap-1.5 bg-gray-700 text-white px-3 py-1.5 rounded-lg font-black text-[10px] uppercase hover:bg-gray-800 transition-colors">
<Wrench size={12} /> Bloquear manutenção
</button>
)}
<span className="text-[11px] font-black text-gray-500 uppercase">{label}</span>
<button onClick={() => setWeekStart(d => { const x = new Date(d); x.setDate(x.getDate() - 7); return x; })} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-500"><ChevronLeft size={16} /></button>
<button onClick={() => setWeekStart(segOf(new Date()))} className="text-[10px] font-black uppercase text-teal-600 hover:bg-teal-50 px-2 py-1.5 rounded-lg">Hoje</button>
@@ -82,6 +92,63 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
<div className="px-6 py-2 flex gap-4 text-[10px] font-bold text-gray-400 uppercase border-t border-gray-50">
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-teal-200 border border-teal-300" /> Confirmada</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-amber-200 border border-amber-300" /> Pendente</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-gray-200 border border-gray-400" /> Manutenção</span>
</div>
</div>
);
};
// Modal: bloquear a sala para manutenção (data + faixa de horário + motivo).
const BloqueioModal: React.FC<{ onClose: () => void; onConfirm: (inicio: string, fim: string, motivo: string) => Promise<void> }> = ({ onClose, onConfirm }) => {
const toast = useToast();
const hojeYmd = new Date().toISOString().split('T')[0];
const [data, setData] = useState(hojeYmd);
const [hIni, setHIni] = useState('08:00');
const [hFim, setHFim] = useState('09:00');
const [motivo, setMotivo] = useState('');
const [saving, setSaving] = useState(false);
const salvar = async () => {
if (!data || !hIni || !hFim) { toast.error('PREENCHA DATA E HORÁRIOS.'); return; }
const inicio = new Date(`${data}T${hIni}`);
const fim = new Date(`${data}T${hFim}`);
if (fim <= inicio) { toast.error('O FIM DEVE SER DEPOIS DO INÍCIO.'); return; }
setSaving(true);
try { await onConfirm(inicio.toISOString(), fim.toISOString(), motivo.trim()); }
catch (e: any) { toast.error((e?.message || 'ERRO AO BLOQUEAR').toUpperCase()); }
finally { setSaving(false); }
};
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h3 className="font-black text-sm uppercase text-gray-800 flex items-center gap-2"><Wrench size={16} className="text-gray-600" /> Bloquear p/ manutenção</h3>
<button onClick={onClose} className="p-1.5 hover:bg-gray-100 rounded-lg"><X size={18} /></button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Data</label>
<input type="date" value={data} onChange={e => setData(e.target.value)} className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Início</label>
<input type="time" value={hIni} onChange={e => setHIni(e.target.value)} className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Fim</label>
<input type="time" value={hFim} onChange={e => setHFim(e.target.value)} className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
</div>
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Motivo (opcional)</label>
<input value={motivo} onChange={e => setMotivo(e.target.value)} placeholder="Ex.: limpeza, conserto do equipo..." className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm text-gray-700 outline-none focus:border-teal-400" />
</div>
<button onClick={salvar} disabled={saving} className="w-full bg-gray-700 text-white py-3 rounded-xl font-black text-sm uppercase hover:bg-gray-800 disabled:opacity-50 flex items-center justify-center gap-2">
{saving ? <RefreshCw size={16} className="animate-spin" /> : <Wrench size={16} />} Bloquear sala
</button>
</div>
</div>
</div>
);
@@ -92,9 +159,12 @@ export const SalaHomeView: React.FC = () => {
const toast = useToast();
const confirm = useConfirm();
const ws: any = HybridBackend.getActiveWorkspace();
// Operador (ex.: recepção): só vê a AGENDA e bloqueia manutenção — sem locação/financeiro.
const isOperador = ws?.papel_sala === 'operador';
const [lancamentos, setLancamentos] = useState<any[]>([]);
const [reservas, setReservas] = useState<{ feitas: any[]; recebidas: any[] }>({ feitas: [], recebidas: [] });
const [loading, setLoading] = useState(false);
const [bloqueioOpen, setBloqueioOpen] = useState(false);
// Relatório por sala (período)
const hoje = new Date();
const [inicio, setInicio] = useState(new Date(hoje.getFullYear(), hoje.getMonth(), 1).toISOString().split('T')[0]);
@@ -105,17 +175,25 @@ export const SalaHomeView: React.FC = () => {
const carregar = useCallback(async () => {
setLoading(true);
try {
const [fin, res] = await Promise.all([HybridBackend.getFinanceiro(), HybridBackend.getMinhasReservas()]);
setLancamentos(Array.isArray(fin) ? fin : []);
setReservas(res && res.recebidas ? res : { feitas: [], recebidas: [] });
if (isOperador) {
// Operador não tem acesso ao financeiro/recebíveis: carrega só a agenda da sala.
const rs = await HybridBackend.getSalaReservas(ws.id);
setLancamentos([]);
setReservas({ feitas: [], recebidas: (Array.isArray(rs) ? rs : []).map((r: any) => ({ ...r, sala_id: ws.id })) });
} else {
const [fin, res] = await Promise.all([HybridBackend.getFinanceiro(), HybridBackend.getMinhasReservas()]);
setLancamentos(Array.isArray(fin) ? fin : []);
setReservas(res && res.recebidas ? res : { feitas: [], recebidas: [] });
}
} catch { /* silent */ } finally { setLoading(false); }
}, []);
}, [isOperador, ws?.id]);
useEffect(() => { carregar(); }, [carregar]);
const carregarRel = useCallback(() => {
if (isOperador) { setRel(null); return; }
setRelLoading(true);
HybridBackend.getSalasRelatorio(inicio, fim).then(setRel).catch(() => setRel(null)).finally(() => setRelLoading(false));
}, [inicio, fim]);
}, [inicio, fim, isOperador]);
useEffect(() => { carregarRel(); }, [carregarRel]);
const recebido = lancamentos.filter(l => l.status === 'Pago').reduce((s, l) => s + Number(l.valor), 0);
@@ -124,6 +202,13 @@ export const SalaHomeView: React.FC = () => {
const pendentes = reservasDaSala.filter(r => r.status === 'pendente').length;
const confirmadas = reservasDaSala.filter(r => r.status === 'confirmada').length;
const bloquearManutencao = async (inicio: string, fim: string, motivo: string) => {
await HybridBackend.criarManutencaoSala(ws.id, { inicio, fim, motivo });
toast.success('SALA BLOQUEADA PARA MANUTENÇÃO.');
setBloqueioOpen(false);
carregar();
};
const darBaixa = async (l: any) => {
if (!await confirm(`MARCAR como RECEBIDO ${fmtBRL(l.valor)} de "${l.descricao}"?`)) return;
try {
@@ -160,15 +245,16 @@ export const SalaHomeView: React.FC = () => {
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KPI titulo="Recebido" valor={fmtBRL(recebido)} cor="text-green-600" Icon={Wallet} sub="Locações pagas" />
<KPI titulo="A receber" valor={fmtBRL(aReceber)} cor="text-amber-500" Icon={Clock} sub="Pendentes" />
<div className={`grid grid-cols-2 ${isOperador ? 'lg:grid-cols-2' : 'lg:grid-cols-4'} gap-4`}>
{!isOperador && <KPI titulo="Recebido" valor={fmtBRL(recebido)} cor="text-green-600" Icon={Wallet} sub="Locações pagas" />}
{!isOperador && <KPI titulo="A receber" valor={fmtBRL(aReceber)} cor="text-amber-500" Icon={Clock} sub="Pendentes" />}
<KPI titulo="Reservas pendentes" valor={String(pendentes)} cor="text-red-500" Icon={Inbox} sub="Aguardando resposta" />
<KPI titulo="Reservas confirmadas" valor={String(confirmadas)} cor="text-teal-600" Icon={CalendarClock} sub="Nesta sala" />
</div>
<AgendaSala reservas={reservasDaSala} />
<AgendaSala reservas={reservasDaSala} onBloquear={() => setBloqueioOpen(true)} />
{!isOperador && (<>
<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 justify-between items-center">
<h3 className="text-sm font-black text-gray-800 uppercase">Recebíveis de locação</h3>
@@ -240,8 +326,10 @@ export const SalaHomeView: React.FC = () => {
</div>
)}
</div>
</>)}
</>
)}
{bloqueioOpen && <BloqueioModal onClose={() => setBloqueioOpen(false)} onConfirm={bloquearManutencao} />}
</div>
);
};
+70 -2
View File
@@ -5,7 +5,7 @@ import {
CheckCircle2, XCircle, Clock, Search, ChevronLeft, ChevronRight,
TrendingUp, Stethoscope, FlaskConical, User, ToggleLeft, ToggleRight,
Save, AlertTriangle, Wifi, WifiOff, Briefcase, Trash2, Bell, Send,
MapPin, Phone, MoreVertical, X, ArrowLeft, UserX, Sparkles, DoorOpen, UserSearch
MapPin, Phone, MoreVertical, X, ArrowLeft, UserX, Sparkles, DoorOpen, UserSearch, Power
} from 'lucide-react';
const apiFetch = (url: string, opts: RequestInit = {}) => {
@@ -74,7 +74,7 @@ const KPI: React.FC<{ icon: React.ElementType; label: string; value: string | nu
);
};
type SuperAdminTab = 'overview' | 'users' | 'clinicas' | 'logs' | 'financeiro' | 'notificacoes';
type SuperAdminTab = 'overview' | 'users' | 'clinicas' | 'logs' | 'financeiro' | 'notificacoes' | 'secretaria';
/* ═══════════════════════════════════════════════════ MAIN VIEW */
export const SuperAdminView: React.FC<{ tab?: SuperAdminTab }> = ({ tab = 'overview' }) => {
@@ -86,6 +86,74 @@ export const SuperAdminView: React.FC<{ tab?: SuperAdminTab }> = ({ tab = 'overv
{tab === 'logs' && <TabLogs />}
{tab === 'financeiro' && <TabFinanceiro />}
{tab === 'notificacoes' && <TabNotificacoes />}
{tab === 'secretaria' && <TabSecretaria />}
</div>
);
};
/* ═══════════════════════════════════════════════════ TAB SECRETÁRIA (liga/desliga por sessão) */
const TabSecretaria: React.FC = () => {
const [log, setLog] = useState<any[]>([]);
const [configurado, setConfigurado] = useState(true);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
apiFetch('/api/superadmin/secretaria-desligamentos')
.then(r => r.json())
.then(d => { setLog(Array.isArray(d.log) ? d.log : []); setConfigurado(d.configurado !== false); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const totalOff = log.filter(l => !l.enabled).length;
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tight flex items-center gap-2"><Power size={18} className="text-red-600" /> Secretária IA liga/desliga por sessão</h2>
<p className="text-xs text-gray-400 font-medium">Quem ligou/desligou a Secretária de cada número, e o motivo do desligamento. {totalOff > 0 && `${totalOff} desligamento(s).`}</p>
</div>
<button onClick={load} className="p-2 rounded-xl border border-gray-200 text-gray-400 hover:text-gray-700"><RefreshCw size={16} /></button>
</div>
{!configurado ? (
<div className="bg-amber-50 text-amber-700 text-sm rounded-2xl p-4 flex items-center gap-2"><AlertTriangle size={16} /> Integração com o motor da Secretária não está configurada neste ambiente.</div>
) : loading ? (
<div className="text-center text-gray-400 py-16">Carregando</div>
) : log.length === 0 ? (
<div className="text-center text-gray-400 py-16">Nenhum registro de liga/desliga ainda.</div>
) : (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
<table className="w-full text-sm min-w-[640px]">
<thead className="bg-gray-50 text-[10px] font-black text-gray-400 uppercase tracking-widest">
<tr>
<th className="text-left px-4 py-3">Ação</th>
<th className="text-left px-4 py-3">Número</th>
<th className="text-left px-4 py-3">Quem</th>
<th className="text-left px-4 py-3">Quando</th>
<th className="text-left px-4 py-3">Motivo</th>
</tr>
</thead>
<tbody>
{log.map((l, i) => (
<tr key={i} className={`border-t border-gray-50 ${!l.enabled ? 'bg-red-50/30' : ''}`}>
<td className="px-4 py-3">
{l.enabled
? <span className="inline-flex items-center gap-1 text-emerald-700 text-[11px] font-black uppercase"><ToggleRight size={14} /> Ligou</span>
: <span className="inline-flex items-center gap-1 text-red-600 text-[11px] font-black uppercase"><ToggleLeft size={14} /> Desligou</span>}
</td>
<td className="px-4 py-3 text-gray-700 font-bold whitespace-nowrap">{l.phone || l.name || l.instance_id}</td>
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{l.by || '—'}</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{fmt(l.at)}</td>
<td className="px-4 py-3 text-gray-600">{l.reason || <span className="text-gray-300"></span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
+231
View File
@@ -0,0 +1,231 @@
// WaInboxView — Inbox WhatsApp do scoreodonto, consumindo o motor NewWhats via
// proxy do backend (/api/nw/v1/* → motor /api/ext/v1/*). Estágio 1: lista de
// conversas + thread + envio. Visual aproximado do motor (WhatsApp-like).
import React, { useEffect, useRef, useState } from 'react';
import { PageHeader } from '../components/PageHeader.tsx';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
async function nw(path: string, opts: RequestInit = {}) {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const res = await fetch(`${API}/nw/v1${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts.headers || {}),
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`.slice(0, 200));
return res.json();
}
// URL do túnel WS (/api/nw/v1/stream → motor). Deriva o host do API (relativo ou
// absoluto) e troca http(s)→ws(s). O JWT vai no query porque o browser não envia
// Authorization no handshake de WebSocket.
function streamUrl(token: string) {
const base = API.startsWith('http') ? API : `${window.location.origin}${API}`;
const u = new URL(`${base}/nw/v1/stream`);
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
u.searchParams.set('token', token);
return u.toString();
}
interface Chat { id: string | number; remote_jid?: string; displayName?: string; phone?: string; last_message_text?: string | null; unread_count?: number }
interface Msg { id: string | number; text?: string; direction?: 'in' | 'out'; from_me?: number; timestamp?: number; type?: string }
// Normaliza a mensagem do stream (shape do motor) para o Msg do inbox.
function normalizeWsMsg(m: any): Msg {
return {
id: m?.id ?? `ws-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
text: m?.body ?? m?.text,
from_me: (m?.fromMe === true || m?.fromMe === 1) ? 1 : 0,
direction: m?.fromMe ? 'out' : 'in',
type: m?.type,
timestamp: m?.timestamp,
};
}
// Atualiza a lista de conversas com uma mensagem nova: preview, badge de unread
// (só quando não é a conversa aberta e não é enviada por nós) e sobe pro topo.
function bumpChat(list: Chat[], chatId: any, m: Msg, countUnread: boolean): Chat[] {
const i = list.findIndex((c) => String(c.id) === String(chatId));
if (i === -1) return list; // conversa ainda não carregada — aparece no próximo refresh
const updated: Chat = { ...list[i], last_message_text: m.text || `[${m.type || 'mídia'}]` };
if (countUnread && m.from_me !== 1) updated.unread_count = (updated.unread_count || 0) + 1;
return [updated, ...list.filter((_, idx) => idx !== i)];
}
export const WaInboxView: React.FC = () => {
const [sessions, setSessions] = useState<any[]>([]);
const [session, setSession] = useState<string>('');
const [chats, setChats] = useState<Chat[]>([]);
const [active, setActive] = useState<Chat | null>(null);
const [msgs, setMsgs] = useState<Msg[]>([]);
const [text, setText] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [wsUp, setWsUp] = useState(false);
const [qr, setQr] = useState<string | null>(null);
const endRef = useRef<HTMLDivElement>(null);
// Ref com a conversa aberta para o handler do WS ler sempre o valor atual
// (evita closure velha dentro do onmessage).
const activeRef = useRef<Chat | null>(null);
useEffect(() => { activeRef.current = active; }, [active]);
// Tempo real: conecta ao túnel WS e reflete eventos do motor sem refetch.
useEffect(() => {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
if (!token) return;
let ws: WebSocket | null = null;
let retry: ReturnType<typeof setTimeout> | null = null;
let closed = false;
const connect = () => {
try { ws = new WebSocket(streamUrl(token)); } catch { return; }
ws.onopen = () => setWsUp(true);
ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
ws.onclose = () => {
setWsUp(false);
if (!closed) retry = setTimeout(connect, 3000); // reconexão simples
};
ws.onmessage = (ev) => {
let env: any;
try { env = JSON.parse(ev.data); } catch { return; }
const { event, data } = env || {};
if (event === 'message.new') {
const norm = normalizeWsMsg(data?.message);
const isActive = !!activeRef.current && String(data?.chatId) === String(activeRef.current.id);
if (isActive) {
setMsgs((p) => p.some((x) => String(x.id) === String(norm.id)) ? p : [...p, norm]);
setTimeout(() => endRef.current?.scrollIntoView(), 50);
}
setChats((prev) => bumpChat(prev, data?.chatId, norm, !isActive));
} else if (event === 'session.status') {
setSessions((prev) => prev.map((s) =>
String(s.id || s.instance) === String(data?.instanceId) ? { ...s, status: data?.status } : s));
if (data?.status === 'connected') setQr(null);
} else if (event === 'session.qr') {
setQr(data?.qrBase64 || null);
}
};
};
connect();
return () => {
closed = true;
if (retry) clearTimeout(retry);
try { ws?.close(); } catch { /* ignore */ }
};
}, []);
useEffect(() => {
nw('/sessions').then((s) => {
const list = Array.isArray(s) ? s : [];
setSessions(list);
const conn = list.find((i: any) => String(i.status).toLowerCase() === 'connected') || list[0];
if (conn) setSession(conn.id || conn.instance);
}).catch((e) => setErr(String(e.message)));
}, []);
useEffect(() => {
if (!session) return;
setLoading(true);
nw(`/inbox?session=${encodeURIComponent(session)}&limit=50`)
.then((c) => setChats(Array.isArray(c) ? c : (c.chats ?? [])))
.catch((e) => setErr(String(e.message)))
.finally(() => setLoading(false));
}, [session]);
const openChat = async (c: Chat) => {
setActive(c); setMsgs([]);
setChats((prev) => prev.map((x) => String(x.id) === String(c.id) ? { ...x, unread_count: 0 } : x));
try {
const m = await nw(`/inbox/${encodeURIComponent(String(c.id))}/messages?limit=50`);
setMsgs(Array.isArray(m) ? m : (m.messages ?? []));
setTimeout(() => endRef.current?.scrollIntoView(), 50);
} catch (e: any) { setErr(String(e.message)); }
};
const send = async () => {
if (!active || !text.trim()) return;
const body = text.trim(); setText('');
try {
await nw(`/inbox/${encodeURIComponent(String(active.id))}/send`, { method: 'POST', body: JSON.stringify({ text: body }) });
setMsgs((p) => [...p, { id: `tmp-${Date.now()}`, text: body, from_me: 1, direction: 'out', timestamp: Date.now() }]);
setTimeout(() => endRef.current?.scrollIntoView(), 50);
} catch (e: any) { setErr(String(e.message)); }
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<PageHeader title="WhatsApp" description="Inbox integrado ao motor NewWhats" />
{err && <div style={{ background: '#fee2e2', color: '#991b1b', padding: '8px 12px', fontSize: 13 }}>Erro: {err}. Verifique a configuração do plugin (super-admin) e o pareamento com o motor.</div>}
<div style={{ display: 'flex', flex: 1, minHeight: 0, border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
{/* Lista de conversas */}
<div style={{ width: 320, borderRight: '1px solid #e5e7eb', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: 8, borderBottom: '1px solid #e5e7eb' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, fontSize: 11, color: wsUp ? '#16a34a' : '#9ca3af' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: wsUp ? '#22c55e' : '#d1d5db', display: 'inline-block' }} />
{wsUp ? 'Tempo real ativo' : 'Conectando tempo real…'}
</div>
<select value={session} onChange={(e) => setSession(e.target.value)} style={{ width: '100%', padding: 6, fontSize: 13 }}>
<option value="">Selecione a sessão</option>
{sessions.map((s) => <option key={s.id || s.instance} value={s.id || s.instance}>{(s.nome || s.name || s.phone || s.id)} ({s.status})</option>)}
</select>
{qr && (
<div style={{ marginTop: 8, textAlign: 'center', background: '#fff', border: '1px solid #e5e7eb', borderRadius: 8, padding: 8 }}>
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 6 }}>Escaneie para conectar</div>
<img src={qr.startsWith('data:') ? qr : `data:image/png;base64,${qr}`} alt="QR Code" style={{ width: '100%', maxWidth: 220 }} />
</div>
)}
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{loading && <div style={{ padding: 12, color: '#6b7280', fontSize: 13 }}>Carregando</div>}
{chats.map((c) => (
<div key={c.id} onClick={() => openChat(c)}
style={{ padding: '10px 12px', cursor: 'pointer', borderBottom: '1px solid #f3f4f6', background: active?.id === c.id ? '#f0fdf4' : 'transparent' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<strong style={{ fontSize: 14 }}>{c.displayName || c.phone || c.remote_jid}</strong>
{!!c.unread_count && <span style={{ background: '#22c55e', color: '#fff', borderRadius: 10, fontSize: 11, padding: '0 6px' }}>{c.unread_count}</span>}
</div>
<div style={{ fontSize: 12, color: '#6b7280', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{c.last_message_text || ''}</div>
</div>
))}
{!loading && !chats.length && <div style={{ padding: 12, color: '#9ca3af', fontSize: 13 }}>Nenhuma conversa.</div>}
</div>
</div>
{/* Thread */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: '#efeae2' }}>
{!active ? (
<div style={{ margin: 'auto', color: '#6b7280' }}>Selecione uma conversa</div>
) : (
<>
<div style={{ padding: '10px 14px', background: '#f0f2f5', borderBottom: '1px solid #e5e7eb', fontWeight: 600 }}>
{active.displayName || active.phone || active.remote_jid}
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 14 }}>
{msgs.map((m) => {
const out = m.from_me === 1 || m.direction === 'out';
return (
<div key={m.id} style={{ display: 'flex', justifyContent: out ? 'flex-end' : 'flex-start', marginBottom: 6 }}>
<div style={{ maxWidth: '70%', padding: '6px 10px', borderRadius: 8, fontSize: 14, background: out ? '#d9fdd3' : '#fff', boxShadow: '0 1px 0.5px rgba(0,0,0,.13)' }}>
{m.text || <em style={{ color: '#6b7280' }}>[{m.type || 'mídia'}]</em>}
</div>
</div>
);
})}
<div ref={endRef} />
</div>
<div style={{ display: 'flex', gap: 8, padding: 10, background: '#f0f2f5' }}>
<input value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder="Mensagem" style={{ flex: 1, padding: '8px 12px', borderRadius: 20, border: '1px solid #d1d5db' }} />
<button onClick={send} style={{ background: '#22c55e', color: '#fff', border: 'none', borderRadius: 20, padding: '8px 16px', cursor: 'pointer' }}>Enviar</button>
</div>
</>
)}
</div>
</div>
</div>
);
};
+67
View File
@@ -0,0 +1,67 @@
// WaSecretariaView — painel da Secretária IA (motor) no scoreodonto. Estágio 1:
// chat de teste via proxy POST /api/nw/v1/secretaria/ask. A configuração do
// cérebro/agente vive no motor (acessível ao super-admin).
import React, { useRef, useState } from 'react';
import { PageHeader } from '../components/PageHeader.tsx';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
async function ask(chatId: string, message: string) {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const res = await fetch(`${API}/nw/v1/secretaria/ask`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ chatId, message, contactName: 'Teste (painel)' }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`.slice(0, 200));
return res.json();
}
export const WaSecretariaView: React.FC = () => {
const [turns, setTurns] = useState<Array<{ q: string; a: string }>>([]);
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const chatId = useRef(`painel-${Date.now()}`);
const endRef = useRef<HTMLDivElement>(null);
const send = async () => {
if (!text.trim() || busy) return;
const q = text.trim(); setText(''); setBusy(true); setErr(null);
try {
const r = await ask(chatId.current, q);
setTurns((p) => [...p, { q, a: r.reply ?? JSON.stringify(r) }]);
setTimeout(() => endRef.current?.scrollIntoView(), 50);
} catch (e: any) { setErr(String(e.message)); }
finally { setBusy(false); }
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<PageHeader title="Secretária IA" description="Atendente virtual (motor NewWhats) — painel de teste" />
{err && <div style={{ background: '#fee2e2', color: '#991b1b', padding: '8px 12px', fontSize: 13 }}>Erro: {err}</div>}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', border: '1px solid #e5e7eb', borderRadius: 8, background: '#fff', overflow: 'hidden' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
{!turns.length && <div style={{ color: '#9ca3af', fontSize: 14 }}>Faça uma pergunta para testar a Secretária (ex.: "Quais procedimentos vocês fazem e os valores?").</div>}
{turns.map((t, i) => (
<div key={i} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 4 }}>
<div style={{ background: '#dbeafe', padding: '8px 12px', borderRadius: 10, fontSize: 14, maxWidth: '75%' }}>{t.q}</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<div style={{ background: '#f3f4f6', padding: '8px 12px', borderRadius: 10, fontSize: 14, maxWidth: '75%', whiteSpace: 'pre-wrap' }}>{t.a}</div>
</div>
</div>
))}
{busy && <div style={{ color: '#6b7280', fontSize: 13 }}>Secretária digitando</div>}
<div ref={endRef} />
</div>
<div style={{ display: 'flex', gap: 8, padding: 10, borderTop: '1px solid #e5e7eb' }}>
<input value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder="Pergunte algo…" style={{ flex: 1, padding: '8px 12px', borderRadius: 8, border: '1px solid #d1d5db' }} />
<button onClick={send} disabled={busy} style={{ background: '#2563eb', color: '#fff', border: 'none', borderRadius: 8, padding: '8px 16px', cursor: 'pointer', opacity: busy ? 0.6 : 1 }}>Perguntar</button>
</div>
</div>
</div>
);
};
+484
View File
@@ -0,0 +1,484 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Search,
MoreVertical,
User,
MessageSquare,
ChevronDown,
RefreshCw,
MessageSquarePlus,
RotateCw,
ArrowLeft,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import type { NewWhatsChat } from './types/inboxTypes';
import { getAvatarProxyUrl, formatPhone } from './utils/inboxUtils';
import { useNewInboxBridge } from './hooks/inbox/useNewInboxBridge';
import { useInstanceSelector } from './hooks/inbox/useInstanceSelector';
import { useChatFilter } from './hooks/inbox/useChatFilter';
import { useChatWindow } from './hooks/inbox/useChatWindow';
import { useMessageInput } from './hooks/inbox/useMessageInput';
import { nw } from './services/nwClient';
import { useProtocolPanel } from './hooks/useProtocolPanel';
import ChatSidebar from './components/ChatSidebar';
import ThinSidebar, { TabType } from './components/ThinSidebar';
import InboxHeader from './components/InboxHeader';
import MessageArea from './components/MessageArea';
import SettingsPanel from './components/SettingsPanel';
import InputBar from './components/InputBar';
import NewConversationModal from './components/NewConversationModal';
import ContactProfile from './components/ContactProfile';
import ProtocolPanel from './components/ProtocolPanel';
import { HybridBackend } from '../../services/backend';
type Chat = NewWhatsChat;
export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void }) {
// ── Seleção de instância (hook dedicado) ─────────────────────────────────
const {
instances,
activeInstance,
mappedActiveInstance,
connectedInstances,
isConnected,
dropdownRef,
selectInstance,
refreshInstances,
handleConnect: handleConnectInstance,
} = useInstanceSelector()
// ── Inbox bridge (chats + mensagens) ──────────────────────────────────────
const {
chats: chatsStore,
selectedChat,
messages,
loadingChats: loading,
loadingMessages,
presenceByJid,
handleSelectChat: handleSelectChatBridge,
handleSendText,
handleLoadMoreHistory,
handleMenuAction,
} = useNewInboxBridge()
// ── Filtros de chat (hook dedicado) ───────────────────────────────────────
const {
searchTerm,
activeFilter,
chatScope,
chatsByScope,
isFavoriteChat,
setSearchTerm,
setActiveFilter,
setChatScope,
toggleFavorite,
} = useChatFilter(chatsStore)
// ── Chat window (hook dedicado) ───────────────────────────────────────────
const {
scrollRef,
isTyping,
replyingTo,
reactionPickerFor,
hasMoreHistory,
loadingMore,
isProfileOpen,
isSyncingAvatar,
isChatMenuOpen,
setReplyingTo,
setIsProfileOpen,
setIsChatMenuOpen,
handleTyping,
handleScroll,
handleSelectChat: onChatWindowSelect,
handleReactToMessage,
handleSendMedia,
handleSendAudio,
handleSyncAvatar,
toggleReactionPicker,
} = useChatWindow({
selectedChat,
activeInstanceId: activeInstance?.instance ?? null,
onLoadMoreHistory: handleLoadMoreHistory,
})
// ── Mensagem de entrada (hook dedicado) ───────────────────────────────────
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
selectedChat,
replyingTo,
onClearReply: () => setReplyingTo(null),
onSendText: handleSendText,
})
// ── Painel de Protocolo ───────────────────────────────────────────────────
const [isProtocolOpen, setIsProtocolOpen] = useState(false)
const protocolPanel = useProtocolPanel(selectedChat ? String(selectedChat.id) : null)
// ── Estado local de UI ────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<TabType>('chats')
const [isInstanceDropdownOpen, setIsInstanceDropdownOpen] = useState(false)
const [isOptionsMenuOpen, setIsOptionsMenuOpen] = useState(false)
const [drafts] = useState<Record<string, string>>({})
const [isMobile, setIsMobile] = useState(false)
const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false)
// Permissão de apagar conversa/mensagem (dono ou liberado). Fail-open enquanto carrega.
const [canDeleteMsg, setCanDeleteMsg] = useState(true)
useEffect(() => {
let alive = true
HybridBackend.getWaAccess().then(a => { if (alive) setCanDeleteMsg(a.isOwner || a.delete_msg) }).catch(() => {})
return () => { alive = false }
}, [])
// Preloader: aparece APENAS em hard reload (F5 / URL direta) — nunca em
// navegação client-side (router.push). Detectado por:
// 1. performance.getEntriesByType('navigation')[0].type === 'reload'
// 2. ausência do flag sessionStorage 'nw:inbox-loaded' (primeira visita na sessão)
// sessionStorage sobrevive a navegações client-side mas é zerado em nova aba.
const [pageReady, setPageReady] = useState<boolean>(() => {
if (typeof window === 'undefined') return false
const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined
const isHardReload = navEntry ? navEntry.type === 'reload' : (performance.navigation?.type === 1)
if (isHardReload) return false
return sessionStorage.getItem('nw:inbox-loaded') === '1'
})
const prevLoadingRef = useRef(false)
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (prevLoadingRef.current && !loading) {
readyTimerRef.current = setTimeout(() => {
setPageReady(true)
sessionStorage.setItem('nw:inbox-loaded', '1')
}, 400)
}
prevLoadingRef.current = loading
return () => { if (readyTimerRef.current) clearTimeout(readyTimerRef.current) }
}, [loading])
const optionsRef = useRef<HTMLDivElement>(null)
const initialized = useRef(false)
// ── Setup mobile + click-outside ─────────────────────────────────────────
useEffect(() => {
if (initialized.current) return
initialized.current = true
const resize = () => setIsMobile(window.innerWidth < 768)
window.addEventListener('resize', resize)
resize()
const click = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) setIsInstanceDropdownOpen(false)
if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) setIsOptionsMenuOpen(false)
}
document.addEventListener('mousedown', click)
return () => { window.removeEventListener('resize', resize); document.removeEventListener('mousedown', click) }
}, [])
// ── Handlers ─────────────────────────────────────────────────────────────
const handleSelectChat = useCallback((chat: Chat | null) => {
handleSelectChatBridge(chat)
onChatWindowSelect()
setIsProtocolOpen(false)
}, [handleSelectChatBridge, onChatWindowSelect])
const handleChatMenuAction = useCallback((action: string, chat: Chat) => {
handleMenuAction(action, chat, toggleFavorite)
}, [handleMenuAction, toggleFavorite])
const handleFetchChats = useCallback(async () => {
await refreshInstances()
setIsOptionsMenuOpen(false)
}, [refreshInstances])
// Voltar: se há chat aberto, fecha-o; senão, página anterior
const handleBack = useCallback(() => {
if (selectedChat) {
handleSelectChat(null)
} else {
onNavigate?.('dashboard')
}
}, [selectedChat, handleSelectChat, onNavigate])
// ESC fecha chat ou vai para página anterior
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleBack()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [handleBack])
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="flex h-full w-full bg-[#f0f2f5] overflow-hidden select-none font-sans text-[#111b21]">
{/* Preloader global visível até o primeiro carregamento de chats completar */}
<AnimatePresence>
{!pageReady && (
<motion.div
key="inbox-preloader"
exit={{ opacity: 0 }}
transition={{ duration: 0.4 }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#070b14]"
>
<div className="flex flex-col items-center gap-5">
<motion.div
animate={{ opacity: [1, 0.2, 1] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
>
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" className="w-20 h-20">
<defs>
<linearGradient id="wpp-grad" x1="50" y1="100" x2="50" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#20b038"/>
<stop offset="1" stopColor="#60d66a"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="50" fill="url(#wpp-grad)"/>
<path d="M50 15.5c-19.05 0-34.5 15.45-34.5 34.5 0 6.36 1.72 12.3 4.73 17.4L15.5 84.5l17.6-4.62a34.36 34.36 0 0 0 16.9 4.42c19.05 0 34.5-15.45 34.5-34.5S69.05 15.5 50 15.5z" fill="rgba(0,0,0,0.12)"/>
<path d="M68.5 60.3c-1.1-.57-6.53-3.22-7.54-3.59-.99-.37-1.72-.55-2.44.55-.73 1.1-2.82 3.59-3.46 4.32-.64.73-1.28.82-2.38.27-6.53-3.26-10.81-5.83-15.1-13.22-1.14-1.97.24-1.83.65-3.05.34-1 .17-1.84-.1-2.39-.27-.55-2.44-5.99-3.4-8.2-.9-2.16-1.81-1.86-2.49-1.89-.64-.03-1.38-.04-2.12-.04s-1.93.28-2.94 1.38c-1.01 1.1-3.87 3.78-3.87 9.22s3.96 10.69 4.51 11.43c.55.73 7.8 11.9 18.9 16.7 7.02 3.03 9.76 3.28 13.28 2.76 2.13-.32 6.53-2.67 7.45-5.25.92-2.58.92-4.79.64-5.25-.27-.46-1-.73-2.09-1.27z" fill="#fff"/>
</svg>
</motion.div>
<span className="text-slate-500 text-sm">Carregando conversas</span>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="flex flex-1 overflow-hidden h-full">
<ThinSidebar activeTab={activeTab} setActiveTab={setActiveTab} activeInstance={activeInstance} onNavigate={onNavigate} />
{activeTab === 'chats' && (
<div className={`${isMobile ? (selectedChat ? 'hidden' : 'w-full') : 'w-[400px]'} flex flex-col bg-white border-r border-[#d1d7db] shrink-0 z-20`}>
{/* Header da sidebar */}
<div className="h-[60px] px-4 flex items-center justify-between bg-[#f0f2f5] border-b border-[#d1d7db] shrink-0">
<div className="flex items-center gap-1">
<button
onClick={handleBack}
title={selectedChat ? 'Voltar aos chats' : 'Voltar'}
className="p-2 hover:bg-black/5 rounded-full text-[#54656f] shrink-0"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="relative" ref={dropdownRef}>
<div onClick={() => setIsInstanceDropdownOpen(!isInstanceDropdownOpen)} className="flex items-center gap-3 cursor-pointer p-1">
<div className="w-10 h-10 rounded-full bg-white border border-black/5 overflow-hidden flex items-center justify-center">
{activeInstance?.instance
? <img
src={getAvatarProxyUrl({
instance_name: activeInstance.instance,
remote_jid: activeInstance.phone ? `${activeInstance.phone}@s.whatsapp.net` : null,
}) || activeInstance.avatar || undefined}
className="w-full h-full object-cover" alt=""
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
: <User className="text-gray-400" />}
</div>
<span className="font-bold text-sm truncate max-w-[120px]">{activeInstance?.nome || 'Instância'}</span>
<ChevronDown className={`w-4 h-4 transition-all ${isInstanceDropdownOpen ? 'rotate-180' : ''}`} />
</div>
<AnimatePresence>
{isInstanceDropdownOpen && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}
className="absolute left-0 top-full bg-white shadow-2xl rounded-xl border border-gray-100 w-72 z-50 py-2">
{instances.map(i => (
<button key={i.instance} onClick={() => { selectInstance(i); setIsInstanceDropdownOpen(false) }}
className="w-full p-3 hover:bg-gray-50 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-100 overflow-hidden">
<img
src={getAvatarProxyUrl({
instance_name: i.instance,
remote_jid: i.phone ? `${i.phone}@s.whatsapp.net` : null,
}) || i.avatar || undefined}
alt="" className="w-full h-full object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
</div>
<div className="text-left">
<span className="text-sm font-medium block">{i.nome}</span>
<span className={`text-xs ${i.status === 'connected' ? 'text-green-500' : 'text-gray-400'}`}>{i.status}</span>
</div>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>{/* end left flex */}
<div className="flex items-center gap-1">
<div className="relative" ref={optionsRef}>
<button onClick={() => setIsOptionsMenuOpen(!isOptionsMenuOpen)} className="p-2 hover:bg-black/5 rounded-full">
<MoreVertical className="w-5 h-5 text-[#54656f]" />
</button>
<AnimatePresence>
{isOptionsMenuOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute right-0 top-full mt-1 bg-white shadow-2xl rounded-xl border border-gray-100 w-56 z-50 py-2"
>
<button
onClick={handleFetchChats}
disabled={loading}
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Atualizar chats
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
{/* Busca */}
<div className="px-4 py-2 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
<input type="text" placeholder="Pesquisar..."
className="w-full bg-[#f0f2f5] h-[35px] pl-10 rounded-lg text-sm border-none focus:ring-0"
value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
</div>
<button onClick={() => setIsNewConvoModalOpen(true)}
className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-black/5">
<MessageSquarePlus className="w-4 h-4" />
</button>
</div>
{/* Lista de chats */}
<ChatSidebar
chats={chatsByScope}
loading={loading}
selectedChat={selectedChat}
searchTerm={searchTerm}
onSelectChat={handleSelectChat}
onMenuAction={handleChatMenuAction}
activeFilter={activeFilter}
selectedLabelFilter={null}
chatScope={chatScope}
activeCustomListName={null}
presenceByJid={presenceByJid}
isFavoriteChat={isFavoriteChat}
drafts={drafts}
canDelete={canDeleteMsg}
/>
</div>
)}
{/* Área principal */}
<div
className={`${isMobile ? ((selectedChat || activeTab === 'settings') ? 'w-full' : 'hidden') : 'flex-1'} flex h-full relative overflow-hidden`}
style={{ backgroundImage: "url('/chat-bg.png')", backgroundRepeat: 'repeat', backgroundColor: '#b5bdb5' }}
>
{/* Overlay branqueador sobre o padrão */}
<div className="absolute inset-0 bg-white/75 pointer-events-none z-0" />
{activeTab === 'settings' ? (
<SettingsPanel instanceId={activeInstance?.instance ?? null} />
) : selectedChat ? (
<>
<div className="flex-1 flex flex-col h-full relative z-[1] overflow-hidden border-r border-[#d1d7db]">
<InboxHeader
selectedChat={selectedChat}
isMobile={isMobile}
isTyping={isTyping}
onAvatarClick={() => { setIsProfileOpen(!isProfileOpen); setIsProtocolOpen(false) }}
onBack={() => handleSelectChat(null)}
onSyncAvatar={handleSyncAvatar}
isChatMenuOpen={isChatMenuOpen}
isSyncingAvatar={isSyncingAvatar}
isProtocolOpen={isProtocolOpen}
hasActiveProtocol={protocolPanel.hasActive}
onSearchClick={() => {}}
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
onProtocolClick={() => { setIsProtocolOpen(!isProtocolOpen); setIsProfileOpen(false) }}
/>
<MessageArea
messages={messages as any}
selectedChat={selectedChat}
scrollRef={scrollRef}
msgLoading={loadingMessages}
onScroll={handleScroll}
historyLoadingMore={loadingMore}
hasMoreHistory={hasMoreHistory}
reactionPickerFor={reactionPickerFor}
onReply={setReplyingTo}
onToggleReactionPicker={toggleReactionPicker}
onReact={handleReactToMessage}
onForward={() => {}}
onDelete={() => {}}
canDelete={canDeleteMsg}
onEdit={() => {}}
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
getGroupSenderLabel={(msg: any) => {
if (msg.direction === 'out') return 'Você'
if (msg.participant) return msg.participant
if (msg.senderJid) {
if (msg.senderJid.endsWith('@lid')) return msg.participant || ''
const phone = msg.senderJid.split('@')[0].split(':')[0]
return formatPhone(phone, msg.senderJid)
}
return ''
}}
/>
<InputBar
newMessage={newMessage}
onNewMessageChange={setNewMessage}
onSend={handleSendMessage}
onPresence={handleTyping}
onCancelReply={() => setReplyingTo(null)}
replyingTo={replyingTo}
selectedChat={selectedChat}
isMobile={isMobile}
onSendMedia={handleSendMedia}
onSendAudio={handleSendAudio}
instanceId={activeInstance?.instance ?? null}
mentions={mentions}
onMentionsChange={setMentions}
/>
</div>
<ContactProfile
isOpen={isProfileOpen && !isProtocolOpen && !isMobile}
chat={selectedChat}
messages={messages as any}
onClose={() => setIsProfileOpen(false)}
/>
<ProtocolPanel
isOpen={isProtocolOpen && !isMobile}
chatId={selectedChat ? String(selectedChat.id) : null}
onClose={() => setIsProtocolOpen(false)}
{...protocolPanel}
/>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-10 bg-[#f0f2f5]">
<MessageSquare className="w-20 h-20 opacity-10 mb-8" />
<h1 className="text-2xl font-light text-[#41525d] mb-4">NewWhats</h1>
<p className="text-sm text-[#667781]">Selecione uma conversa para começar.</p>
</div>
)}
</div>
</div>
<NewConversationModal
isOpen={isNewConvoModalOpen}
onClose={() => setIsNewConvoModalOpen(false)}
activeInstance={mappedActiveInstance as any}
connectedInstances={connectedInstances as any}
sendMessage={async (instanceId: string, phone: string, text: string) => {
try {
// ext: inicia nova conversa por telefone (resolve/cria o chat).
const res = await nw.post('/conversations', { sessionId: instanceId, phone, text })
return { ok: true, messageId: res?.messageId }
} catch {
return { ok: false }
}
}}
/>
</div>
)
}
File diff suppressed because it is too large Load Diff
+907
View File
@@ -0,0 +1,907 @@
// SessionsView — página /sessions do motor NewWhats, portada para o satélite
// scoreodonto. Dados/realtime via shims (ext API + túnel WS). Adaptações: sem
// next/link (navegação via onNavigate), export nomeado, wrapper de fundo escuro.
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Wifi, Plus, RefreshCw, Loader2, QrCode, X,
CheckCircle2, AlertCircle, Trash2, LogOut, Phone,
ArrowLeft, Zap, WifiOff, Clock, Terminal, Download,
ChevronDown, ChevronRight, Crown, Lock, ShieldCheck, UserCog,
} from 'lucide-react';
import { Button } from './components/ui';
import { useSessionsLogic } from './hooks/useSessionsLogic';
import { socketService } from './services/socketService';
import type { Instance } from './store/instanceStore';
import { HybridBackend, type WaPerms } from '../../services/backend';
// Permissões da conta logada sobre uma sessão específica.
interface SessionPerms {
isOwner: boolean;
canRescan: boolean;
canDelete: boolean;
ownerNome: string | null;
}
const OWNER_PERMS: SessionPerms = { isOwner: true, canRescan: true, canDelete: true, ownerNome: null };
// ─── Status config ────────────────────────────────────────────────────────────
const STATUS_CONFIG = {
CONNECTED: {
label: 'Conectado',
dot: 'bg-emerald-400',
badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
icon: <Wifi className="w-4 h-4" />,
},
QR_PENDING: {
label: 'Aguardando QR',
dot: 'bg-amber-400 animate-pulse',
badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
icon: <QrCode className="w-4 h-4" />,
},
CONNECTING: {
label: 'Conectando...',
dot: 'bg-blue-400 animate-pulse',
badge: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
icon: <Loader2 className="w-4 h-4 animate-spin" />,
},
DISCONNECTED: {
label: 'Desconectado',
dot: 'bg-gray-400',
badge: 'bg-gray-100 text-gray-600 border-gray-300',
icon: <WifiOff className="w-4 h-4" />,
},
BANNED: {
label: 'Banido',
dot: 'bg-red-500',
badge: 'bg-red-500/10 text-red-400 border-red-500/20',
icon: <AlertCircle className="w-4 h-4" />,
},
};
// ─── Baileys Log Types ────────────────────────────────────────────────────────
interface BaileysLogEntry {
id: string;
ts: number;
event: string;
data: any;
}
const EVENT_COLOR: Record<string, string> = {
'connection.update': 'text-emerald-400 bg-emerald-500/15',
'messages.upsert': 'text-blue-400 bg-blue-500/15',
'contacts.upsert': 'text-purple-400 bg-purple-500/15',
'contacts.update': 'text-purple-300 bg-purple-500/10',
'chats.upsert': 'text-yellow-400 bg-yellow-500/15',
'chats.update': 'text-yellow-300 bg-yellow-500/10',
'groups.upsert': 'text-orange-400 bg-orange-500/15',
'groups.update': 'text-orange-300 bg-orange-500/10',
'messaging-history.set': 'text-cyan-400 bg-cyan-500/15',
'message': 'text-blue-300 bg-blue-500/10',
};
function eventColor(event: string): string {
return EVENT_COLOR[event] ?? 'text-gray-600 bg-gray-100';
}
function logSummary(entry: BaileysLogEntry): string {
const d = entry.data;
if (!d) return '—';
if (entry.event === 'connection.update') {
if (d.connection) return `${d.connection}`;
if (d.qr) return 'QR gerado';
}
if (entry.event === 'message' || entry.event === 'messages.upsert') {
const body = d.body ?? d.message?.conversation ?? d.message?.extendedTextMessage?.text;
if (body) return String(body).slice(0, 60);
const jid = d.remoteJid ?? d.key?.remoteJid;
if (jid) return jid;
}
if (typeof d === 'object') {
const count = Array.isArray(d) ? d.length : (d.count ?? d.chats ?? d.contacts ?? d.messages);
if (count !== undefined) return `${count} item(s)`;
const keys = Object.keys(d).slice(0, 3).join(', ');
return keys || '{}';
}
return String(d).slice(0, 60);
}
// ─── Log Row (expandable) ─────────────────────────────────────────────────────
function LogRow({ entry, index }: { entry: BaileysLogEntry; index: number }) {
const [open, setOpen] = useState(false);
const time = new Date(entry.ts).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
return (
<div className="border-b border-white/[0.04] last:border-0">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-gray-100 text-left group transition-colors"
>
<span className="text-[9px] font-mono text-gray-400 w-5 shrink-0 text-right">{index + 1}</span>
<span className={`text-[9px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap ${eventColor(entry.event)}`}>
{entry.event}
</span>
<span className="text-[11px] text-gray-600 truncate flex-1 font-mono">{logSummary(entry)}</span>
<span className="text-[9px] text-gray-400 shrink-0 font-mono">{time}</span>
<span className="shrink-0 text-gray-400 group-hover:text-gray-500">
{open ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
</span>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<pre className="mx-3 mb-2 p-2 bg-gray-100 rounded-lg text-[10px] font-mono text-gray-700 overflow-x-auto whitespace-pre-wrap max-h-48 border border-gray-100">
{JSON.stringify(entry.data, null, 2)}
</pre>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// ─── QR Modal ─────────────────────────────────────────────────────────────────
function QrModal({
instanceId,
instanceName,
qrBase64,
onClose,
onNewQr,
}: {
instanceId: string;
instanceName: string;
qrBase64: string | null;
onClose: () => void;
onNewQr: (id: string) => Promise<void>;
}) {
const [expired, setExpired] = useState(false);
const [logs, setLogs] = useState<BaileysLogEntry[]>([]);
const logEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!qrBase64) return;
setExpired(false);
const t = setTimeout(() => setExpired(true), 60_000);
return () => clearTimeout(t);
}, [qrBase64]);
useEffect(() => {
const handler = (log: any) => {
if (log.sessionId !== instanceId) return;
setLogs(prev => {
const entry: BaileysLogEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
ts: log.timestamp ? new Date(log.timestamp).getTime() : Date.now(),
event: log.event ?? 'unknown',
data: log.data,
};
return [...prev, entry].slice(-300);
});
};
socketService.on('baileys:log', handler);
return () => socketService.off('baileys:log', handler);
}, [instanceId]);
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs.length]);
const saveJson = useCallback(() => {
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `baileys-${instanceId}-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}, [logs, instanceId]);
return (
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative bg-white border border-gray-200 rounded-[2rem] shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
<div>
<h2 className="text-lg font-black text-gray-800">Conectar WhatsApp</h2>
<p className="text-gray-500 text-xs mt-0.5">{instanceName}</p>
</div>
<button onClick={onClose} className="p-2 text-gray-500 hover:text-gray-800 bg-gray-50 rounded-xl transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-1 min-h-0">
<div className="w-72 shrink-0 flex flex-col items-center justify-center gap-5 p-6 border-r border-gray-100">
<div className="bg-gray-50 border border-gray-100 p-5 rounded-3xl w-full flex flex-col items-center justify-center min-h-[260px]">
{expired ? (
<div className="flex flex-col items-center gap-4">
<Clock className="w-10 h-10 text-gray-500" />
<p className="font-bold text-gray-800 text-sm">QR expirado</p>
<Button variant="primary" size="sm" onClick={async () => {
setExpired(false);
await onNewQr(instanceId);
}}>
Novo QR
</Button>
</div>
) : qrBase64 ? (
<div className="bg-white p-2.5 rounded-2xl shadow-xl">
<img src={qrBase64.startsWith('data:') ? qrBase64 : `data:image/png;base64,${qrBase64}`} alt="QR Code" className="w-48 h-48 rounded-xl" />
</div>
) : (
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-10 h-10 animate-spin text-emerald-500" />
<p className="text-xs text-gray-500 font-semibold uppercase tracking-widest">Gerando QR...</p>
</div>
)}
</div>
{!expired && (
<div className="flex items-center gap-2 text-emerald-400 text-[10px] font-bold uppercase tracking-widest animate-pulse">
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> Aguardando pareamento...
</div>
)}
<button onClick={onClose} className="w-full py-2.5 text-gray-500 hover:text-gray-800 font-bold transition-colors text-sm">
Fechar
</button>
</div>
<div className="flex-1 flex flex-col min-w-0">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-gray-100 bg-gray-50 shrink-0">
<Terminal className="w-3.5 h-3.5 text-gray-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500">Console Baileys</span>
<span className="ml-1 text-[9px] font-mono text-gray-400 bg-gray-50 px-1.5 py-0.5 rounded-full">
{logs.length} eventos
</span>
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => setLogs([])}
className="text-[9px] font-bold uppercase tracking-wider text-gray-500 hover:text-gray-600 transition-colors px-2 py-0.5 rounded hover:bg-gray-50"
>
Limpar
</button>
<button
onClick={saveJson}
disabled={logs.length === 0}
className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-emerald-500 hover:text-emerald-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors px-2 py-0.5 rounded hover:bg-emerald-500/10"
>
<Download size={10} /> Salvar JSON
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400 gap-2 py-16">
<Terminal size={28} className="opacity-30" />
<p className="text-xs">Aguardando eventos do Baileys...</p>
</div>
) : (
<div>
{logs.map((entry, i) => (
<LogRow key={entry.id} entry={entry} index={i} />
))}
<div ref={logEndRef} />
</div>
)}
</div>
<div className="px-4 py-2 border-t border-gray-100 bg-gray-50 shrink-0">
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[9px] font-mono text-gray-500">
{instanceId.slice(0, 8)}... · live
</span>
</div>
</div>
</div>
</div>
</motion.div>
</div>
);
}
// ─── Session Card ─────────────────────────────────────────────────────────────
const SessionCard = React.forwardRef<HTMLDivElement, {
inst: Instance;
perms: SessionPerms;
onConnect: () => void;
onDisconnect: () => void;
onDelete: () => void;
onShowQr: () => void;
onManagePerms: () => void;
}>(function SessionCard({
inst,
perms,
onConnect,
onDisconnect,
onDelete,
onShowQr,
onManagePerms,
}, ref) {
const cfg = STATUS_CONFIG[inst.status] ?? STATUS_CONFIG.DISCONNECTED;
const isConnected = inst.status === 'CONNECTED';
const isQrPending = inst.status === 'QR_PENDING';
const { isOwner, canRescan, canDelete, ownerNome } = perms;
const rescanLocked = !isOwner && !canRescan;
const deleteLocked = !isOwner && !canDelete;
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }}
className="bg-gray-100 backdrop-blur-xl p-6 rounded-[2rem] shadow-2xl border border-gray-100 flex flex-col gap-5 hover:border-gray-200 transition-all group relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-brand-500/5 blur-[60px] rounded-full translate-x-1/2 -translate-y-1/2 pointer-events-none" />
<div className="flex items-start justify-between relative z-10">
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-2xl overflow-hidden flex items-center justify-center border-2 shadow-lg ${isConnected ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' : 'bg-gray-50 border-gray-200 text-gray-500'}`}>
{inst.avatar
? <img src={inst.avatar} alt={inst.name} className="w-full h-full object-cover" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; (e.currentTarget.nextSibling as HTMLElement)?.style.setProperty('display', 'flex') }} />
: null}
<span className={`w-full h-full items-center justify-center ${inst.avatar ? 'hidden' : 'flex'}`}>
<Phone className="w-6 h-6" />
</span>
</div>
<div>
<h3 className="font-black text-gray-800 text-lg tracking-tight">{inst.name}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{inst.phone && (
<span className="text-xs font-mono text-gray-500 bg-gray-50 px-2 py-0.5 rounded border border-gray-100">{inst.phone}</span>
)}
<span className={`inline-flex items-center gap-1.5 text-[10px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full border ${cfg.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${cfg.dot}`} />
{cfg.label}
</span>
{isOwner ? (
<span className="inline-flex items-center gap-1 text-[10px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full border bg-amber-500/10 text-amber-500 border-amber-500/20" title="Você é o dono desta conta">
<Crown className="w-3 h-3" /> Dono
</span>
) : ownerNome ? (
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full border bg-gray-500/10 text-gray-500 border-gray-200" title={`Dono: ${ownerNome}`}>
<Crown className="w-3 h-3" /> {ownerNome}
</span>
) : null}
</div>
</div>
</div>
{deleteLocked ? (
<button disabled title="Somente o dono pode excluir esta sessão — peça autorização de exclusão."
className="p-2 text-gray-300 rounded-xl shrink-0 cursor-not-allowed"
>
<Lock className="w-4 h-4" />
</button>
) : (
<button onClick={onDelete} title="Deletar instância"
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-colors shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
{isQrPending && (
rescanLocked ? (
<div className="flex items-center gap-3 p-3 bg-gray-500/5 border border-gray-200 rounded-2xl text-gray-400 text-sm font-semibold w-full"
title="Somente o dono pode parear esta sessão — peça autorização de re-scan."
>
<Lock className="w-5 h-5 shrink-0" />
<span className="text-left">QR disponível somente o dono pode escanear</span>
</div>
) : (
<button onClick={onShowQr}
className="flex items-center gap-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-2xl text-amber-300 text-sm font-semibold hover:bg-amber-500/15 transition-colors w-full"
>
<QrCode className="w-5 h-5 shrink-0" />
<span className="text-left">QR aguardando escaneamento clique para ver</span>
</button>
)
)}
<div className="flex items-center gap-2 mt-auto relative z-10 pt-2 border-t border-gray-100">
{isConnected ? (
<>
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold uppercase tracking-widest flex-1">
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> Sessão ativa
</div>
{isOwner && (
<button onClick={onManagePerms} title="Gerenciar quem pode reconectar/excluir"
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-brand-600 hover:text-brand-700 bg-brand-500/10 hover:bg-brand-500/15 rounded-xl transition-colors border border-brand-500/20"
>
<UserCog className="w-3.5 h-3.5" /> Acesso
</button>
)}
<button onClick={onDisconnect}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-600 hover:text-gray-800 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-100"
>
<LogOut className="w-3.5 h-3.5" /> Desconectar
</button>
</>
) : rescanLocked ? (
<div className="flex-1 flex items-center justify-center gap-2 bg-gray-500/5 text-gray-400 font-black py-3 rounded-xl text-[11px] uppercase tracking-widest border border-gray-200 cursor-not-allowed"
title="Somente o dono pode reconectar esta sessão — peça autorização de re-scan."
>
<Lock className="w-3.5 h-3.5" /> Conectar
</div>
) : (
<>
<button onClick={onConnect}
className="flex-1 flex items-center justify-center gap-2 bg-brand-500 text-white font-black py-3 rounded-xl hover:bg-brand-600 transition-all text-[11px] uppercase tracking-widest"
>
<Zap className="w-3.5 h-3.5" fill="currentColor" /> Conectar
</button>
{isOwner && (
<button onClick={onManagePerms} title="Gerenciar quem pode reconectar/excluir"
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-brand-600 hover:text-brand-700 bg-brand-500/10 hover:bg-brand-500/15 rounded-xl transition-colors border border-brand-500/20"
>
<UserCog className="w-3.5 h-3.5" /> Acesso
</button>
)}
</>
)}
</div>
</motion.div>
);
});
// ─── Confirm Delete Modal ─────────────────────────────────────────────────────
function ConfirmDeleteModal({
inst,
onClose,
onConfirm,
loading,
}: {
inst: Instance;
onClose: () => void;
onConfirm: () => void;
loading: boolean;
}) {
return (
<div className="fixed inset-0 z-[130] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={onClose} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
<motion.div initial={{ opacity: 0, scale: 0.92, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9 }}
className="relative bg-white backdrop-blur-2xl border border-red-500/20 border-t-[3px] border-t-red-500 rounded-[28px] p-7 w-full max-w-sm shadow-2xl"
>
<div className="flex items-center gap-3 mb-4 mt-2">
<div className="w-11 h-11 rounded-xl bg-red-500/15 border border-red-500/30 flex items-center justify-center">
<Trash2 className="w-5 h-5 text-red-500" />
</div>
<div>
<h3 className="font-bold text-gray-800">Deletar instância</h3>
<p className="text-xs text-gray-500">{inst.name}</p>
</div>
</div>
<p className="text-sm text-gray-600 mb-6 leading-relaxed">
Isso removerá permanentemente a instância, seus chats e sessão Baileys.<br />
<strong className="text-red-400">Esta ação é irreversível.</strong>
</p>
<div className="flex gap-3">
<Button variant="ghost" onClick={onClose} className="flex-1">Cancelar</Button>
<button onClick={onConfirm} disabled={loading}
className="flex-1 py-3 bg-red-500 hover:bg-red-600 text-white font-bold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Deletar
</button>
</div>
</motion.div>
</div>
);
}
// ─── Main View ────────────────────────────────────────────────────────────────
// ─── Modal: gestão de acesso (autorizações por sessão) — só dono ───────────────
type PermMember = { usuarioId: string; nome: string; email: string; role: string } & WaPerms;
type PermField = keyof WaPerms;
function ManagePermsModal({ inst, onClose }: { inst: Instance; onClose: () => void }) {
const [members, setMembers] = useState<PermMember[]>([]);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null);
useEffect(() => {
let alive = true;
HybridBackend.getWaSessionAuthz(inst.id).then((r) => {
if (alive) { setMembers((r.members as PermMember[]) || []); setLoading(false); }
}).catch(() => alive && setLoading(false));
return () => { alive = false; };
}, [inst.id]);
const toggle = useCallback(async (usuarioId: string, field: PermField, value: boolean) => {
const target = members.find((m) => m.usuarioId === usuarioId);
if (!target) return;
const next: PermMember = { ...target, [field]: value };
setSavingId(usuarioId);
// Otimista
setMembers((prev) => prev.map((m) => m.usuarioId === usuarioId ? next : m));
const res = await HybridBackend.setWaSessionAuthz(inst.id, usuarioId, {
can_rescan: next.can_rescan, can_delete: next.can_delete, can_inbox: next.can_inbox, can_secretaria: next.can_secretaria, can_delete_msg: next.can_delete_msg,
});
if (!res.ok) {
// Reverte
setMembers((prev) => prev.map((m) => m.usuarioId === usuarioId ? target : m));
}
setSavingId(null);
}, [members, inst.id]);
return (
<div className="fixed inset-0 z-[130] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={onClose} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
<motion.div initial={{ opacity: 0, scale: 0.92, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9 }}
className="relative bg-white border border-brand-500/20 border-t-[3px] border-t-brand-500 rounded-[28px] p-7 w-full max-w-lg shadow-2xl max-h-[85vh] flex flex-col"
>
<button onClick={onClose} className="absolute top-5 right-5 p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-lg"><X className="w-4 h-4" /></button>
<div className="flex items-center gap-3 mb-1 mt-1">
<div className="w-11 h-11 rounded-xl bg-brand-500/15 border border-brand-500/30 flex items-center justify-center">
<ShieldCheck className="w-5 h-5 text-brand-600" />
</div>
<div>
<h3 className="font-black text-gray-800">Gerenciar acesso</h3>
<p className="text-xs text-gray-500">{inst.name} · o que cada conta pode fazer</p>
</div>
</div>
<p className="text-[11px] text-gray-500 leading-snug bg-gray-50 rounded-xl p-3 mt-3 mb-4">
Você é o <b>dono</b> desta conta você faz o pareamento inicial. Autorize contas da equipe a acessar o
<b> Inbox</b> e a <b>Secretária</b>, <b>reconectar</b>, <b>apagar conversas/mensagens</b> e/ou <b>excluir</b> a sessão.
O scan inicial permanece exclusivo seu.
</p>
<div className="flex-1 overflow-y-auto custom-scrollbar -mx-1 px-1 space-y-2">
{loading ? (
<div className="py-10 flex items-center justify-center text-gray-400"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : members.length === 0 ? (
<p className="text-center text-xs text-gray-400 font-semibold py-10 uppercase">Nenhuma outra conta neste workspace.</p>
) : members.map((m) => (
<div key={m.usuarioId} className="flex items-center gap-3 p-3 rounded-2xl border border-gray-100 bg-gray-50/60">
<div className="w-9 h-9 rounded-full bg-brand-500/10 flex items-center justify-center text-brand-600 shrink-0 text-xs font-black uppercase">
{(m.nome || m.email || '?').slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-gray-800 truncate">{m.nome || m.email}</p>
<p className="text-[10px] text-gray-400 font-semibold truncate">{m.email}{m.role ? ` · ${m.role}` : ''}</p>
</div>
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end max-w-[190px]">
<PermToggle label="Inbox" active={m.can_inbox} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_inbox', !m.can_inbox)} />
<PermToggle label="Secretária" active={m.can_secretaria} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_secretaria', !m.can_secretaria)} />
<PermToggle label="Re-scan" active={m.can_rescan} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_rescan', !m.can_rescan)} />
<PermToggle label="Apagar msg" danger active={m.can_delete_msg} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_delete_msg', !m.can_delete_msg)} />
<PermToggle label="Excluir sessão" danger active={m.can_delete} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_delete', !m.can_delete)} />
</div>
</div>
))}
</div>
</motion.div>
</div>
);
}
function PermToggle({ label, active, danger, busy, onClick }: { label: string; active: boolean; danger?: boolean; busy?: boolean; onClick: () => void }) {
const on = danger
? 'bg-red-500/15 text-red-600 border-red-500/30'
: 'bg-emerald-500/15 text-emerald-600 border-emerald-500/30';
const off = 'bg-white text-gray-400 border-gray-200 hover:border-gray-300';
return (
<button onClick={onClick} disabled={busy}
className={`px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider border transition-all disabled:opacity-60 ${active ? on : off}`}
>
{active ? '✓ ' : ''}{label}
</button>
);
}
export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => void }) {
const {
instances,
loading,
toast,
addOpen,
newName,
creating,
setNewName,
openAddModal,
closeAddModal,
handleCreate,
qrModal,
closeQrModal,
handleNewQr,
deleteTarget,
deleting,
setDeleteTarget,
closeDeleteModal,
handleDelete,
handleConnect,
handleDisconnect,
handleShowQr,
refreshInstances,
} = useSessionsLogic();
// ── Ownership & permissões por sessão ──────────────────────────────────────
const [authzByInstance, setAuthzByInstance] = useState<Record<string, SessionPerms>>({});
const [workspaceOwner, setWorkspaceOwner] = useState<{ isOwner: boolean; ownerNome: string | null }>({ isOwner: false, ownerNome: null });
// Endpoint de authz disponível? Se não (backend antigo/erro), a UI NÃO bloqueia —
// o enforcement real é do proxy do backend. Começa true (otimista) até saber.
const [authzAvailable, setAuthzAvailable] = useState(true);
const [permsModalFor, setPermsModalFor] = useState<Instance | null>(null);
const instanceIdsKey = instances.map((i) => i.id).join(',');
useEffect(() => {
let alive = true;
(async () => {
if (instances.length === 0) {
const r = await HybridBackend.getWaSessionAuthz('').catch(() => null);
if (!alive) return;
setAuthzAvailable(!!r?.available);
if (r) setWorkspaceOwner({ isOwner: r.isOwner, ownerNome: r.ownerNome });
return;
}
const results = await Promise.all(instances.map(async (inst) => {
const r = await HybridBackend.getWaSessionAuthz(inst.id).catch(() => null);
return [inst.id, r] as const;
}));
if (!alive) return;
const map: Record<string, SessionPerms> = {};
let owner = { isOwner: false, ownerNome: null as string | null };
let available = false;
for (const [id, r] of results) {
if (r?.available) available = true;
map[id] = r
? { isOwner: r.isOwner, canRescan: r.me.can_rescan, canDelete: r.me.can_delete, ownerNome: r.ownerNome }
: { isOwner: false, canRescan: true, canDelete: true, ownerNome: null };
if (r?.available && r.isOwner) owner = { isOwner: true, ownerNome: r.ownerNome };
else if (r?.available && !owner.ownerNome && r.ownerNome) owner.ownerNome = r.ownerNome;
}
setAuthzAvailable(available);
setAuthzByInstance(map);
setWorkspaceOwner(owner);
})();
return () => { alive = false; };
}, [instanceIdsKey]);
// Permissões efetivas de uma instância.
// Sem authz disponível → permissivo (não bloqueia; backend é a autoridade).
const permsFor = (inst: Instance): SessionPerms => {
if (!authzAvailable) return { isOwner: false, canRescan: true, canDelete: true, ownerNome: null };
return authzByInstance[inst.id]
?? (workspaceOwner.isOwner
? { ...OWNER_PERMS, ownerNome: workspaceOwner.ownerNome }
: { isOwner: false, canRescan: false, canDelete: false, ownerNome: workspaceOwner.ownerNome });
};
// "Nova Instância" (scan inicial): só o dono. Mas se o endpoint não está
// disponível ainda, não bloqueia na UI (o backend decide).
const canCreateInstance = !authzAvailable || workspaceOwner.isOwner;
// Já existe uma sessão ativa no motor para esta conta → mostra mensagem de
// sucesso em vez de sugerir novo pareamento.
const hasActiveSession = instances.some((i) => i.status === 'CONNECTED');
return (
<div className="bg-gray-50 min-h-full text-gray-800">
<div className="max-w-5xl mx-auto p-6 lg:p-10 pb-20 font-sans space-y-10">
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }}
className={`fixed top-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-2.5 px-5 py-3 rounded-2xl shadow-2xl border text-sm font-semibold ${toast.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-300' : 'bg-red-500/10 border-red-500/20 text-red-300'}`}
>
{toast.ok ? <CheckCircle2 className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
{toast.msg}
</motion.div>
)}
</AnimatePresence>
<header className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 relative">
<div className="absolute -top-16 -left-16 w-56 h-56 bg-brand-500/10 blur-[100px] rounded-full pointer-events-none" />
<div className="relative z-10 flex items-start gap-4">
<button onClick={() => onNavigate?.('dashboard')} className="p-2 hover:bg-gray-50 rounded-xl transition-colors text-gray-600 hover:text-gray-800 mt-1">
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex-1">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-brand-500 to-brand-700 rounded-xl flex items-center justify-center text-white shadow-glow-brand shrink-0">
<Wifi className="w-6 h-6" />
</div>
<h1 className="text-3xl font-black text-gray-800 tracking-tight">Instâncias</h1>
</div>
<p className="text-sm text-gray-500 font-medium mt-1 ml-[60px]">WhatsApp Multi-Device · Baileys</p>
</div>
</div>
<div className="flex items-center gap-3 relative z-10">
<button onClick={refreshInstances} title="Recarregar"
className="p-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-600 hover:text-gray-800 transition-all hover:bg-gray-100"
>
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
</button>
{canCreateInstance ? (
<button onClick={openAddModal}
className="flex items-center gap-2 px-5 py-3 bg-brand-500 hover:bg-brand-600 text-white font-bold rounded-xl transition-all shadow-glow-brand text-sm"
>
<Plus className="w-5 h-5" /> Nova Instância
</button>
) : (
<button disabled title="Somente o dono do workspace pode conectar um novo WhatsApp."
className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-400 font-bold rounded-xl text-sm cursor-not-allowed border border-gray-200"
>
<Lock className="w-5 h-5" /> Nova Instância
</button>
)}
</div>
</header>
{hasActiveSession && (
<motion.div
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-4 p-5 rounded-2xl bg-emerald-500/10 border border-emerald-500/20"
>
<div className="w-11 h-11 rounded-xl bg-emerald-500/15 flex items-center justify-center text-emerald-400 shrink-0">
<CheckCircle2 className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-base font-black text-emerald-300">Você possui acesso ao WhatsApp</h2>
<p className="text-sm text-emerald-400/70 font-medium">Sua sessão está ativa e conectada não é preciso parear novamente.</p>
</div>
{onNavigate && (
<button onClick={() => onNavigate('wa-inbox')}
className="shrink-0 flex items-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white font-bold rounded-xl transition-all text-sm"
>
Abrir conversas
</button>
)}
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<AnimatePresence mode="popLayout">
{loading ? (
Array(3).fill(0).map((_, i) => (
<div key={i} className="h-56 rounded-[2rem] animate-pulse bg-gray-50" />
))
) : instances.length === 0 ? (
<motion.div key="empty" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
className="col-span-full py-24 flex flex-col items-center justify-center text-center gap-6"
>
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center text-gray-500">
<Phone className="w-10 h-10" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-800">Nenhuma instância</h2>
<p className="text-gray-500 mt-1 text-sm">
{canCreateInstance
? 'Crie uma para começar a usar o WhatsApp.'
: 'O dono do workspace ainda não conectou um WhatsApp.'}
</p>
</div>
{canCreateInstance && (
<button onClick={openAddModal}
className="flex items-center gap-2 px-6 py-3 bg-gray-50 hover:bg-gray-100 text-gray-800 font-bold rounded-2xl border border-gray-200 transition-all"
>
<Plus className="w-5 h-5" /> Criar instância
</button>
)}
</motion.div>
) : (
instances.map((inst) => (
<SessionCard
key={inst.id}
inst={inst}
perms={permsFor(inst)}
onConnect={() => handleConnect(inst)}
onDisconnect={() => handleDisconnect(inst)}
onDelete={() => setDeleteTarget(inst)}
onShowQr={() => handleShowQr(inst)}
onManagePerms={() => setPermsModalFor(inst)}
/>
))
)}
</AnimatePresence>
</div>
<AnimatePresence>
{addOpen && (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={closeAddModal} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative bg-gray-100 backdrop-blur-2xl p-8 rounded-[3rem] border border-gray-200 max-w-sm w-full space-y-6 shadow-2xl"
>
<div>
<h2 className="text-2xl font-black text-gray-800">Nova Instância</h2>
<p className="text-gray-500 text-sm mt-1"> um nome para identificar esta conexão.</p>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Nome</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
placeholder="ex: principal, comercial, suporte..."
autoFocus
className="w-full bg-gray-50 border border-gray-200 rounded-2xl px-4 py-3 text-gray-800 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-brand-500/40 text-sm"
/>
</div>
<div className="flex gap-3">
<Button variant="ghost" onClick={closeAddModal} className="flex-1">
Cancelar
</Button>
<button onClick={handleCreate} disabled={creating || !newName.trim()}
className="flex-1 py-3 bg-emerald-500 hover:bg-emerald-600 text-white font-bold rounded-2xl transition-all disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Criar e conectar
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
<AnimatePresence>
{qrModal && (
<QrModal
instanceId={qrModal.instanceId}
instanceName={qrModal.instanceName}
qrBase64={qrModal.qrBase64}
onClose={closeQrModal}
onNewQr={handleNewQr}
/>
)}
</AnimatePresence>
<AnimatePresence>
{deleteTarget && (
<ConfirmDeleteModal
inst={deleteTarget}
onClose={closeDeleteModal}
onConfirm={handleDelete}
loading={deleting}
/>
)}
</AnimatePresence>
<AnimatePresence>
{permsModalFor && (
<ManagePermsModal inst={permsModalFor} onClose={() => setPermsModalFor(null)} />
)}
</AnimatePresence>
</div>
</div>
);
}
export default SessionsView;
+452
View File
@@ -0,0 +1,452 @@
'use client';
/**
* WhatsChatDrawer painel slide-over (direita) com um clone FOCADO da área de
* mensagens do NewWhats, aberto a partir de um agendamento da agenda.
*
* Diferente do InboxView (rota /wa-inbox), NÃO mostra a lista lateral de chats:
* abre direto na conversa do paciente, resolvida pelo telefone do cadastro.
* Um dropdown permite trocar entre o número do paciente e os do grupo familiar.
* Se o paciente ainda não tem conversa, oferece iniciar uma (POST /conversations,
* mesmo fluxo do NewConversationModal, que funciona no satélite via proxy nw).
*
* Reaproveita a MESMA fiação de hooks do InboxView (stores zustand globais +
* socket com guard de conexão), então montar este drawer não duplica a conexão.
*/
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronDown, User, Phone, Send, MessageCircle, Loader2, WifiOff, QrCode } from 'lucide-react';
import { HybridBackend } from '../../services/backend';
import { useChatStore } from './store/chatStore';
import { useInstanceStore } from './store/instanceStore';
import { formatPhone } from './utils/inboxUtils';
import { nw } from './services/nwClient';
import { useNewInboxBridge } from './hooks/inbox/useNewInboxBridge';
import { useInstanceSelector } from './hooks/inbox/useInstanceSelector';
import { useChatWindow } from './hooks/inbox/useChatWindow';
import { useMessageInput } from './hooks/inbox/useMessageInput';
import InboxHeader from './components/InboxHeader';
import MessageArea from './components/MessageArea';
import InputBar from './components/InputBar';
import ContactProfile from './components/ContactProfile';
// ─── Tipos ──────────────────────────────────────────────────────────────────
export interface WhatsChatDrawerProps {
isOpen: boolean;
onClose: () => void;
pacienteId?: string | null;
pacienteNome?: string | null;
onNavigate?: (view: string) => void;
}
interface NumeroOpcao {
phone: string; // dígitos crus do cadastro
nome: string;
relacao: string; // 'Paciente' | grupofamiliarrelacao
}
// Normaliza para o formato de envio brasileiro (55 + DDD + número).
function normalizeSendPhone(input: string): string {
const digits = String(input || '').replace(/\D/g, '');
if (digits.startsWith('55') && (digits.length === 12 || digits.length === 13)) return digits;
if (digits.length === 10 || digits.length === 11) return '55' + digits;
return digits;
}
// Compara dois telefones de forma tolerante (Brasil: 9º dígito, DDI opcional):
// casa pelos últimos 8 dígitos, que são estáveis.
function samePhone(a: string, b: string): boolean {
const da = String(a || '').replace(/\D/g, '');
const db = String(b || '').replace(/\D/g, '');
if (!da || !db) return false;
return da.slice(-8) === db.slice(-8);
}
// ─── Conteúdo interno (só monta quando aberto → hooks/socket sob demanda) ──────
const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose, pacienteId, pacienteNome, onNavigate }) => {
const { activeInstance, instances, connectedInstances } = useInstanceSelector();
const loadingInstances = useInstanceStore((s) => s.loading);
// Há WhatsApp utilizável? (pelo menos uma instância conectada)
const hasConnected = connectedInstances.length > 0;
const {
selectedChat,
messages,
loadingMessages,
handleSendText,
handleLoadMoreHistory,
activeInstanceId,
} = useNewInboxBridge();
const {
scrollRef, isTyping, replyingTo, reactionPickerFor, hasMoreHistory, loadingMore,
isProfileOpen, isSyncingAvatar, isChatMenuOpen,
setReplyingTo, setIsProfileOpen, setIsChatMenuOpen,
handleTyping, handleScroll, handleSelectChat: onChatWindowSelect,
handleReactToMessage, handleSendMedia, handleSendAudio, handleSyncAvatar, toggleReactionPicker,
} = useChatWindow({
selectedChat,
activeInstanceId: activeInstance?.instance ?? null,
onLoadMoreHistory: handleLoadMoreHistory,
});
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
selectedChat,
replyingTo,
onClearReply: () => setReplyingTo(null),
onSendText: handleSendText,
});
// ── Números disponíveis (paciente + grupo familiar) ──────────────────────
const [numeros, setNumeros] = useState<NumeroOpcao[]>([]);
const [selectedPhone, setSelectedPhone] = useState<string>('');
const [numDropdownOpen, setNumDropdownOpen] = useState(false);
// ── Estado de resolução da conversa ──────────────────────────────────────
const [resolving, setResolving] = useState(false);
const [notFound, setNotFound] = useState(false); // número válido, mas sem conversa
const [initText, setInitText] = useState('');
const [initSending, setInitSending] = useState(false);
const [initError, setInitError] = useState('');
// Carrega telefone do paciente + membros da família ao abrir.
useEffect(() => {
let alive = true;
(async () => {
const opts: NumeroOpcao[] = [];
const seen = new Set<string>();
const push = (phone: string, nome: string, relacao: string) => {
const d = String(phone || '').replace(/\D/g, '');
if (!d) return;
const key = d.slice(-8);
if (seen.has(key)) return;
seen.add(key);
opts.push({ phone: d, nome, relacao });
};
// 1) Telefone do próprio paciente — via cadastro (desambigua por id).
if (pacienteNome) {
try {
const r = await HybridBackend.getPacientes(pacienteNome);
const self = (r?.data || []).find((p: any) => p.id === pacienteId)
?? (r?.data || []).find((p: any) => (p.nome || '').toUpperCase() === pacienteNome.toUpperCase());
if (self?.telefone) push(self.telefone, self.nome || pacienteNome, 'Paciente');
} catch { /* ignore */ }
}
// 2) Grupo familiar / dependentes.
if (pacienteId) {
try {
const { membros } = await HybridBackend.getFamiliaPaciente(pacienteId);
for (const m of membros || []) {
if (m.telefone) push(m.telefone, m.nome, m.grupofamiliarrelacao || (m.id === pacienteId ? 'Paciente' : 'Familiar'));
}
} catch { /* ignore */ }
}
if (!alive) return;
setNumeros(opts);
setSelectedPhone(opts[0]?.phone || '');
})();
return () => { alive = false; };
}, [pacienteId, pacienteNome]);
// Resolve a conversa (chatId) a partir do telefone selecionado, sem enviar nada.
const resolveChat = useCallback(async (phoneDigits: string) => {
const store = useChatStore.getState();
if (!activeInstanceId || !phoneDigits) { setNotFound(true); return; }
setResolving(true);
setNotFound(false);
store.setActiveChat(null);
try {
const send = normalizeSendPhone(phoneDigits);
// Busca pelos ÚLTIMOS 8 dígitos (estáveis): o WhatsApp grava o jid do BR sem o
// 9º dígito (ex.: 556799591687), então buscar o número completo (13 dígitos)
// não casaria no `jid contains` do backend. O samePhone (últimos 8) filtra.
const tail8 = send.replace(/\D/g, '').slice(-8);
await store.loadChats(activeInstanceId, { search: tail8 });
const chats = useChatStore.getState().chats;
const hit = chats.find((c) =>
samePhone(c.contactPhone || c.jid.split('@')[0], send)) ?? null;
if (hit) {
await store.selectChat(hit.id);
onChatWindowSelect();
setNotFound(false);
} else {
setNotFound(true);
}
} catch {
setNotFound(true);
} finally {
setResolving(false);
}
}, [activeInstanceId, onChatWindowSelect]);
// (Re)resolve quando muda o número selecionado ou a instância fica pronta.
useEffect(() => {
if (selectedPhone && activeInstanceId) resolveChat(selectedPhone);
}, [selectedPhone, activeInstanceId, resolveChat]);
// Inicia conversa (paciente sem chat): mesmo fluxo do NewConversationModal.
const handleIniciarConversa = useCallback(async () => {
if (!activeInstanceId || !selectedPhone) return;
if (!initText.trim()) { setInitError('Escreva uma mensagem para iniciar a conversa.'); return; }
setInitSending(true);
setInitError('');
try {
const phone = normalizeSendPhone(selectedPhone);
await nw.post('/conversations', { sessionId: activeInstanceId, phone, text: initText.trim() });
setInitText('');
// Dá um respiro para o motor registrar o chat e então re-resolve.
await new Promise((r) => setTimeout(r, 900));
await resolveChat(selectedPhone);
} catch (e: any) {
setInitError(e?.message || 'Erro ao enviar. Verifique o número e tente novamente.');
} finally {
setInitSending(false);
}
}, [activeInstanceId, selectedPhone, initText, resolveChat]);
const selectedNumero = useMemo(
() => numeros.find((n) => n.phone === selectedPhone) ?? null,
[numeros, selectedPhone]
);
const tituloContato = selectedNumero?.nome || pacienteNome || 'Paciente';
return (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
className="fixed inset-0 z-[190] bg-black/40 backdrop-blur-[2px]"
/>
{/* Painel */}
<motion.aside
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="fixed inset-y-0 right-0 z-[191] w-full sm:w-[440px] bg-[#f0f2f5] shadow-2xl flex flex-col overflow-hidden"
>
{/* Barra superior do drawer: contato + dropdown de números + fechar */}
<div className="shrink-0 bg-[#008069] text-white px-3 py-2.5 flex items-center gap-2">
<MessageCircle className="w-5 h-5 shrink-0" />
<div className="min-w-0 flex-1 relative">
<button
type="button"
onClick={() => numeros.length > 1 && setNumDropdownOpen((v) => !v)}
className={`flex items-center gap-1.5 max-w-full ${numeros.length > 1 ? 'cursor-pointer' : 'cursor-default'}`}
>
<span className="font-bold text-sm truncate">{tituloContato}</span>
{selectedNumero && (
<span className="text-[11px] text-white/70 truncate">
{formatPhone(normalizeSendPhone(selectedNumero.phone))}
</span>
)}
{numeros.length > 1 && <ChevronDown className={`w-4 h-4 shrink-0 transition-transform ${numDropdownOpen ? 'rotate-180' : ''}`} />}
</button>
<AnimatePresence>
{numDropdownOpen && numeros.length > 1 && (
<motion.div
initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}
className="absolute left-0 top-full mt-1 w-72 bg-white text-gray-800 rounded-xl shadow-2xl border border-gray-100 py-1.5 z-10"
>
<p className="px-3 py-1 text-[9px] font-black uppercase tracking-widest text-gray-400">Número / grupo familiar</p>
{numeros.map((n) => (
<button
key={n.phone}
onClick={() => { setSelectedPhone(n.phone); setNumDropdownOpen(false); }}
className={`w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2.5 ${n.phone === selectedPhone ? 'bg-teal-50' : ''}`}
>
<div className="w-8 h-8 rounded-full bg-teal-50 flex items-center justify-center shrink-0">
<User size={15} className="text-teal-600" />
</div>
<div className="min-w-0">
<p className="text-xs font-black uppercase truncate">{n.nome}</p>
<p className="text-[10px] text-gray-400 font-bold">
<span className="text-teal-500">{n.relacao}</span> · {formatPhone(normalizeSendPhone(n.phone))}
</p>
</div>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<button onClick={onClose} className="p-1.5 hover:bg-white/20 rounded-full shrink-0" title="Fechar">
<X className="w-5 h-5" />
</button>
</div>
{/* Corpo */}
<div
className="flex-1 flex flex-col overflow-hidden relative"
style={{ backgroundImage: "url('/chat-bg.png')", backgroundRepeat: 'repeat', backgroundColor: '#b5bdb5' }}
>
<div className="absolute inset-0 bg-white/75 pointer-events-none z-0" />
{(loadingInstances && instances.length === 0) ? (
// ── Carregando estado das sessões ──────────────────────────────
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
<Loader2 className="w-8 h-8 animate-spin text-[#008069]" />
<span className="text-sm">Verificando conexão do WhatsApp</span>
</div>
) : !hasConnected ? (
// ── Nenhuma sessão de WhatsApp conectada ────────────────────────
<div className="flex-1 flex flex-col items-center justify-center gap-5 relative z-[1] p-8 text-center">
<div className="relative">
<div className="w-20 h-20 rounded-full bg-[#008069]/10 flex items-center justify-center">
<WifiOff className="w-9 h-9 text-[#008069]" />
</div>
<span className="absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-white shadow-md flex items-center justify-center">
<MessageCircle className="w-4 h-4 text-[#008069]" />
</span>
</div>
<div className="max-w-xs">
<h3 className="text-lg font-bold text-[#111b21]">
{instances.length === 0 ? 'WhatsApp não conectado' : 'Sessão desconectada'}
</h3>
<p className="text-[13px] text-[#667781] leading-relaxed mt-1.5">
{instances.length === 0
? 'Nenhuma conta de WhatsApp está vinculada a esta clínica. Conecte um número para conversar com os pacientes direto da agenda.'
: 'A sessão do WhatsApp está fora do ar no momento. Reconecte o número lendo o QR Code para retomar as conversas.'}
</p>
</div>
{onNavigate && (
<button
onClick={() => { onClose(); onNavigate('wa-sessions'); }}
className="flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold text-white bg-[#008069] hover:bg-[#017259] transition-all active:scale-[0.98] shadow-sm"
>
<QrCode className="w-4 h-4" />
{instances.length === 0 ? 'Conectar WhatsApp' : 'Reconectar sessão'}
</button>
)}
</div>
) : resolving ? (
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
<Loader2 className="w-8 h-8 animate-spin text-[#008069]" />
<span className="text-sm">Abrindo conversa</span>
</div>
) : !selectedPhone ? (
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] p-8 text-center">
<Phone className="w-12 h-12 opacity-10" />
<p className="text-sm text-[#667781]">Este paciente não tem telefone no cadastro.</p>
</div>
) : notFound ? (
// ── Sem conversa: iniciar uma nova ──────────────────────────────
<div className="flex-1 flex flex-col items-center justify-center gap-4 relative z-[1] p-6">
<div className="w-14 h-14 rounded-full bg-[#008069]/10 flex items-center justify-center">
<MessageCircle className="w-7 h-7 text-[#008069]" />
</div>
<div className="text-center">
<p className="text-sm font-bold text-[#111b21]">Sem conversa com {tituloContato}</p>
<p className="text-[12px] text-[#667781] mt-0.5">{formatPhone(normalizeSendPhone(selectedPhone))}</p>
</div>
<div className="w-full max-w-sm space-y-2">
<textarea
rows={3}
value={initText}
onChange={(e) => { setInitText(e.target.value); setInitError(''); }}
placeholder="Olá! Tudo bem? Passando para confirmar seu horário…"
disabled={initSending}
className="w-full bg-white rounded-xl px-4 py-3 text-sm text-[#111b21] placeholder:text-[#8696a0] focus:outline-none focus:ring-2 focus:ring-[#008069]/40 resize-none disabled:opacity-60 border border-gray-100"
/>
{initError && <p className="text-[12px] text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{initError}</p>}
<button
onClick={handleIniciarConversa}
disabled={initSending || !initText.trim()}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-sm font-semibold text-white bg-[#008069] hover:bg-[#017259] transition-all active:scale-[0.98] disabled:opacity-50"
>
{initSending
? <><Loader2 className="w-4 h-4 animate-spin" /> Enviando</>
: <><Send className="w-4 h-4" /> Iniciar conversa</>}
</button>
</div>
</div>
) : selectedChat ? (
// ── Conversa ativa ──────────────────────────────────────────────
<div className="flex-1 flex h-full relative z-[1] overflow-hidden">
<div className="flex-1 flex flex-col h-full overflow-hidden">
<InboxHeader
selectedChat={selectedChat}
isMobile={true}
isTyping={isTyping}
onAvatarClick={() => setIsProfileOpen(!isProfileOpen)}
onBack={onClose}
onSyncAvatar={handleSyncAvatar}
isChatMenuOpen={isChatMenuOpen}
isSyncingAvatar={isSyncingAvatar}
onSearchClick={() => {}}
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
/>
<MessageArea
messages={messages as any}
selectedChat={selectedChat}
scrollRef={scrollRef}
msgLoading={loadingMessages}
onScroll={handleScroll}
historyLoadingMore={loadingMore}
hasMoreHistory={hasMoreHistory}
reactionPickerFor={reactionPickerFor}
onReply={setReplyingTo}
onToggleReactionPicker={toggleReactionPicker}
onReact={handleReactToMessage}
onForward={() => {}}
onDelete={() => {}}
onEdit={() => {}}
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
getGroupSenderLabel={(msg: any) => {
if (msg.direction === 'out') return 'Você';
if (msg.participant) return msg.participant;
if (msg.senderJid) {
if (msg.senderJid.endsWith('@lid')) return msg.participant || '';
const phone = msg.senderJid.split('@')[0].split(':')[0];
return formatPhone(phone, msg.senderJid);
}
return '';
}}
/>
<InputBar
newMessage={newMessage}
onNewMessageChange={setNewMessage}
onSend={handleSendMessage}
onPresence={handleTyping}
onCancelReply={() => setReplyingTo(null)}
replyingTo={replyingTo}
selectedChat={selectedChat}
isMobile={true}
onSendMedia={handleSendMedia}
onSendAudio={handleSendAudio}
instanceId={activeInstance?.instance ?? null}
mentions={mentions}
onMentionsChange={setMentions}
/>
</div>
<ContactProfile
isOpen={isProfileOpen}
chat={selectedChat}
messages={messages as any}
onClose={() => setIsProfileOpen(false)}
/>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
)}
</div>
</motion.aside>
</>
);
};
// ─── Wrapper: só monta o conteúdo (e os hooks) quando aberto ──────────────────
export const WhatsChatDrawer: React.FC<WhatsChatDrawerProps> = ({ isOpen, ...rest }) => (
<AnimatePresence>
{isOpen && <DrawerInner {...rest} />}
</AnimatePresence>
);
export default WhatsChatDrawer;
@@ -0,0 +1,67 @@
'use client';
import React from 'react';
import { MessageCircle } from 'lucide-react';
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
import ChatListItem from './ChatListItem';
interface ChatListProps {
chats: NewWhatsChat[];
loading: boolean;
selectedChat: NewWhatsChat | null;
onSelectChat: (chat: NewWhatsChat) => void;
onMenuAction: (action: any, chat: NewWhatsChat) => void;
presenceByJid: Record<string, PresenceState>;
isFavoriteChat: (chat: NewWhatsChat) => boolean;
drafts?: Record<string, string>;
canDelete?: boolean;
}
export default function ChatList({
chats,
loading,
selectedChat,
onSelectChat,
onMenuAction,
presenceByJid,
isFavoriteChat,
drafts = {},
canDelete = true,
}: ChatListProps) {
if (loading) {
return (
<div className="flex items-center justify-center h-40">
<div className="w-8 h-8 border-3 border-[#00a884] border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
if (chats.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 px-10 text-center">
<div className="w-16 h-16 bg-[#f0f2f5] rounded-full flex items-center justify-center mb-4">
<MessageCircle className="w-8 h-8 text-[#8696a0]" />
</div>
<p className="text-[#667781] text-sm font-normal">Nenhuma conversa encontrada</p>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto bg-white custom-scrollbar">
{chats.map((chat) => (
<ChatListItem
key={`${chat.instance_name}-${chat.remote_jid}`}
chat={chat}
isActive={selectedChat?.id === chat.id}
onSelect={onSelectChat}
onMenuAction={onMenuAction}
presenceByJid={presenceByJid}
isFavorite={isFavoriteChat(chat)}
draft={drafts[chat.remote_jid]}
canDelete={canDelete}
/>
))}
</div>
);
}
@@ -0,0 +1,294 @@
'use client';
import React, { useMemo, useRef, useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
Pin, Check, CheckCheck, Clock, ChevronDown,
VolumeX, Star, StarOff, Archive, ArchiveRestore,
Trash2, MailOpen, Volume2, PinOff,
Mic, FileText, Video, Image as ImageIcon,
AlertCircle, Bot,
} from 'lucide-react';
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
import {
normalizeJid,
} from '../utils/inboxUtils';
import Avatar from './ui/Avatar';
interface ChatListItemProps {
chat: NewWhatsChat;
isActive: boolean;
onSelect: (chat: NewWhatsChat) => void;
onMenuAction: (action: any, chat: NewWhatsChat) => void;
presenceByJid: Record<string, PresenceState>;
isFavorite: boolean;
draft?: string;
canDelete?: boolean; // false esconde "Apagar conversa" (sem permissão)
}
const renderLastStatus = (chat: NewWhatsChat) => {
if (chat.last_message_from_me !== 1) return null;
switch (chat.last_message_status) {
case 'pending':
case 'sending':
return <Clock className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
case 'sent':
return <Check className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
case 'delivered':
return <CheckCheck className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
case 'read':
return <CheckCheck className="w-3.5 h-3.5 text-[#53bdeb] flex-shrink-0" />;
case 'failed':
return <AlertCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />;
default:
return null;
}
};
const getLastMessageSnippet = (chat: NewWhatsChat, draft?: string): React.ReactNode => {
if (draft) return <span className="text-[#00a884]">Rascunho: {draft}</span>;
const truncate = (value: string, limit = 35) => value.length > limit ? `${value.slice(0, limit - 1)}...` : value;
// Group Sender Prefix — WhatsApp exibe "Você:" ou primeiro nome do remetente
const isGroup = chat.remote_jid.endsWith('@g.us');
const senderPrefix = isGroup
? (chat.last_message_from_me === 1
? 'Você: '
: (chat.last_message_sender ? `${chat.last_message_sender.split(' ')[0]}: ` : ''))
: '';
const IconLabel = ({ icon: Icon, label }: { icon: any, label: string }) => (
<span className="flex items-center gap-1">
<Icon className="w-3.5 h-3.5" />
{senderPrefix}{label}
</span>
);
// Use last_message_type (pipeline do chatStore) para ícones de mídia
const msgType = (chat.last_message_type ?? '').toLowerCase();
if (msgType === 'audio' || msgType === 'ptt') return <IconLabel icon={Mic} label="Áudio" />;
if (msgType === 'sticker') return <IconLabel icon={Star} label="Figurinha" />;
if (msgType === 'image') {
const caption = chat.last_message_text?.trim();
return caption
? <IconLabel icon={ImageIcon} label={truncate(caption)} />
: <IconLabel icon={ImageIcon} label="Imagem" />;
}
if (msgType === 'video') {
const caption = chat.last_message_text?.trim();
return caption
? <IconLabel icon={Video} label={truncate(caption)} />
: <IconLabel icon={Video} label="Vídeo" />;
}
if (msgType === 'document') return <IconLabel icon={FileText} label={chat.last_message_text?.trim() || 'Documento'} />;
// Texto puro (text, extendedtext, etc.)
const text = chat.last_message_text ?? '';
if (text.trim()) return `${senderPrefix}${truncate(text.trim())}`;
return senderPrefix || '';
};
export default function ChatListItem({
chat,
isActive,
onSelect,
onMenuAction,
presenceByJid,
isFavorite,
draft,
canDelete = true
}: ChatListItemProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
const menuBtnRef = useRef<HTMLButtonElement>(null);
const relativeTime = useMemo(() => {
if (!chat.last_message_time) return '';
const date = new Date(chat.last_message_time);
if (isNaN(date.getTime())) return '';
// Comparação por data de calendário (não por 24h corridas)
const today = new Date(); today.setHours(0, 0, 0, 0);
const msgDay = new Date(date); msgDay.setHours(0, 0, 0, 0);
const diffDays = Math.round((today.getTime() - msgDay.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
if (diffDays === 1) return 'Ontem';
if (diffDays < 7) {
// "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"
const short = date.toLocaleDateString('pt-BR', { weekday: 'short' });
return short.replace('.', '').replace(/^\w/, (c) => c.toUpperCase());
}
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' });
}, [chat.last_message_time]);
const isMuted = useMemo(() => {
return Boolean(chat.muted_until && new Date(chat.muted_until).getTime() > Date.now());
}, [chat.muted_until]);
const menuItems = useMemo(() => {
const isPinned = Boolean(chat.is_pinned);
const isArchived = Boolean(chat.is_archived);
const isGroup = chat.remote_jid.endsWith('@g.us');
return [
{ key: 'favorite', label: isFavorite ? 'Remover favorito' : 'Favoritar', icon: isFavorite ? <StarOff className="w-4 h-4" /> : <Star className="w-4 h-4" /> },
{ key: 'archive', label: isArchived ? 'Desarquivar' : 'Arquivar', icon: isArchived ? <ArchiveRestore className="w-4 h-4" /> : <Archive className="w-4 h-4" /> },
{ key: 'mute', label: isMuted ? 'Desativar silêncio' : 'Silenciar', icon: isMuted ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" /> },
{ key: 'pin', label: isPinned ? 'Desafixar' : 'Fixar conversa', icon: isPinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" /> },
{ key: 'mark_read', label: 'Marcar como lida', icon: <MailOpen className="w-4 h-4" /> },
...(canDelete ? [{ key: 'delete', label: 'Apagar conversa', icon: <Trash2 className="w-4 h-4" />, danger: true }] : []),
];
}, [chat, isFavorite, isMuted, canDelete]);
const MENU_H = 252; // 6 itens × ~40px + 12px padding container
const MENU_W = 224; // w-56
const openMenu = (anchorX: number, anchorY: number, anchorBottom?: number) => {
const triggerBottom = anchorBottom ?? anchorY;
const spaceBelow = window.innerHeight - triggerBottom;
// Abre abaixo se couber; senão ancora o BOTTOM do menu no TOP do trigger
const top = spaceBelow >= MENU_H + 8
? triggerBottom + 4
: anchorY - MENU_H;
const left = Math.min(anchorX, window.innerWidth - MENU_W - 8);
setMenuPos({ top: Math.max(8, top), left: Math.max(8, left) });
setMenuOpen(true);
};
return (
<>
{/* Usamos div+role="button" em vez de <button> porque dentro há outro
<button> (o menu chevron) aninhar botões é inválido em HTML e
dispara validateDOMNesting warning no React. */}
<div
role="button"
tabIndex={0}
onClick={() => onSelect(chat)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(chat);
}
}}
onContextMenu={(e) => {
e.preventDefault();
// Abre à direita do cursor se couber, senão à esquerda
const spaceRight = window.innerWidth - e.clientX;
const x = spaceRight >= MENU_W + 8 ? e.clientX : e.clientX - MENU_W;
// Passa anchorY = cursor Y, anchorBottom = cursor Y (sem item height)
openMenu(Math.max(8, x), e.clientY, e.clientY);
}}
className={`w-full flex items-center gap-3 px-3 py-2 transition-all text-[#111b21] group border-b border-[#f0f2f5] last:border-none cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[#00a884]/40 ${isActive ? 'bg-[#f0f2f5]' : 'hover:bg-[#f5f6f6]'}`}
>
{/* Avatar Section */}
<Avatar chat={chat} className="flex-shrink-0" />
{/* Content Section */}
<div className="flex-1 min-w-0 flex flex-col justify-center py-0.5 pr-1 text-left">
<div className="flex items-center justify-between">
<h3 className="text-[16px] leading-[21px] font-normal text-[#111b21] truncate">
{chat.displayName || chat.subject || chat.phone}
</h3>
<span className={`text-[12px] leading-[14px] flex-shrink-0 ml-2 ${chat.unread_count > 0 ? 'text-[#00a884] font-medium' : 'text-[#667781]'}`}>
{relativeTime}
</span>
</div>
<div className="flex items-center justify-between mt-1 h-5">
<div className="flex items-center gap-1 flex-1 min-w-0">
{(() => {
const jid = normalizeJid(chat.remote_jid);
const p = presenceByJid[jid];
const isPresenceLive = p && (Date.now() - p.ts) <= 6000;
if (isPresenceLive && (p.state === 'composing' || p.state === 'recording')) {
return (
<p className="text-[13px] text-[#00a884] font-normal truncate">
{p.state === 'recording' ? 'gravando áudio...' : 'digitando...'}
</p>
);
}
return (
<div className="flex items-center gap-1 min-w-0">
{!draft && renderLastStatus(chat)}
<div className={`text-[13px] leading-[20px] truncate max-w-full ${draft ? 'text-[#00a884]' : 'text-[#667781]'}`}>
{getLastMessageSnippet(chat, draft)}
</div>
</div>
);
})()}
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-1">
{chat.bot_paused && (
<span title="Bot pausado — aguardando atendente">
<Bot className="w-4 h-4 text-amber-500" />
</span>
)}
{isMuted && <VolumeX className="w-4 h-4 text-[#8696a0]" />}
{Boolean(chat.is_pinned) && <Pin className="w-4 h-4 text-[#8696a0]" />}
{chat.unread_count > 0 && (
<span className="bg-[#00a884] text-white text-[12px] font-bold px-1.5 py-0.5 rounded-full min-w-[20px] h-5 flex items-center justify-center">
{chat.unread_count > 99 ? '99+' : chat.unread_count}
</span>
)}
{/* Hover Chevron for Menu */}
<div className="relative group-hover:block hidden">
<button
ref={menuBtnRef}
className="p-1 rounded-full hover:bg-black/5"
onClick={(e) => {
e.stopPropagation();
const rect = menuBtnRef.current?.getBoundingClientRect();
if (rect) {
openMenu(rect.right - MENU_W, rect.top, rect.bottom);
}
}}
>
<ChevronDown className="w-4 h-4 text-[#8696a0]" />
</button>
</div>
</div>
</div>
</div>
</div>
{/* Context Menu Portal (fora do item pra não ficar dentro do role=button) */}
{menuOpen && menuPos && typeof window !== 'undefined' && createPortal(
<>
<div className="fixed inset-0 z-[1000]" onClick={(e) => { e.stopPropagation(); setMenuOpen(false); }} />
<div
className="fixed bg-white py-1.5 rounded-lg shadow-xl border border-gray-100 z-[1001] w-56 flex flex-col"
style={{ top: menuPos.top, left: menuPos.left }}
onClick={(e) => e.stopPropagation()}
>
{menuItems.map((item: any) => (
<button
key={item.key}
onClick={() => {
onMenuAction(item.key, chat);
setMenuOpen(false);
}}
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left ${
item.danger ? 'text-red-600 hover:bg-red-50' : 'text-[#3b4a54] hover:bg-[#f5f6f6]'
}`}
>
<span className={item.danger ? 'text-red-500' : 'text-[#8696a0]'}>{item.icon}</span>
{item.label}
</button>
))}
</div>
</>,
document.body
)}
</>
);
}
@@ -0,0 +1,110 @@
'use client';
import React, { useMemo, useState } from 'react';
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
import ChatList from './ChatList';
interface ChatSidebarProps {
chats: NewWhatsChat[];
loading: boolean;
selectedChat: NewWhatsChat | null;
searchTerm: string;
selectedLabelFilter: string | null;
activeFilter: string;
chatScope: 'inbox' | 'favorites' | 'archived' | 'custom_list';
activeCustomListName?: string | null;
presenceByJid: Record<string, PresenceState>;
isFavoriteChat: (chat: NewWhatsChat) => boolean;
onSelectChat: (chat: NewWhatsChat) => void;
onMenuAction: (action: any, chat: NewWhatsChat) => void;
drafts?: Record<string, string>;
canDelete?: boolean;
}
export default function ChatSidebar({
chats,
loading,
selectedChat,
searchTerm,
selectedLabelFilter,
activeFilter,
chatScope,
activeCustomListName,
presenceByJid,
isFavoriteChat,
onSelectChat,
onMenuAction,
drafts = {},
canDelete = true,
}: ChatSidebarProps) {
// UI Pills (Simplified, for now we keep layout consistent)
const [pillFilter, setPillFilter] = useState<'all' | 'unread' | 'favorites' | 'groups'>('all');
const filteredChats = useMemo(() => chats.filter((chat) => {
const isGroup = chat.remote_jid.endsWith('@g.us');
// Filter by pill
if (pillFilter === 'unread' && chat.unread_count === 0) return false;
if (pillFilter === 'groups' && !isGroup) return false;
if (pillFilter === 'favorites' && !isFavoriteChat(chat)) return false;
const displayName = chat.displayName || chat.subject || chat.phone || chat.remote_jid;
const matchesSearch = displayName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(chat.last_message_text || '').toLowerCase().includes(searchTerm.toLowerCase());
const matchesLabel = !selectedLabelFilter ||
(chat.labels && chat.labels.some((l) => l.label_id === selectedLabelFilter));
return matchesSearch && matchesLabel;
}), [chats, searchTerm, selectedLabelFilter, pillFilter, isFavoriteChat]);
const sortedFilteredChats = useMemo(
() => [...filteredChats].sort((a, b) => {
const aPinned = Boolean(a.is_pinned);
const bPinned = Boolean(b.is_pinned);
if (aPinned !== bPinned) return aPinned ? -1 : 1;
const tA = a.last_message_time ? new Date(a.last_message_time).getTime() : 0;
const tB = b.last_message_time ? new Date(b.last_message_time).getTime() : 0;
return tB - tA;
}),
[filteredChats]
);
return (
<div className="flex flex-col h-full bg-white border-r border-[#e9edef] overflow-hidden">
{/* Filter Pills */}
<div className="px-3 py-2 flex items-center gap-2 overflow-x-auto no-scrollbar flex-shrink-0 border-b border-[#f0f2f5]">
{(['all', 'unread', 'favorites', 'groups'] as const).map((filter) => (
<button
key={filter}
onClick={() => setPillFilter(filter)}
className={`px-3 py-1 rounded-full text-sm whitespace-nowrap transition-colors ${
pillFilter === filter
? 'bg-[#00a884] text-white'
: 'bg-[#f0f2f5] text-[#54656f] hover:bg-[#e9edef]'
}`}
>
{filter === 'all' && 'Tudo'}
{filter === 'unread' && 'Não lidas'}
{filter === 'favorites' && 'Favoritos'}
{filter === 'groups' && 'Grupos'}
</button>
))}
</div>
{/* Scrollable Chat List */}
<ChatList
chats={sortedFilteredChats}
loading={loading}
selectedChat={selectedChat}
onSelectChat={onSelectChat}
onMenuAction={onMenuAction}
presenceByJid={presenceByJid}
isFavoriteChat={isFavoriteChat}
drafts={drafts}
canDelete={canDelete}
/>
</div>
);
}
@@ -0,0 +1,173 @@
'use client';
import React, { useMemo } from 'react';
import {
X,
ChevronRight,
Image as ImageIcon,
Video as VideoIcon,
FileText,
Mic,
Bell,
Star,
Clock,
Ban,
ThumbsDown,
Trash2
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
formatPhone,
getMediaUrl
} from '../utils/inboxUtils';
import Avatar from './ui/Avatar';
import { Message } from '../types/inboxTypes';
interface ContactProfileProps {
isOpen: boolean;
chat: any;
messages: Message[];
onClose: () => void;
}
const ContactProfile: React.FC<ContactProfileProps> = ({
isOpen,
chat,
messages,
onClose,
}) => {
// Filter media messages
const mediaMessages = useMemo(() => {
return messages.filter(m => m.type === 'image' || m.type === 'video' || m.type === 'document' || m.type === 'audio');
}, [messages]);
const photoVideos = mediaMessages.filter(m => m.type === 'image' || m.type === 'video');
const documents = mediaMessages.filter(m => m.type === 'document');
const audios = mediaMessages.filter(m => m.type === 'audio');
if (!chat) return null;
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-[400px] h-full bg-[#f0f2f5] border-l border-[#d1d7db] flex flex-col z-40 relative flex-shrink-0"
>
{/* Header */}
<div className="h-[60px] px-4 bg-white flex items-center gap-6 border-b border-[#d1d7db] shrink-0">
<button
onClick={onClose}
className="p-2 hover:bg-black/5 rounded-full transition-colors"
>
<X className="w-6 h-6 text-[#54656f]" />
</button>
<h2 className="text-[16px] font-normal text-[#111b21]">Dados do contato</h2>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar pb-10">
{/* Profile Section */}
<div className="bg-white px-8 py-8 flex flex-col items-center mb-2 shadow-sm">
{/* Profile Photo */}
<Avatar chat={chat} size={200} className="mb-5 shadow-lg" />
<h1 className="text-[24px] font-normal text-[#111b21] mb-1 text-center">
{chat.lead_name || chat.subject || formatPhone(chat.phone, chat.remote_jid)}
</h1>
<p className="text-[16px] text-[#667781] font-normal mb-1">
{formatPhone(chat.phone, chat.remote_jid)}
</p>
{chat.bio && (
<p className="text-[14px] text-[#667781] font-normal mt-2 text-center max-w-xs">
{chat.bio}
</p>
)}
</div>
{/* Status (Bio) */}
<div className="bg-white px-8 py-4 mb-2 shadow-sm">
<h3 className="text-[14px] text-[#667781] font-normal mb-3">Recado</h3>
<p className="text-[16px] text-[#111b21] font-normal leading-relaxed">
{chat.bio || 'Sem recado disponível'}
</p>
</div>
{/* Media Section */}
<div className="bg-white mb-2 shadow-sm">
<button className="w-full px-8 py-4 flex items-center justify-between hover:bg-[#f5f6f6] transition-colors group">
<span className="text-[14px] text-[#667781] font-normal">Mídia, links e documentos</span>
<div className="flex items-center gap-1">
<span className="text-[14px] text-[#667781] font-normal">{mediaMessages.length}</span>
<ChevronRight className="w-5 h-5 text-[#8696a0] group-hover:translate-x-0.5 transition-transform" />
</div>
</button>
{mediaMessages.length > 0 ? (
<div className="px-8 pb-4 grid grid-cols-3 gap-1">
{photoVideos.slice(0, 3).map((m, i) => (
<div
key={m.id || i}
className="aspect-square bg-gray-100 rounded-sm overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const url = getMediaUrl(m.media_url);
if (url) window.open(url, '_blank');
}}
>
<div className="w-full h-full flex items-center justify-center bg-black/10">
{m.type === 'video' ? <VideoIcon className="w-6 h-6 text-white" /> : <ImageIcon className="w-6 h-6 text-gray-400" />}
</div>
</div>
))}
{mediaMessages.length > 3 && (
<div className="aspect-square flex items-center justify-center bg-gray-50 border border-dashed border-gray-200 text-gray-400 text-xs rounded-sm">
+{mediaMessages.length - 3}
</div>
)}
</div>
) : (
<div className="px-8 pb-6 flex flex-col items-center justify-center text-center">
<p className="text-[14px] text-[#667781] italic">Nenhuma mídia compartilhada</p>
</div>
)}
</div>
{/* Actions Section */}
<div className="bg-white mb-2 shadow-sm py-2">
{[
{ icon: Bell, label: 'Silenciar notificações', color: '#111b21' },
{ icon: Star, label: 'Mensagens favoritas', color: '#111b21' },
{ icon: Clock, label: 'Mensagens temporárias', sub: 'Desativadas', color: '#111b21' },
].map((item, idx) => (
<button key={idx} className="w-full px-8 py-3.5 flex items-center gap-6 hover:bg-[#f5f6f6] transition-colors text-left">
<item.icon className="w-5 h-5 text-[#8696a0]" />
<div className="flex flex-col">
<span className="text-[16px] text-[#111b21] font-normal leading-none">{item.label}</span>
{item.sub && <span className="text-[12px] text-[#667781] mt-1">{item.sub}</span>}
</div>
</button>
))}
</div>
<div className="bg-white mb-2 shadow-sm py-2">
{[
{ icon: Ban, label: `Bloquear ${chat.lead_name || 'Contato'}`, color: '#ea0038' },
{ icon: ThumbsDown, label: `Denunciar ${chat.lead_name || 'Contato'}`, color: '#ea0038' },
{ icon: Trash2, label: 'Apagar conversa', color: '#ea0038' }
].map((item, idx) => (
<button key={idx} className="w-full px-8 py-3.5 flex items-center gap-6 hover:bg-[#f5f6f6] transition-colors text-left">
<item.icon className="w-5 h-5" style={{ color: item.color }} />
<span className="text-[16px] font-normal" style={{ color: item.color }}>{item.label}</span>
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
};
export default ContactProfile;
@@ -0,0 +1,244 @@
'use client';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Send, FileText, Film, Music, Archive, File, ZoomIn, ZoomOut } from 'lucide-react';
interface FilePreviewModalProps {
file: File | null;
onConfirm: (file: File, caption: string) => Promise<void>;
onCancel: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getFileIcon(type: string) {
if (type.startsWith('video/')) return <Film className="w-16 h-16 text-blue-400" />;
if (type.startsWith('audio/')) return <Music className="w-16 h-16 text-purple-400" />;
if (type.includes('pdf') || type.includes('document') || type.includes('word'))
return <FileText className="w-16 h-16 text-red-400" />;
if (type.includes('zip') || type.includes('compressed') || type.includes('tar'))
return <Archive className="w-16 h-16 text-yellow-400" />;
return <File className="w-16 h-16 text-[#8696a0]" />;
}
function getFileLabel(type: string): string {
if (type.startsWith('image/')) return 'Imagem';
if (type.startsWith('video/')) return 'Vídeo';
if (type.startsWith('audio/')) return 'Áudio';
if (type.includes('pdf')) return 'PDF';
return 'Arquivo';
}
function ModalContent({ file, onConfirm, onCancel }: FilePreviewModalProps & { file: File }) {
const [caption, setCaption] = useState('');
const [sending, setSending] = useState(false);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [zoomed, setZoomed] = useState(false);
// Posição do mouse como % dentro da área da imagem (para o zoom seguir o cursor)
const [mouseOrigin, setMouseOrigin] = useState({ x: 50, y: 50 });
const captionRef = useRef<HTMLInputElement>(null);
const previewAreaRef = useRef<HTMLDivElement>(null);
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
useEffect(() => {
const url = URL.createObjectURL(file);
setObjectUrl(url);
setCaption('');
setSending(false);
setZoomed(false);
setMouseOrigin({ x: 50, y: 50 });
setTimeout(() => captionRef.current?.focus(), 250);
return () => URL.revokeObjectURL(url);
}, [file]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onCancel]);
const handleSend = useCallback(async () => {
if (sending) return;
setSending(true);
try { await onConfirm(file, caption.trim()); }
finally { setSending(false); }
}, [file, caption, sending, onConfirm]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { e.preventDefault(); handleSend(); }
};
// Atualiza a origem do zoom conforme o mouse se move na área de preview
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isImage) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
setMouseOrigin({ x, y });
}, [isImage]);
const toggleZoom = useCallback(() => setZoomed(z => !z), []);
return (
<motion.div
key="file-preview-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="fixed inset-0 flex flex-col bg-[#0b141a]"
style={{ zIndex: 99999 }}
>
{/* ── Top bar ── */}
<div className="flex items-center gap-3 px-4 py-3 bg-[#202c33] shrink-0 shadow-md">
{/* Info do arquivo — lado esquerdo */}
<div className="flex-1 min-w-0">
<p className="text-[#e9edef] text-sm font-medium truncate">{file.name}</p>
<p className="text-[#8696a0] text-xs">{getFileLabel(file.type)} · {formatFileSize(file.size)}</p>
</div>
{/* Botões — lado direito: zoom (se imagem) → fechar */}
<div className="flex items-center gap-1 shrink-0">
{isImage && (
<button
onClick={toggleZoom}
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
title={zoomed ? 'Zoom normal' : 'Ampliar'}
>
{zoomed ? <ZoomOut className="w-5 h-5" /> : <ZoomIn className="w-5 h-5" />}
</button>
)}
<button
onClick={onCancel}
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
title="Fechar"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* ── Preview area ── */}
<div
ref={previewAreaRef}
className="flex-1 flex items-center justify-center min-h-0 overflow-hidden p-4"
onMouseMove={handleMouseMove}
onClick={isImage ? toggleZoom : undefined}
style={{ cursor: isImage ? (zoomed ? 'zoom-out' : 'zoom-in') : 'default' }}
>
{isImage && objectUrl && (
<motion.img
src={objectUrl}
alt="preview"
initial={{ scale: 0.94, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.22 }}
draggable={false}
className="select-none rounded-lg shadow-2xl"
style={zoomed ? {
// Zoom 2.5× centrado no cursor
transform: `scale(2.5)`,
transformOrigin: `${mouseOrigin.x}% ${mouseOrigin.y}%`,
transition: 'transform-origin 0s', // origin segue o mouse instantaneamente
maxWidth: '100%',
maxHeight: 'calc(100vh - 160px)',
objectFit: 'contain',
cursor: 'zoom-out',
} : {
maxWidth: '100%',
maxHeight: 'calc(100vh - 160px)',
objectFit: 'contain',
cursor: 'zoom-in',
transition: 'transform 0.2s ease',
transform: 'scale(1)',
transformOrigin: 'center center',
}}
/>
)}
{isVideo && objectUrl && (
<motion.video
src={objectUrl}
controls
initial={{ scale: 0.94, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.22 }}
className="max-w-full max-h-full rounded-lg shadow-2xl"
style={{ maxHeight: 'calc(100vh - 160px)' }}
/>
)}
{!isImage && !isVideo && (
<motion.div
initial={{ scale: 0.92, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.22 }}
className="flex flex-col items-center gap-5 bg-[#202c33] rounded-2xl px-14 py-12 shadow-2xl max-w-sm w-full"
>
<div className="w-24 h-24 rounded-2xl bg-[#2a3942] flex items-center justify-center">
{getFileIcon(file.type)}
</div>
<div className="text-center space-y-1">
<p className="text-[#e9edef] text-sm font-medium break-all">{file.name}</p>
<p className="text-[#8696a0] text-xs">{formatFileSize(file.size)}</p>
</div>
</motion.div>
)}
</div>
{/* ── Caption + Send bar ── */}
<div className="shrink-0 bg-[#202c33] px-4 py-3 flex items-center gap-3 shadow-[0_-1px_0_rgba(255,255,255,0.06)]">
<input
ref={captionRef}
type="text"
placeholder="Adicionar legenda…"
value={caption}
onChange={(e) => setCaption(e.target.value)}
onKeyDown={handleKeyDown}
disabled={sending}
maxLength={1024}
className="flex-1 bg-[#2a3942] text-[#e9edef] placeholder:text-[#8696a0] text-sm rounded-full px-5 py-2.5 outline-none border-none focus:ring-0 disabled:opacity-50 transition-opacity"
/>
<button
onClick={handleSend}
disabled={sending}
className="w-12 h-12 bg-[#00a884] hover:bg-[#00c49a] disabled:opacity-60 rounded-full flex items-center justify-center transition-all shadow-lg active:scale-95 shrink-0"
>
{sending
? <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Send className="w-5 h-5 text-white translate-x-px" />
}
</button>
</div>
</motion.div>
);
}
export default function FilePreviewModal({ file, onConfirm, onCancel }: FilePreviewModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{file && (
<ModalContent
file={file}
onConfirm={onConfirm}
onCancel={onCancel}
/>
)}
</AnimatePresence>,
document.body
);
}
@@ -0,0 +1,159 @@
'use client';
import React from 'react';
import {
ArrowLeft,
Search,
MoreVertical,
RefreshCw,
Tag,
X,
ClipboardList,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
formatPhone,
} from '../utils/inboxUtils';
import Avatar from './ui/Avatar';
interface InboxHeaderProps {
selectedChat: any;
isMobile: boolean;
isTyping: boolean;
isChatMenuOpen: boolean;
isSyncingAvatar: boolean;
isProtocolOpen?: boolean;
hasActiveProtocol?: boolean;
onBack: () => void;
onSearchClick: () => void;
onToggleMenu: () => void;
onSyncAvatar: () => void;
onAvatarClick?: () => void;
onProtocolClick?: () => void;
}
const InboxHeader: React.FC<InboxHeaderProps> = ({
selectedChat,
isMobile,
isTyping,
isChatMenuOpen,
isSyncingAvatar,
isProtocolOpen,
hasActiveProtocol,
onBack,
onSearchClick,
onToggleMenu,
onSyncAvatar,
onAvatarClick,
onProtocolClick,
}) => {
return (
<header className="h-[60px] px-4 bg-[#f0f2f5] flex items-center justify-between border-b border-[#d1d7db] z-30 relative shrink-0">
<div className="flex items-center gap-3 flex-1 min-w-0">
{isMobile && (
<button
onClick={onBack}
className="p-1 -ml-1 mr-1 hover:bg-black/5 rounded-full transition-colors active:scale-90"
>
<ArrowLeft className="w-6 h-6 text-[#54656f]" />
</button>
)}
{/* Identity Area - Clickable */}
<div
onClick={onAvatarClick}
className="flex items-center gap-3 min-w-0 cursor-pointer group/header hover:bg-black/5 px-2 py-1 -ml-2 rounded-md transition-all active:scale-[0.98]"
>
{/* Avatar */}
<Avatar chat={selectedChat} size={40} className="flex-shrink-0" />
<div className="flex flex-col min-w-0">
<h3 className="text-[16px] leading-[21px] font-bold text-[#111b21] flex items-center gap-2 truncate group-hover/header:text-[#00a884] transition-colors">
{selectedChat.lead_name || selectedChat.subject || formatPhone(selectedChat.phone, selectedChat.remote_jid)}
</h3>
<div className="flex items-center gap-1 min-w-0">
{isTyping ? (
<span className="text-[13px] text-[#00a884] font-medium animate-pulse">
digitando...
</span>
) : (
<span className="text-[12.5px] text-[#667781] truncate max-w-[300px] font-normal">
{selectedChat.bio || (
selectedChat.remote_jid?.endsWith('@g.us')
? `Clique para dados do grupo`
: 'Clique para informações do contato'
)}
</span>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-1">
{onProtocolClick && (
<button
onClick={onProtocolClick}
className={`p-2 rounded-full transition-all relative ${isProtocolOpen ? 'bg-black/10 text-[#111b21]' : 'hover:bg-black/5 text-[#54656f] hover:text-[#111b21]'}`}
title="Protocolos de atendimento"
>
<ClipboardList className="w-5 h-5" />
{hasActiveProtocol && !isProtocolOpen && (
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-brand-500 border border-white" />
)}
</button>
)}
<button
onClick={onSearchClick}
className="p-2 hover:bg-black/5 rounded-full transition-all text-[#54656f] hover:text-[#111b21]"
title="Procurar na conversa"
>
<Search className="w-5 h-5" />
</button>
<div className="relative">
<button
onClick={onToggleMenu}
className={`p-2 rounded-full transition-all ${isChatMenuOpen ? 'bg-black/10 text-[#111b21]' : 'hover:bg-black/5 text-[#54656f]'}`}
>
<MoreVertical className="w-5 h-5" />
</button>
<AnimatePresence>
{isChatMenuOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="absolute right-0 top-12 w-56 bg-white rounded-lg shadow-2xl border border-gray-100 py-2 z-50 overflow-hidden"
>
<button
onClick={() => {
onSyncAvatar();
onToggleMenu();
}}
disabled={isSyncingAvatar}
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isSyncingAvatar ? 'animate-spin' : ''}`} />
Sincronizar Foto
</button>
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors">
<Tag className="w-4 h-4" />
Gerenciar etiquetas
</button>
<div className="border-t border-gray-100 my-1"></div>
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] font-bold text-red-600 hover:bg-red-50 transition-colors">
<X className="w-4 h-4" />
Limpar conversa
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</header>
);
};
export default InboxHeader;
@@ -0,0 +1,722 @@
'use client';
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import {
Smile,
Paperclip,
Mic,
Send,
X,
StopCircle,
Pause,
Play,
LayoutTemplate,
BookOpen,
Plus,
Sticker,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
// Satélite: emoji picker simples (evita a dep @emoji-mart, incompatível com react 19).
const EMOJI_SET = ['😀','😁','😂','🤣','😊','😍','😘','😎','🤔','😐','😴','😭','😡','👍','👎','🙏','👏','🙌','💪','🔥','🎉','❤️','💔','✅','❌','⚠️','📌','📎','📞','💬','⏰','🥳','😇','🤝','👀','💯'];
import { groupApi, type GroupParticipant, type MessageTemplate, type TemplateType } from '../services/chatApiService';
import RichMessageComposer from './RichMessageComposer';
import TemplateLibrary from './TemplateLibrary';
import ScheduleMessageModal from './ScheduleMessageModal';
import FilePreviewModal from './FilePreviewModal';
import StickerPanel from './StickerPanel';
import { mediaApi } from '../services/chatApiService';
interface Message {
id: number;
chat_id: number;
message_id: string;
text: string;
direction: 'in' | 'out';
}
interface InputBarProps {
newMessage: string;
onNewMessageChange: (val: string) => void;
onSend: (e?: React.FormEvent) => void;
onPresence: () => void;
onSendMedia: (file: File, caption?: string) => Promise<void>;
onSendAudio: (blob: Blob) => Promise<void>;
replyingTo: Message | null;
onCancelReply: () => void;
isMobile: boolean;
selectedChat: any;
instanceId?: string | null;
mentions?: string[];
onMentionsChange?: (mentions: string[]) => void;
}
const InputBar: React.FC<InputBarProps> = ({
newMessage,
onNewMessageChange,
onSend,
onPresence,
onSendMedia,
onSendAudio,
replyingTo,
onCancelReply,
isMobile,
selectedChat,
instanceId,
mentions = [],
onMentionsChange,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [showStickerPanel, setShowStickerPanel] = useState(false);
const [showAttachMenu, setShowAttachMenu] = useState(false);
const stickerPanelRef = useRef<HTMLDivElement>(null);
const [previewFile, setPreviewFile] = useState<File | null>(null);
const attachMenuRef = useRef<HTMLDivElement>(null);
const [showRichComposer, setShowRichComposer] = useState(false);
const [showTemplateLibrary, setShowTemplateLibrary] = useState(false);
const [richInitialPayload, setRichInitialPayload] = useState<Record<string, unknown> | undefined>();
const [richInitialType, setRichInitialType] = useState<TemplateType | undefined>();
const [showScheduleModal, setShowScheduleModal] = useState(false);
const [schedulingPayload, setSchedulingPayload] = useState<Record<string, unknown>>({});
const [schedulingType, setSchedulingType] = useState<TemplateType>('TEXT');
const emojiPickerRef = useRef<HTMLDivElement>(null);
// ── Mention autocomplete state ──────────────────────────────────────
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const [participants, setParticipants] = useState<GroupParticipant[]>([]);
const participantsCache = useRef<Record<string, GroupParticipant[]>>({});
const mentionDropdownRef = useRef<HTMLDivElement>(null);
const isGroupChat = selectedChat?.remote_jid?.endsWith('@g.us') || selectedChat?.jid?.endsWith('@g.us');
const groupJid = selectedChat?.remote_jid || selectedChat?.jid;
// Fetch group participants when group chat is selected
useEffect(() => {
if (!isGroupChat || !instanceId || !groupJid) {
setParticipants([]);
return;
}
if (participantsCache.current[groupJid]) {
setParticipants(participantsCache.current[groupJid]);
return;
}
groupApi.getParticipants(instanceId, groupJid)
.then((data) => {
participantsCache.current[groupJid] = data.participants;
setParticipants(data.participants);
})
.catch(() => setParticipants([]));
}, [isGroupChat, instanceId, groupJid]);
// Filter participants by mention query
const filteredParticipants = useMemo(() => {
if (mentionQuery === null || !isGroupChat) return [];
const q = mentionQuery.toLowerCase();
// Only show participants that have a displayable name or phone
return participants.filter((p) => {
const label = p.name || p.phone || '';
if (!label) return false;
return q === '' || label.toLowerCase().includes(q);
}).slice(0, 8);
}, [mentionQuery, participants, isGroupChat]);
// Detect @ trigger in text
const detectMentionQuery = useCallback((text: string, cursorPos: number) => {
if (!isGroupChat) { setMentionQuery(null); return; }
const before = text.slice(0, cursorPos);
const atMatch = before.match(/@([^\s@]*)$/);
if (atMatch) {
setMentionQuery(atMatch[1]);
setMentionIndex(0);
} else {
setMentionQuery(null);
}
}, [isGroupChat]);
const selectMention = useCallback((participant: GroupParticipant) => {
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const before = newMessage.slice(0, cursorPos);
const after = newMessage.slice(cursorPos);
const atIdx = before.lastIndexOf('@');
if (atIdx === -1) return;
const displayName = participant.name || participant.phone;
const replacement = `@${displayName} `;
const newText = before.slice(0, atIdx) + replacement + after;
onNewMessageChange(newText);
setMentionQuery(null);
// Add JID to mentions list
if (onMentionsChange && !mentions.includes(participant.jid)) {
onMentionsChange([...mentions, participant.jid]);
}
// Restore cursor
requestAnimationFrame(() => {
if (textareaRef.current) {
const newCursorPos = atIdx + replacement.length;
textareaRef.current.selectionStart = newCursorPos;
textareaRef.current.selectionEnd = newCursorPos;
textareaRef.current.focus();
}
});
}, [newMessage, onNewMessageChange, mentions, onMentionsChange]);
// Audio recording states
const [isRecording, setIsRecording] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [recordingDuration, setRecordingDuration] = useState(0);
const [micError, setMicError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const timerRef = useRef<NodeJS.Timeout | null>(null);
// navigator.mediaDevices só existe em contextos seguros (HTTPS ou localhost)
const micAvailable = typeof window !== 'undefined' && !!window.isSecureContext && !!navigator.mediaDevices?.getUserMedia;
// Auto-expand textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 150)}px`;
}
}, [newMessage]);
// Close emoji picker, sticker panel and attach menu on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
setShowEmojiPicker(false);
}
if (stickerPanelRef.current && !stickerPanelRef.current.contains(event.target as Node)) {
setShowStickerPanel(false);
}
if (attachMenuRef.current && !attachMenuRef.current.contains(event.target as Node)) {
setShowAttachMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSendSticker = useCallback(async (blob: Blob) => {
if (!instanceId || !selectedChat?.remote_jid) return;
setShowStickerPanel(false);
await mediaApi.sendSticker(instanceId, selectedChat.remote_jid, blob);
}, [instanceId, selectedChat]);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Mention dropdown navigation
if (mentionQuery !== null && filteredParticipants.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setMentionIndex((prev) => (prev + 1) % filteredParticipants.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setMentionIndex((prev) => (prev - 1 + filteredParticipants.length) % filteredParticipants.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
selectMention(filteredParticipants[mentionIndex]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setMentionQuery(null);
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
};
const onEmojiSelect = (emoji: any) => {
onNewMessageChange(newMessage + (emoji.native ?? ''));
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setPreviewFile(file);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handlePreviewConfirm = async (file: File, caption: string) => {
await onSendMedia(file, caption);
setPreviewFile(null);
};
const handlePreviewCancel = () => setPreviewFile(null);
const startRecording = async () => {
if (!micAvailable) {
setMicError('Áudio requer HTTPS. Acesse via https:// para gravar.');
setTimeout(() => setMicError(null), 4000);
return;
}
setMicError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
const mimeType = mediaRecorder.mimeType || 'audio/webm'
const audioBlob = new Blob(audioChunksRef.current, { type: mimeType });
await onSendAudio(audioBlob);
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start();
setIsRecording(true);
setIsPaused(false);
setRecordingDuration(0);
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err: any) {
const denied = err?.name === 'NotAllowedError' || err?.name === 'PermissionDeniedError';
const msg = denied
? 'Permissão de microfone negada.'
: 'Não foi possível acessar o microfone.';
setMicError(msg);
setTimeout(() => setMicError(null), 4000);
}
};
const togglePause = () => {
const mr = mediaRecorderRef.current
if (!mr) return
if (isPaused) {
mr.resume()
setIsPaused(false)
timerRef.current = setInterval(() => setRecordingDuration(prev => prev + 1), 1000)
} else {
mr.pause()
setIsPaused(true)
if (timerRef.current) clearInterval(timerRef.current)
}
}
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
// Se estava pausado, retoma antes de parar para capturar o último chunk
if (isPaused && mediaRecorderRef.current.state === 'paused') {
mediaRecorderRef.current.resume()
}
mediaRecorderRef.current.stop();
setIsRecording(false);
setIsPaused(false);
if (timerRef.current) clearInterval(timerRef.current);
}
};
const handleTemplateSelect = (template: MessageTemplate) => {
setRichInitialPayload(template.payload)
setRichInitialType(template.type)
setShowRichComposer(true)
}
const handleScheduleFromComposer = (type: TemplateType, payload: Record<string, unknown>) => {
setSchedulingType(type)
setSchedulingPayload(payload)
setShowRichComposer(false)
setShowScheduleModal(true)
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<>
<div className="relative z-20">
{/* Hidden File Input */}
<input
type="file"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
{/* Emoji Picker Popover */}
<AnimatePresence>
{showEmojiPicker && (
<motion.div
ref={emojiPickerRef}
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute bottom-20 left-4 z-50 shadow-2xl rounded-2xl overflow-hidden border border-gray-100"
>
<div className="bg-white w-[280px] p-2 grid grid-cols-8 gap-1">
{EMOJI_SET.map((e) => (
<button
key={e}
type="button"
onClick={() => onEmojiSelect({ native: e })}
className="text-xl leading-none p-1 rounded-lg hover:bg-gray-100"
>
{e}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Sticker Panel Popover */}
<AnimatePresence>
{showStickerPanel && instanceId && (
<motion.div
ref={stickerPanelRef}
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute bottom-20 left-16 z-50"
>
<StickerPanel
instanceId={instanceId}
jid={selectedChat?.remote_jid ?? ''}
onSend={handleSendSticker}
/>
</motion.div>
)}
</AnimatePresence>
{/* Reply Preview */}
<AnimatePresence>
{replyingTo && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="mx-4 mb-2 bg-white/80 backdrop-blur-sm border-l-[4px] border-[#00a884] rounded-lg px-4 py-3 flex items-start justify-between gap-4 shadow-sm"
>
<div className="min-w-0">
<div className="text-[12px] font-medium text-[#00a884] mb-1">
{replyingTo.direction === 'out' ? 'Respondendo a você' : 'Respondendo ao contato'}
</div>
<div className="text-sm text-gray-500 font-medium truncate italic">
{replyingTo.text || 'Anexo/Mídia'}
</div>
</div>
<button
type="button"
className="bg-gray-100 hover:bg-gray-200 text-gray-500 p-1 rounded-full transition-colors"
onClick={onCancelReply}
>
<X className="w-3.5 h-3.5 stroke-[3px]" />
</button>
</motion.div>
)}
</AnimatePresence>
{/* Mic error banner */}
<AnimatePresence>
{micError && (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.18 }}
className="mx-4 mb-1 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2"
>
<Mic className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
<span className="text-xs text-amber-700">{micError}</span>
</motion.div>
)}
</AnimatePresence>
{/* Main Input Bar */}
<div className="min-h-[62px] px-4 py-2 bg-transparent flex items-center gap-2 shrink-0">
{!isRecording ? (
<>
<div className="flex items-center gap-1">
{/* Grupo flutuante: + expande Anexar / Templates / Mensagem Rica */}
<div className="relative" ref={attachMenuRef}>
<button
onClick={() => setShowAttachMenu(v => !v)}
className={`p-2 rounded-full transition-all active:scale-95 ${showAttachMenu ? 'bg-[#00a884] text-white' : 'hover:bg-black/10 text-[#54656f]'}`}
title="Mais opções"
>
<motion.div
animate={{ rotate: showAttachMenu ? 45 : 0 }}
transition={{ duration: 0.18 }}
>
<Plus className="w-[26px] h-[26px]" />
</motion.div>
</button>
<AnimatePresence>
{showAttachMenu && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.92 }}
transition={{ duration: 0.15 }}
className="absolute bottom-full left-0 mb-2 flex flex-col items-center gap-1 bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 px-1.5"
>
{/* Anexar arquivo */}
<button
onClick={() => { fileInputRef.current?.click(); setShowAttachMenu(false); }}
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
title="Anexar arquivo"
>
<Paperclip className="w-[22px] h-[22px] rotate-45" />
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
Anexar
</span>
</button>
{/* Biblioteca de templates */}
<button
onClick={() => { setShowTemplateLibrary(true); setShowAttachMenu(false); }}
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
title="Templates salvos"
>
<BookOpen className="w-[22px] h-[22px]" />
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
Templates
</span>
</button>
{/* Mensagem rica */}
<button
onClick={() => { setRichInitialPayload(undefined); setRichInitialType(undefined); setShowRichComposer(true); setShowAttachMenu(false); }}
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
title="Mensagem rica"
>
<LayoutTemplate className="w-[22px] h-[22px]" />
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
Mensagem Rica
</span>
</button>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Emoji — mantém no lugar */}
<button
onClick={() => { setShowEmojiPicker(!showEmojiPicker); setShowStickerPanel(false); }}
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showEmojiPicker ? 'text-[#00a884]' : 'text-[#54656f]'}`}
title="Emojis"
>
<Smile className="w-[26px] h-[26px]" />
</button>
{/* Figurinhas */}
{instanceId && (
<button
onClick={() => { setShowStickerPanel(!showStickerPanel); setShowEmojiPicker(false); }}
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showStickerPanel ? 'text-[#00a884]' : 'text-[#54656f]'}`}
title="Figurinhas"
>
<Sticker className="w-[24px] h-[24px]" />
</button>
)}
</div>
<div className="flex-1 relative">
{/* Mention Autocomplete Dropdown */}
<AnimatePresence>
{mentionQuery !== null && filteredParticipants.length > 0 && (
<motion.div
ref={mentionDropdownRef}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.12 }}
className="absolute bottom-full left-0 right-0 mb-1 bg-white rounded-lg shadow-xl border border-gray-200 max-h-[240px] overflow-y-auto z-50"
>
{filteredParticipants.map((p, i) => (
<button
key={p.jid}
type="button"
className={`w-full px-3 py-2 flex items-center gap-2 text-left text-sm hover:bg-[#f0f2f5] transition-colors ${
i === mentionIndex ? 'bg-[#e9edef]' : ''
}`}
onMouseDown={(e) => {
e.preventDefault();
selectMention(p);
}}
onMouseEnter={() => setMentionIndex(i)}
>
<div className="w-7 h-7 rounded-full bg-[#dfe5e7] flex items-center justify-center text-xs font-bold text-[#54656f] shrink-0">
{(p.name || p.phone || '?')[0].toUpperCase()}
</div>
<div className="min-w-0">
<div className="font-medium text-[#111b21] truncate">{p.name || p.phone}</div>
{p.name && <div className="text-[11px] text-[#667781] truncate">+{p.phone}</div>}
</div>
{p.admin && (
<span className="ml-auto text-[10px] bg-[#e7f8f2] text-[#00a884] px-1.5 py-0.5 rounded font-medium shrink-0">
{p.admin === 'superadmin' ? 'Admin' : 'Admin'}
</span>
)}
</button>
))}
</motion.div>
)}
</AnimatePresence>
<label htmlFor="message-textarea" className="sr-only">Digite sua mensagem</label>
<textarea
id="message-textarea"
name="message-textarea"
ref={textareaRef}
rows={1}
placeholder="Digite uma mensagem"
className="w-full px-3 py-2.5 bg-white rounded-lg text-[15px] text-[#111b21] border-none focus:ring-0 focus:outline-none placeholder:text-[#667781] resize-none shadow-sm transition-all overflow-hidden leading-[1.4]"
value={newMessage}
onChange={(e) => {
onNewMessageChange(e.target.value);
onPresence();
detectMentionQuery(e.target.value, e.target.selectionStart);
}}
onKeyDown={handleKeyDown}
/>
</div>
<div className="transition-all flex items-center">
{newMessage.trim() ? (
<motion.button
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
onClick={() => onSend()}
className="p-2 text-[#54656f] hover:bg-black/10 rounded-full transition-all active:scale-90"
>
<Send className="w-[26px] h-[26px]" />
</motion.button>
) : (
<button
onClick={startRecording}
title={!micAvailable ? 'Requer HTTPS para gravar áudio' : 'Gravar áudio'}
className={`p-2 rounded-full transition-all active:scale-90 ${
!micAvailable
? 'text-[#aab7bf] cursor-not-allowed opacity-50'
: 'hover:bg-black/10 text-[#54656f]'
}`}
>
<Mic className="w-[26px] h-[26px]" />
</button>
)}
</div>
</>
) : (
<div className={`flex-1 flex items-center justify-between rounded-2xl px-4 py-2 border shadow-inner ${isPaused ? 'bg-amber-50/70 border-amber-200' : 'bg-red-50/50 border-red-100'}`}>
{/* Indicador + tempo */}
<div className="flex items-center gap-3">
{isPaused ? (
<div className="w-3 h-3 bg-amber-400 rounded-full" />
) : (
<div className="w-3 h-3 bg-red-500 rounded-full animate-ping" />
)}
<span className={`text-sm font-bold tracking-wider ${isPaused ? 'text-amber-600' : 'text-red-600'}`}>
{isPaused ? 'PAUSADO' : 'GRAVANDO'} {formatDuration(recordingDuration)}
</span>
</div>
{/* Controles */}
<div className="flex items-center gap-1.5">
{/* Cancelar */}
<button
onClick={() => {
const mr = mediaRecorderRef.current
if (mr) {
mr.onstop = null;
mr.stream?.getTracks().forEach(t => t.stop());
if (mr.state !== 'inactive') mr.stop();
setIsRecording(false);
setIsPaused(false);
if (timerRef.current) clearInterval(timerRef.current);
}
}}
className="p-2 hover:bg-red-100 rounded-full text-red-400 transition-colors"
title="Cancelar"
>
<X className="w-5 h-5" />
</button>
{/* Pause / Resume */}
<button
onClick={togglePause}
className="p-2 hover:bg-black/10 rounded-full text-[#54656f] transition-colors"
title={isPaused ? 'Continuar gravação' : 'Pausar gravação'}
>
{isPaused
? <Play className="w-5 h-5 fill-current text-amber-500" />
: <Pause className="w-5 h-5 fill-current" />
}
</button>
{/* Parar e Enviar */}
<button
onClick={stopRecording}
className="p-2 bg-red-500 hover:bg-red-600 text-white rounded-full transition-all shadow-md active:scale-90"
title="Parar e Enviar"
>
<StopCircle className="w-5 h-5 fill-current" />
</button>
</div>
</div>
)}
</div>
</div>
{/* ── Modal de preview de arquivo ─────────────────────── */}
<FilePreviewModal
file={previewFile}
onConfirm={handlePreviewConfirm}
onCancel={handlePreviewCancel}
/>
{/* ── Modais de mensagem rica ────────────────────────────── */}
<RichMessageComposer
isOpen={showRichComposer}
onClose={() => setShowRichComposer(false)}
instanceId={instanceId ?? null}
jid={selectedChat?.remote_jid ?? selectedChat?.jid ?? ''}
onSent={() => setShowRichComposer(false)}
onSchedule={handleScheduleFromComposer}
initialPayload={richInitialPayload}
initialType={richInitialType}
/>
<TemplateLibrary
isOpen={showTemplateLibrary}
onClose={() => setShowTemplateLibrary(false)}
onSelect={handleTemplateSelect}
/>
<ScheduleMessageModal
isOpen={showScheduleModal}
onClose={() => setShowScheduleModal(false)}
payload={schedulingPayload}
messageType={schedulingType}
onSaved={() => setShowScheduleModal(false)}
/>
</>
);
};
export default InputBar;
@@ -0,0 +1,143 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Globe } from 'lucide-react';
// Satélite: a ext API não expõe /link-preview — stub que rejeita (o componente
// cai para "sem preview" no catch, sem quebrar a renderização).
const api = { get: (_url: string): Promise<any> => Promise.reject(new Error('no link-preview')) };
interface PreviewData {
title: string;
description: string;
image: string;
url: string;
domain: string;
}
// Simple in-memory cache so the same URL is only fetched once per page load
const cache = new Map<string, PreviewData | null>();
interface LinkPreviewProps {
url: string;
isOut: boolean;
}
const LinkPreview: React.FC<LinkPreviewProps> = ({ url, isOut }) => {
const [data, setData] = useState<PreviewData | null>(cache.has(url) ? cache.get(url)! : undefined as any);
const [loading, setLoading] = useState(!cache.has(url));
useEffect(() => {
if (cache.has(url)) {
setData(cache.get(url) ?? null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
api.get(`/link-preview?url=${encodeURIComponent(url)}`)
.then((d: PreviewData) => {
if (cancelled) return;
const value = d?.title ? d : null;
cache.set(url, value);
setData(value);
})
.catch(() => {
if (!cancelled) {
cache.set(url, null);
setData(null);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [url]);
if (loading) {
return (
<div className={`mt-2 rounded-xl overflow-hidden border animate-pulse ${isOut ? 'border-green-200' : 'border-gray-100'}`}>
<div className={`h-24 ${isOut ? 'bg-green-100' : 'bg-gray-100'}`} />
<div className="p-2.5 space-y-1.5">
<div className={`h-2 rounded w-1/3 ${isOut ? 'bg-green-200' : 'bg-gray-200'}`} />
<div className={`h-3 rounded w-4/5 ${isOut ? 'bg-green-200' : 'bg-gray-200'}`} />
<div className={`h-2 rounded w-3/5 ${isOut ? 'bg-green-100' : 'bg-gray-100'}`} />
</div>
</div>
);
}
if (!data) return null;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className={`mt-2 block rounded-xl overflow-hidden border transition-all hover:shadow-md active:scale-[0.98] no-underline ${isOut
? 'border-green-200 bg-white/70 hover:bg-white/90'
: 'border-gray-200 bg-gray-50 hover:bg-white'
}`}
>
{data.image && (
<img
src={data.image}
alt={data.title}
className="w-full max-h-[160px] object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div className="px-3 py-2.5">
<div className="flex items-center gap-1 text-[10px] text-gray-400 font-bold uppercase tracking-wider mb-1">
<Globe className="w-3 h-3 shrink-0" />
<span className="truncate">{data.domain}</span>
</div>
{data.title && (
<p className="text-[13px] font-bold text-gray-800 leading-tight line-clamp-2">
{data.title}
</p>
)}
{data.description && (
<p className="text-[11px] text-gray-500 mt-0.5 leading-snug line-clamp-2">
{data.description}
</p>
)}
</div>
</a>
);
};
// URL extraction helper (exported for use in MessageBubble)
const URL_RE = /https?:\/\/[^\s<>"{}|\\^`[\]]{4,}/;
export const extractFirstUrl = (text: string | null | undefined): string | null => {
if (!text) return null;
const m = text.match(URL_RE);
return m ? m[0] : null;
};
// Renders plain text with URLs converted to clickable <a> tags
export const renderTextWithLinks = (text: string): React.ReactNode[] => {
const parts = text.split(/(https?:\/\/[^\s<>"{}|\\^`[\]]{4,})/g);
return parts.map((part, i) => {
if (/^https?:\/\//.test(part)) {
return (
<a
key={i}
href={part}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-blue-600 underline break-all hover:text-blue-700"
>
{part}
</a>
);
}
return <span key={i}>{part}</span>;
});
};
export default LinkPreview;
@@ -0,0 +1,260 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
X,
Download,
Reply,
Star,
ZoomIn,
ZoomOut,
ChevronLeft,
ChevronRight,
Video,
} from 'lucide-react';
import { Message } from '../types/inboxTypes';
import { getMediaUrl } from '../utils/inboxUtils';
import { format } from 'date-fns';
import { parseSafeDate } from '../utils/inboxUtils';
interface MediaViewerProps {
msgs: Message[]
currentMsgId: string
onClose: () => void
onReply?: (msg: Message) => void
onStar?: (msg: Message) => void
}
const MediaViewer: React.FC<MediaViewerProps> = ({ msgs, currentMsgId, onClose, onReply, onStar }) => {
const [currentIdx, setCurrentIdx] = useState(() => {
const idx = msgs.findIndex(m => m.message_id === currentMsgId)
return idx >= 0 ? idx : 0
})
const [zoom, setZoom] = useState(1)
const filmstripRef = useRef<HTMLDivElement>(null)
const currentMsg = msgs[currentIdx]
const currentUrl = getMediaUrl(currentMsg?.media_url)
const goNext = useCallback(() => {
setCurrentIdx(i => Math.min(i + 1, msgs.length - 1))
setZoom(1)
}, [msgs.length])
const goPrev = useCallback(() => {
setCurrentIdx(i => Math.max(i - 1, 0))
setZoom(1)
}, [])
// Keyboard: ← → Esc
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowRight') goNext()
if (e.key === 'ArrowLeft') goPrev()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose, goNext, goPrev])
// Scroll filmstrip to keep current thumbnail visible
useEffect(() => {
const el = filmstripRef.current
if (!el) return
const thumb = el.children[currentIdx] as HTMLElement
if (thumb) thumb.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' })
}, [currentIdx])
if (!currentMsg) return null
const isVideo = currentMsg.type === 'video'
const isImage = currentMsg.type === 'image'
const caption = currentMsg.text?.trim() || null
const timeStr = currentMsg.timestamp ? format(parseSafeDate(currentMsg.timestamp), 'dd/MM/yyyy HH:mm') : ''
const senderLabel = currentMsg.direction === 'out' ? 'Você' : 'Contato'
const viewer = (
<div className="fixed inset-0 z-[9999] flex flex-col bg-[#111b21]">
{/* ── Top bar ─────────────────────────────────────────── */}
<div className="flex items-center justify-between px-2 py-1.5 bg-[#202c33] shrink-0">
{/* Left: back + sender info */}
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Fechar"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="leading-tight">
<div className="text-white text-[13.5px] font-medium">{senderLabel}</div>
<div className="text-[#aebac1] text-[11px]">{timeStr}</div>
</div>
</div>
{/* Right: actions */}
<div className="flex items-center">
{isImage && (
<>
<button
onClick={() => setZoom(z => Math.min(z + 0.3, 4))}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Aumentar zoom"
>
<ZoomIn className="w-5 h-5" />
</button>
<button
onClick={() => setZoom(z => Math.max(z - 0.3, 0.4))}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Diminuir zoom"
>
<ZoomOut className="w-5 h-5" />
</button>
</>
)}
<button
onClick={() => { onReply?.(currentMsg); onClose(); }}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Responder"
>
<Reply className="w-5 h-5" />
</button>
<button
onClick={() => onStar?.(currentMsg)}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Favoritar"
>
<Star className="w-5 h-5" />
</button>
{currentUrl && (
<a
href={currentUrl}
download
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Baixar"
>
<Download className="w-5 h-5" />
</a>
)}
<button
onClick={onClose}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Fechar"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* ── Main content ─────────────────────────────────────── */}
<div
className="flex-1 flex items-center justify-center relative overflow-hidden"
onClick={onClose}
>
{/* Left arrow */}
{currentIdx > 0 && (
<button
onClick={e => { e.stopPropagation(); goPrev() }}
className="absolute left-4 z-10 w-11 h-11 bg-black/50 hover:bg-black/80 text-white rounded-full flex items-center justify-center transition-colors shadow-lg"
>
<ChevronLeft className="w-6 h-6" />
</button>
)}
{/* Media */}
<div className="max-w-full max-h-full flex items-center justify-center" onClick={e => e.stopPropagation()}>
{!currentUrl ? (
<div className="text-[#aebac1] text-sm">Mídia não disponível</div>
) : isVideo ? (
<video
src={currentUrl}
controls
autoPlay
className="max-w-full rounded"
style={{ maxHeight: 'calc(100vh - 160px)' }}
/>
) : (
<img
src={currentUrl}
alt={caption || 'Imagem'}
draggable={false}
className="object-contain select-none rounded"
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 160px)',
transform: `scale(${zoom})`,
transition: 'transform 0.18s ease',
cursor: zoom > 1 ? 'zoom-out' : 'default',
}}
onClick={e => { e.stopPropagation(); if (zoom > 1) setZoom(1) }}
/>
)}
</div>
{/* Right arrow */}
{currentIdx < msgs.length - 1 && (
<button
onClick={e => { e.stopPropagation(); goNext() }}
className="absolute right-4 z-10 w-11 h-11 bg-black/50 hover:bg-black/80 text-white rounded-full flex items-center justify-center transition-colors shadow-lg"
>
<ChevronRight className="w-6 h-6" />
</button>
)}
{/* Caption */}
{caption && (
<div className="absolute bottom-2 inset-x-0 flex justify-center pointer-events-none">
<span className="bg-black/60 text-white text-[13px] px-4 py-1.5 rounded-lg max-w-[70%] text-center leading-snug">
{caption}
</span>
</div>
)}
</div>
{/* ── Bottom filmstrip ─────────────────────────────────── */}
{msgs.length > 1 && (
<div className="shrink-0 bg-[#202c33] py-2 px-3">
<div
ref={filmstripRef}
className="flex gap-1.5 overflow-x-auto items-center"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{msgs.map((m, i) => {
const url = getMediaUrl(m.media_url)
const isVid = m.type === 'video'
const isActive = i === currentIdx
return (
<button
key={m.message_id}
onClick={() => { setCurrentIdx(i); setZoom(1) }}
className={`relative shrink-0 w-[58px] h-[58px] rounded overflow-hidden border-2 transition-all ${
isActive
? 'border-white opacity-100'
: 'border-transparent opacity-50 hover:opacity-80'
}`}
>
{url ? (
isVid ? (
<div className="w-full h-full bg-black flex items-center justify-center">
<Video className="w-5 h-5 text-white fill-white" />
</div>
) : (
<img src={url} alt="" className="w-full h-full object-cover" />
)
) : (
<div className="w-full h-full bg-[#2a3942]" />
)}
</button>
)
})}
</div>
</div>
)}
</div>
)
return typeof document !== 'undefined' ? createPortal(viewer, document.body) : null
}
export default MediaViewer
@@ -0,0 +1,277 @@
'use client';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { ChevronDown, ArrowDown } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import MessageBubble from './MessageBubble';
import MediaViewer from './MediaViewer';
import { isSameDay, format, isToday, isYesterday } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { normalizeJid, parseSafeDate } from '../utils/inboxUtils';
import { Message } from '../types/inboxTypes';
import { mediaApi } from '../services/chatApiService';
import { useChatStore } from '../store/chatStore';
// Message interface removed and moved to types.ts
interface MessageAreaProps {
messages: Message[];
selectedChat: any;
scrollRef: React.RefObject<HTMLDivElement>;
onScroll: () => void;
msgLoading: boolean;
historyLoadingMore: boolean;
hasMoreHistory: boolean;
reactionPickerFor: string | null;
onReply: (msg: Message) => void;
onToggleReactionPicker: (msgId: string) => void;
onReact: (msg: Message, emoji: string) => void;
onForward?: (msg: Message) => void;
onDelete?: (msg: Message) => void;
onStar?: (msg: Message) => void;
onEdit?: (msg: Message) => void;
quickReactions: string[];
getGroupSenderLabel: (msg: Message) => string;
canDelete?: boolean;
}
const MessageArea: React.FC<MessageAreaProps> = ({
messages,
selectedChat,
scrollRef,
onScroll,
msgLoading,
historyLoadingMore,
hasMoreHistory,
reactionPickerFor,
onReply,
onToggleReactionPicker,
onReact,
onForward,
onDelete,
onStar,
onEdit,
quickReactions,
getGroupSenderLabel,
canDelete = true,
}) => {
const [showScrollBottom, setShowScrollBottom] = useState(false);
const lastScrollTop = useRef(0);
const needsInitialScroll = useRef(false);
// ── Media viewer state ────────────────────────────────────────────────────
const [viewerMsgId, setViewerMsgId] = useState<string | null>(null)
// ── Re-download de mídia não baixada ─────────────────────────────────────
// ~95% das mídias vêm com mediaUrl NULL (lazy). O motor re-baixa do WhatsApp
// e devolve { mediaUrl }; atualizamos o store direto pelo retorno (o WS
// message:media_ready não trafega pela ponte ext do satélite).
const updateMediaReady = useChatStore((s) => s.updateMediaReady)
const handleRedownloadMedia = useCallback(async (msg: Message) => {
// selectedChat no formato legado usa instance_name (não instanceId)
const instanceId = selectedChat?.instance_name ?? selectedChat?.instanceId
if (!instanceId || !msg.id) return
const { mediaUrl } = await mediaApi.redownload(instanceId, String(msg.id))
if (mediaUrl) updateMediaReady(String(msg.id), mediaUrl)
}, [selectedChat?.instance_name, selectedChat?.instanceId, updateMediaReady])
const mediaMessages = messages.filter(m => m.type === 'image' || m.type === 'video')
const handleInternalScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
setShowScrollBottom(!isAtBottom);
// Trigger parent onScroll (for infinite loading etc)
onScroll();
lastScrollTop.current = el.scrollTop;
}, [onScroll, scrollRef]);
const scrollToBottom = (smooth = true) => {
const el = scrollRef.current;
if (el) {
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'instant' });
}
};
const formatDateSeparator = (date: Date) => {
try {
if (isToday(date)) return 'Hoje';
if (isYesterday(date)) return 'Ontem';
return format(date, "d 'de' MMMM 'de' yyyy", { locale: ptBR });
} catch (e) {
return 'Histórico';
}
};
const isGroupChat = Boolean(selectedChat?.remote_jid?.endsWith('@g.us'));
const chatId = selectedChat?.remote_jid ?? null;
// Marca que precisa scroll ao fundo quando trocar de chat
useEffect(() => {
needsInitialScroll.current = true;
}, [chatId]);
// Auto-scroll: roda sempre que messages muda
useEffect(() => {
const el = scrollRef.current;
if (!el || messages.length === 0) return;
if (needsInitialScroll.current) {
// Carga inicial após selectChat → scroll instantâneo ao fundo
needsInitialScroll.current = false;
// Triplo rAF: aguarda React render + layout + paint
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
});
});
} else {
// Nova mensagem em tempo real → só rola se já estiver perto do fundo
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 250;
if (isNearBottom) {
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
});
}
}
}, [messages, scrollRef]);
return (
<>
<div className="flex-1 relative flex flex-col min-h-0 bg-transparent overflow-hidden">
<div
className="flex-1 overflow-y-auto px-4 md:px-14 py-6 flex flex-col custom-scrollbar relative z-10"
ref={scrollRef}
onScroll={handleInternalScroll}
>
{/* Loader for history */}
{historyLoadingMore && (
<div className="flex justify-center py-4">
<div className="w-5 h-5 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" />
</div>
)}
{(() => {
const seen = new Set<string>();
return messages
.filter(m => {
// Reactions não são mensagens visuais — são metadados
if (m.type === 'reaction') return false;
const key = m.message_id || m.id?.toString();
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
})
.map((msg, idx) => {
const msgDate = parseSafeDate(msg.timestamp);
const prevMsg = messages[idx - 1];
const prevDate = prevMsg ? parseSafeDate(prevMsg.timestamp) : null;
const showDateSeparator = !prevDate || !isSameDay(prevDate, msgDate);
// Authentic WA Tail Logic: Show tail on the first message of a sequence from the same sender
const isSameSenderAsPrev = prevMsg && (prevMsg.direction === msg.direction);
const showTail = showDateSeparator || !isSameSenderAsPrev;
const senderLabel = isGroupChat ? getGroupSenderLabel(msg) : '';
// Resolve Quoted Sender
let quotedSenderLabel = 'Mensagem';
if (msg.quoted_participant) {
const qp = normalizeJid(msg.quoted_participant);
const chatJid = normalizeJid(selectedChat?.remote_jid || '');
if (qp === chatJid) quotedSenderLabel = selectedChat?.subject || 'Contato';
else quotedSenderLabel = 'Você';
}
return (
<React.Fragment key={msg.message_id || msg.id}>
{showDateSeparator && (
<div className="flex justify-center my-6">
<span className="px-3 py-1 bg-white text-[#54656f] text-[12.5px] font-normal uppercase tracking-tight rounded-lg shadow-sm border border-[#d1d7db]">
{formatDateSeparator(msgDate)}
</span>
</div>
)}
<MessageBubble
msg={msg}
isGroupChat={isGroupChat}
senderLabel={senderLabel}
showTail={showTail}
quotedSenderLabel={quotedSenderLabel}
quotedPreviewText={msg.quoted_message_text || 'Mídia'}
reactionPickerFor={reactionPickerFor}
onReply={onReply}
onToggleReactionPicker={onToggleReactionPicker}
onReact={onReact}
onForward={onForward}
onDelete={onDelete}
canDelete={canDelete}
onStar={onStar}
onEdit={onEdit}
quickReactions={quickReactions}
selectedChat={selectedChat}
onOpenMedia={(m) => setViewerMsgId(m.message_id)}
onRedownloadMedia={handleRedownloadMedia}
/>
</React.Fragment>
);
});
})()}
{/* Bottom Anchor */}
<div className="h-2 w-full shrink-0" />
</div>
{/* Scroll to Bottom Button */}
<AnimatePresence>
{showScrollBottom && (
<motion.button
initial={{ opacity: 0, scale: 0.8, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 10 }}
onClick={() => scrollToBottom()}
className="absolute bottom-6 right-6 w-11 h-11 bg-white text-[#54656f] rounded-full shadow-lg flex items-center justify-center hover:bg-[#f0f2f5] transition-all z-30 border border-[#d1d7db] active:scale-90"
>
<ArrowDown className="w-5 h-5" />
{selectedChat?.unread_count > 0 && (
<span className="absolute -top-1 -right-1 bg-[#00a884] text-white text-[10px] min-w-[20px] h-5 px-1 flex items-center justify-center rounded-full font-bold border-2 border-white shadow-sm">
{selectedChat.unread_count}
</span>
)}
</motion.button>
)}
</AnimatePresence>
{/* Initial Loading Overlay */}
{msgLoading && messages.length === 0 && (
<div className="absolute inset-0 bg-white/50 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-[3px] border-[#00a884]/20 border-t-[#00a884] rounded-full animate-spin" />
<span className="text-[12px] font-medium text-[#667781]">Carregando mensagens...</span>
</div>
</div>
)}
</div>
{/* ── Media viewer portal ───────────────────────────────────────────── */}
{viewerMsgId && mediaMessages.length > 0 && (
<MediaViewer
msgs={mediaMessages}
currentMsgId={viewerMsgId}
onClose={() => setViewerMsgId(null)}
onReply={onReply}
onStar={onStar}
/>
)}
</>
);
};
export default MessageArea;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,281 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { X, Phone, Send, ChevronDown, Check } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
interface Instance {
id: string;
nome: string;
avatar: string | null;
phone: string | null;
}
interface NewConversationModalProps {
isOpen: boolean;
onClose: () => void;
onSent?: (phone: string, instanceId: string) => void;
activeInstance: Instance | null;
connectedInstances: Instance[];
sendMessage: (instanceId: string, phone: string, text: string) => Promise<{ ok: boolean; messageId?: string }>;
}
function normalizePhoneForSend(input: string): string {
const digits = input.replace(/\D/g, '');
// Already has Brazil country code: 55 + DDD (2) + number (8-9) = 12-13 digits
if (digits.startsWith('55') && (digits.length === 12 || digits.length === 13)) return digits;
// Local Brazilian number: DDD (2) + number (8-9) = 10-11 digits
if (digits.length === 10 || digits.length === 11) return '55' + digits;
// Return as-is (international or unusual)
return digits;
}
export default function NewConversationModal({
isOpen,
onClose,
onSent,
activeInstance,
connectedInstances,
sendMessage,
}: NewConversationModalProps) {
const [phone, setPhone] = useState('');
const [message, setMessage] = useState('');
const [selectedInstanceId, setSelectedInstanceId] = useState('');
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
const phoneInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setPhone('');
setMessage('');
setError('');
setSent(false);
setSending(false);
setSelectedInstanceId(activeInstance?.id || connectedInstances[0]?.id || '');
setTimeout(() => phoneInputRef.current?.focus(), 120);
}
}, [isOpen, activeInstance?.id, connectedInstances]);
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPhone(e.target.value.replace(/[^\d\s\-()+]/g, ''));
setError('');
};
const handleSubmit = async () => {
const normalized = normalizePhoneForSend(phone);
const digits = normalized.replace(/\D/g, '');
if (digits.length < 10) {
setError('Número inválido. Informe DDD + número (ex: 67 99922-2377)');
phoneInputRef.current?.focus();
return;
}
if (!message.trim()) {
setError('Escreva uma mensagem para iniciar a conversa');
textareaRef.current?.focus();
return;
}
if (!selectedInstanceId) {
setError('Nenhuma instância conectada');
return;
}
setSending(true);
setError('');
try {
const result = await sendMessage(selectedInstanceId, normalized, message.trim());
if (!result.ok) throw new Error('Envio falhou');
setSent(true);
// Brief success state, then close and notify parent
setTimeout(() => {
onSent?.(normalized, selectedInstanceId);
onClose();
}, 1300);
} catch (err: any) {
setError(err?.message || 'Erro ao enviar. Verifique o número e tente novamente.');
setSending(false);
}
};
const handleTextareaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmit();
}
};
const handlePhoneKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') textareaRef.current?.focus();
};
const canSend = !!phone.trim() && !!message.trim() && !!selectedInstanceId && !sending && !sent;
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={!sending ? onClose : undefined}
/>
{/* Modal */}
<motion.div
initial={{ scale: 0.92, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.92, opacity: 0, y: 16 }}
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
className="relative w-full max-w-md bg-white rounded-2xl shadow-2xl flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 bg-[#008069] text-white">
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-white/20 rounded-full flex items-center justify-center">
<Phone className="w-5 h-5" />
</div>
<div>
<h2 className="text-base font-semibold leading-tight">Nova Conversa</h2>
<p className="text-[11px] text-white/70 leading-tight">Enviar primeira mensagem</p>
</div>
</div>
<button
onClick={onClose}
disabled={sending}
className="p-1.5 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-5">
{/* Instance selector — only if multiple instances connected */}
{connectedInstances.length > 1 && (
<div>
<label className="block text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
Enviar como
</label>
<div className="relative">
<select
value={selectedInstanceId}
onChange={(e) => setSelectedInstanceId(e.target.value)}
disabled={sending || sent}
className="w-full appearance-none bg-[#f0f2f5] rounded-xl px-4 py-2.5 pr-10 text-sm text-[#111b21] font-medium focus:outline-none focus:ring-2 focus:ring-[#008069]/40 disabled:opacity-60"
>
{connectedInstances.map(inst => (
<option key={inst.id} value={inst.id}>
{inst.nome}{inst.phone ? ` (+${inst.phone})` : ''}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
</div>
)}
{/* Phone input */}
<div>
<label className="block text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
Número do WhatsApp
</label>
<div className="flex items-center gap-2 bg-[#f0f2f5] rounded-xl px-4 py-2.5 focus-within:ring-2 focus-within:ring-[#008069]/40 transition-all">
<span className="text-gray-400 text-sm select-none shrink-0">🇧🇷 +55</span>
<div className="w-px h-5 bg-gray-300 shrink-0" />
<input
ref={phoneInputRef}
type="tel"
placeholder="(67) 99922-2377"
value={phone}
onChange={handlePhoneChange}
onKeyDown={handlePhoneKeyDown}
disabled={sending || sent}
className="flex-1 bg-transparent text-sm text-[#111b21] placeholder:text-[#8696a0] focus:outline-none disabled:opacity-60 min-w-0"
/>
</div>
</div>
{/* Message textarea */}
<div>
<label className="block text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
Mensagem
</label>
<textarea
ref={textareaRef}
rows={3}
placeholder="Olá! Tudo bem?"
value={message}
onChange={(e) => { setMessage(e.target.value); setError(''); }}
onKeyDown={handleTextareaKeyDown}
disabled={sending || sent}
className="w-full bg-[#f0f2f5] rounded-xl px-4 py-3 text-sm text-[#111b21] placeholder:text-[#8696a0] focus:outline-none focus:ring-2 focus:ring-[#008069]/40 resize-none transition-all disabled:opacity-60"
/>
<p className="text-[11px] text-gray-400 mt-1 text-right">Ctrl+Enter para enviar</p>
</div>
{/* Error */}
<AnimatePresence>
{error && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="bg-red-50 border border-red-200 text-red-600 text-sm rounded-xl px-4 py-2.5"
>
{error}
</motion.p>
)}
</AnimatePresence>
</div>
{/* Footer */}
<div className="px-6 pb-6 flex items-center justify-between">
<button
onClick={onClose}
disabled={sending}
className="px-5 py-2.5 text-sm font-semibold text-gray-500 hover:bg-gray-100 rounded-xl transition-colors disabled:opacity-50"
>
Cancelar
</button>
<button
onClick={handleSubmit}
disabled={!canSend}
className={`flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed ${
sent
? 'bg-green-500'
: sending
? 'bg-[#008069]/80'
: 'bg-[#008069] hover:bg-[#017259]'
}`}
>
{sent ? (
<>
<Check className="w-4 h-4" />
Enviado!
</>
) : sending ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Enviando...
</>
) : (
<>
<Send className="w-4 h-4" />
Enviar
</>
)}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,380 @@
'use client';
import React from 'react';
import {
X, Plus, ClipboardList, ChevronDown, Loader2,
CheckCircle2, Clock, AlertCircle, Ban, Users, Layers,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import type { Protocol, ProtocolStatus } from '../hooks/useProtocolPanel';
import Toast from './ui/Toast';
// ─── Status config ────────────────────────────────────────────────────────────
type StatusConfig = {
label: string
color: string
bg: string
icon: React.ReactNode
}
const STATUS: Record<ProtocolStatus, StatusConfig> = {
OPEN: { label: 'Aberto', color: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20', icon: <Clock className="w-3 h-3" /> },
IN_PROGRESS: { label: 'Em Andamento', color: 'text-brand-400', bg: 'bg-brand-500/10 border-brand-500/20', icon: <AlertCircle className="w-3 h-3" /> },
WAITING_CLIENT: { label: 'Aguardando Cliente', color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/20', icon: <Clock className="w-3 h-3" /> },
WAITING_AGENT: { label: 'Aguardando Agente', color: 'text-orange-400', bg: 'bg-orange-500/10 border-orange-500/20', icon: <Clock className="w-3 h-3" /> },
RESOLVED: { label: 'Resolvido', color: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20', icon: <CheckCircle2 className="w-3 h-3" /> },
CANCELLED: { label: 'Cancelado', color: 'text-slate-400', bg: 'bg-slate-500/10 border-slate-500/20', icon: <Ban className="w-3 h-3" /> },
}
const TRANSITIONS: Record<ProtocolStatus, ProtocolStatus[]> = {
OPEN: ['IN_PROGRESS', 'CANCELLED'],
IN_PROGRESS: ['WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED'],
WAITING_CLIENT: ['IN_PROGRESS', 'RESOLVED', 'CANCELLED'],
WAITING_AGENT: ['IN_PROGRESS', 'RESOLVED', 'CANCELLED'],
RESOLVED: [],
CANCELLED: [],
}
function StatusBadge({ status }: { status: ProtocolStatus }) {
const cfg = STATUS[status]
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-bold border ${cfg.color} ${cfg.bg}`}>
{cfg.icon}
{cfg.label}
</span>
)
}
// ─── Protocol card ────────────────────────────────────────────────────────────
interface ProtocolCardProps {
protocol: Protocol
isActive: boolean
updatingId: string | null
onStatusChange: (protocol: Protocol, status: ProtocolStatus) => void
}
function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: ProtocolCardProps) {
const [showActions, setShowActions] = React.useState(false)
const transitions = TRANSITIONS[protocol.status]
const isUpdating = updatingId === protocol.id
return (
<div className={`rounded-xl border p-4 space-y-3 ${isActive ? 'border-brand-500/30 bg-brand-500/5' : 'border-white/5 bg-white/[0.02]'}`}>
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2">
<span className="text-white font-bold text-sm">{protocol.number}</span>
{isActive && (
<span className="text-[10px] font-bold text-brand-400 bg-brand-500/10 px-1.5 py-0.5 rounded-full border border-brand-500/20">
ATIVO
</span>
)}
</div>
<p className="text-xs text-slate-500 mt-0.5">
{format(new Date(protocol.createdAt), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}
</p>
</div>
<StatusBadge status={protocol.status} />
</div>
{/* Sector / Team */}
{(protocol.sector || protocol.team) && (
<div className="flex items-center gap-3 text-xs text-slate-400">
{protocol.sector && (
<span className="flex items-center gap-1">
<Layers className="w-3 h-3" />
{protocol.sector.name}
</span>
)}
{protocol.team && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{protocol.team.name}
</span>
)}
</div>
)}
{/* Summary */}
{protocol.summary && (
<p className="text-xs text-slate-400 italic border-l-2 border-white/10 pl-3">{protocol.summary}</p>
)}
{/* Status actions */}
{transitions.length > 0 && (
<div className="relative">
<button
onClick={() => setShowActions(!showActions)}
disabled={isUpdating}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-white transition-colors disabled:opacity-50"
>
{isUpdating
? <Loader2 className="w-3 h-3 animate-spin" />
: <ChevronDown className={`w-3 h-3 transition-transform ${showActions ? 'rotate-180' : ''}`} />}
Alterar status
</button>
<AnimatePresence>
{showActions && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="absolute left-0 top-full mt-1 bg-[#1a1f2e] border border-white/10 rounded-xl shadow-2xl z-10 py-1 min-w-[180px]"
>
{transitions.map((s) => {
const cfg = STATUS[s]
return (
<button
key={s}
onClick={() => { onStatusChange(protocol, s); setShowActions(false) }}
className={`w-full text-left px-3 py-2 text-xs flex items-center gap-2 hover:bg-white/5 transition-colors ${cfg.color}`}
>
{cfg.icon}
{cfg.label}
</button>
)
})}
</motion.div>
)}
</AnimatePresence>
</div>
)}
{/* Resolved at */}
{protocol.resolvedAt && (
<p className="text-xs text-emerald-400 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" />
Resolvido em {format(new Date(protocol.resolvedAt), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}
</p>
)}
</div>
)
}
// ─── Panel ────────────────────────────────────────────────────────────────────
interface ProtocolPanelProps {
isOpen: boolean
chatId: string | null
onClose: () => void
// injected from useProtocolPanel
protocols: Protocol[]
loading: boolean
activeProtocol: Protocol | null
hasActive: boolean
showOpenForm: boolean
openForm: () => void
setShowOpenForm: (v: boolean) => void
sectors: any[]
teams: any[]
selectedSectorId: string
setSelectedSectorId: (v: string) => void
selectedTeamId: string
setSelectedTeamId: (v: string) => void
loadingSectors: boolean
loadingTeams: boolean
isOpening: boolean
openProtocol: () => void
updatingId: string | null
updateStatus: (protocol: Protocol, status: ProtocolStatus) => void
toast: { message: string; type: 'success' | 'error' } | null
}
export default function ProtocolPanel({
isOpen, onClose,
protocols, loading,
activeProtocol, hasActive,
showOpenForm, openForm, setShowOpenForm,
sectors, teams,
selectedSectorId, setSelectedSectorId,
selectedTeamId, setSelectedTeamId,
loadingSectors, loadingTeams,
isOpening, openProtocol,
updatingId, updateStatus,
toast,
}: ProtocolPanelProps) {
const selectClass = "w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-500/50 transition-colors appearance-none"
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-[360px] h-full bg-[#111b21] border-l border-[#d1d7db] flex flex-col z-40 flex-shrink-0"
>
{/* Header */}
<div className="h-[60px] bg-[#f0f2f5] px-4 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<ClipboardList className="w-5 h-5 text-[#54656f]" />
<span className="font-bold text-[#111b21] text-sm">Protocolos de Atendimento</span>
</div>
<button onClick={onClose} className="p-1.5 hover:bg-black/5 rounded-full transition-colors">
<X className="w-5 h-5 text-[#54656f]" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Open protocol form */}
<AnimatePresence>
{showOpenForm ? (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="bg-white/[0.03] border border-white/10 rounded-xl p-4 space-y-3">
<p className="text-white text-sm font-semibold">Novo Protocolo</p>
{/* Sector */}
<div>
<label className="block text-xs text-slate-400 mb-1">Setor (opcional)</label>
<div className="relative">
<select
value={selectedSectorId}
onChange={(e) => setSelectedSectorId(e.target.value)}
className={selectClass}
disabled={loadingSectors}
>
<option value="">Sem setor</option>
{sectors.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
{loadingSectors && (
<Loader2 className="absolute right-3 top-2.5 w-3.5 h-3.5 text-slate-400 animate-spin" />
)}
</div>
</div>
{/* Team */}
{selectedSectorId && (
<div>
<label className="block text-xs text-slate-400 mb-1">Equipe (opcional)</label>
<div className="relative">
<select
value={selectedTeamId}
onChange={(e) => setSelectedTeamId(e.target.value)}
className={selectClass}
disabled={loadingTeams}
>
<option value="">Sem equipe</option>
{teams.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{loadingTeams && (
<Loader2 className="absolute right-3 top-2.5 w-3.5 h-3.5 text-slate-400 animate-spin" />
)}
</div>
</div>
)}
<div className="flex gap-2">
<button
onClick={() => setShowOpenForm(false)}
className="flex-1 py-2 text-xs font-semibold text-slate-400 hover:text-white bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
>
Cancelar
</button>
<button
onClick={openProtocol}
disabled={isOpening}
className="flex-1 py-2 text-xs font-bold text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors disabled:opacity-50 flex items-center justify-center gap-1.5"
>
{isOpening
? <><Loader2 className="w-3 h-3 animate-spin" /> Abrindo</>
: 'Abrir Protocolo'
}
</button>
</div>
</div>
</motion.div>
) : (
!hasActive && (
<button
onClick={openForm}
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-white/10 rounded-xl text-sm text-slate-400 hover:text-brand-400 hover:border-brand-500/40 transition-colors"
>
<Plus className="w-4 h-4" />
Abrir Protocolo
</button>
)
)}
</AnimatePresence>
{/* Active protocol */}
{activeProtocol && !showOpenForm && (
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest font-bold mb-2">Protocolo Ativo</p>
<ProtocolCard
protocol={activeProtocol}
isActive
updatingId={updatingId}
onStatusChange={updateStatus}
/>
{!hasActive && null}
</div>
)}
{/* Open new when active exists */}
{hasActive && !showOpenForm && (
<button
onClick={openForm}
className="w-full flex items-center justify-center gap-2 py-2.5 border border-dashed border-white/10 rounded-xl text-xs text-slate-500 hover:text-brand-400 hover:border-brand-500/30 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Abrir novo protocolo
</button>
)}
{/* History */}
{loading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 text-brand-400 animate-spin" />
</div>
) : protocols.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2">
<ClipboardList className="w-8 h-8 text-slate-700" />
<p className="text-slate-500 text-xs text-center">Nenhum protocolo para este chat.</p>
</div>
) : (
(() => {
const history = protocols.filter(
(p) => p.status === 'RESOLVED' || p.status === 'CANCELLED'
)
return history.length > 0 ? (
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest font-bold mb-2">Histórico</p>
<div className="space-y-2">
{history.map((p) => (
<ProtocolCard
key={p.id}
protocol={p}
isActive={false}
updatingId={updatingId}
onStatusChange={updateStatus}
/>
))}
</div>
</div>
) : null
})()
)}
</div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,719 @@
'use client';
/**
* RichMessageComposer modal completo para compor e enviar mensagens ricas.
*
* Suporta 6 tipos de mensagem WhatsApp via InfiniteAPI/Baileys:
* TEXT texto simples (padrão atual)
* BUTTONS até 3 botões de resposta rápida (nativeButtons reply)
* INTERACTIVE botões CTA: URL / Copiar / Ligar (nativeButtons url/copy/call)
* LIST lista dropdown com seções e itens (nativeList)
* POLL enquete nativa do WhatsApp (poll)
* CAROUSEL cards deslizáveis com imagem e botões (nativeCarousel)
*
* Props:
* isOpen controla visibilidade
* onClose callback ao fechar
* instanceId instância WhatsApp ativa
* jid JID destino (pré-preenchido quando aberto do chat)
* onSent callback após envio bem-sucedido
* initialPayload pré-preenche o form (ao usar um template)
* initialType tipo inicial selecionado
*/
import React, { useState, useCallback, useEffect, useId } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Send, Plus, Trash2, ChevronDown, ChevronUp,
Type, MousePointerClick, List, BarChart2, LayoutGrid, Link,
BookmarkPlus, Calendar,
} from 'lucide-react'
import { richMessageApi, templateApi, type TemplateType, type RichPayload } from '../services/chatApiService'
// ─── Tipos locais ────────────────────────────────────────────────────────────
type MessageType = TemplateType
interface BtnItem { id: string; text: string }
interface CtaItem { type: 'url' | 'copy' | 'call'; text: string; extra: string }
interface ListRow { id: string; title: string; description: string }
interface ListSection { title: string; rows: ListRow[] }
interface CarouselBtn { id: string; text: string }
interface CarouselCard { title: string; body: string; footer: string; imageUrl: string; buttons: CarouselBtn[] }
interface FormState {
// TEXT
text: string
// BUTTONS
btnText: string
btnFooter: string
buttons: BtnItem[]
// INTERACTIVE (CTA)
ctaText: string
ctaFooter: string
ctaButtons: CtaItem[]
// LIST
listText: string
listButtonText: string
listFooter: string
listSections: ListSection[]
// POLL
pollName: string
pollSelectable: number
pollOptions: string[]
// CAROUSEL
carouselText: string
carouselFooter: string
cards: CarouselCard[]
}
const defaultForm: FormState = {
text: '',
btnText: '', btnFooter: '', buttons: [{ id: 'btn1', text: '' }],
ctaText: '', ctaFooter: '', ctaButtons: [{ type: 'url', text: '', extra: '' }],
listText: '', listButtonText: 'Ver opções', listFooter: '',
listSections: [{ title: 'Seção 1', rows: [{ id: 'r1', title: '', description: '' }] }],
pollName: '', pollSelectable: 1, pollOptions: ['', ''],
carouselText: '', carouselFooter: '',
cards: [{ title: '', body: '', footer: '', imageUrl: '', buttons: [{ id: 'cb1', text: '' }] }],
}
// ─── Tipo selector ────────────────────────────────────────────────────────────
const TYPE_TABS: Array<{ type: MessageType; label: string; icon: React.ReactNode; color: string }> = [
{ type: 'TEXT', label: 'Texto', icon: <Type className="w-4 h-4" />, color: '#667781' },
{ type: 'BUTTONS', label: 'Botões', icon: <MousePointerClick className="w-4 h-4" />, color: '#0ea5e9' },
{ type: 'INTERACTIVE', label: 'CTA', icon: <Link className="w-4 h-4" />, color: '#8b5cf6' },
{ type: 'LIST', label: 'Lista', icon: <List className="w-4 h-4" />, color: '#f59e0b' },
{ type: 'POLL', label: 'Enquete', icon: <BarChart2 className="w-4 h-4" />, color: '#10b981' },
{ type: 'CAROUSEL', label: 'Carrossel', icon: <LayoutGrid className="w-4 h-4" />, color: '#ef4444' },
]
// ─── Helpers ──────────────────────────────────────────────────────────────────
function uid() { return `id_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` }
function buildPayload(type: MessageType, form: FormState): RichPayload {
switch (type) {
case 'TEXT':
return { text: form.text }
case 'BUTTONS':
return {
text: form.btnText || undefined,
footer: form.btnFooter || undefined,
nativeButtons: form.buttons.filter(b => b.id && b.text).map(b => ({
type: 'reply' as const, id: b.id, text: b.text,
})),
}
case 'INTERACTIVE':
return {
text: form.ctaText || undefined,
nativeButtons: form.ctaButtons.filter(b => b.text && b.extra).map(b => {
if (b.type === 'url') return { type: 'url' as const, text: b.text, url: b.extra }
if (b.type === 'copy') return { type: 'copy' as const, text: b.text, copyText: b.extra }
return { type: 'call' as const, text: b.text, phoneNumber: b.extra }
}),
}
case 'LIST': {
const sections = form.listSections
.map(s => ({ title: s.title, rows: s.rows.filter(r => r.id && r.title) }))
.filter(s => s.rows.length > 0)
return {
text: form.listText || undefined,
nativeList: {
buttonText: form.listButtonText || 'Ver opções',
sections,
},
}
}
case 'POLL':
return {
poll: {
name: form.pollName || 'Enquete',
values: form.pollOptions.filter(Boolean),
selectableCount: form.pollSelectable,
},
}
case 'CAROUSEL': {
const cards = form.cards.map(c => ({
title: c.title || undefined,
body: c.body || undefined,
footer: c.footer || undefined,
image: c.imageUrl ? { url: c.imageUrl } : undefined,
buttons: c.buttons.filter(b => b.id && b.text).map(b => ({ type: 'reply' as const, id: b.id, text: b.text })),
}))
return {
text: form.carouselText || undefined,
nativeCarousel: { cards },
}
}
}
}
// ─── Sub-forms ────────────────────────────────────────────────────────────────
function TextForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
return (
<div className="space-y-3">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Mensagem</label>
<textarea
rows={5}
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30 resize-none"
placeholder="Digite o texto da mensagem..."
value={form.text}
onChange={e => set({ text: e.target.value })}
/>
</div>
)
}
function ButtonsForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const add = () => {
if (form.buttons.length >= 3) return
set({ buttons: [...form.buttons, { id: uid(), text: '' }] })
}
const remove = (i: number) => set({ buttons: form.buttons.filter((_, idx) => idx !== i) })
const update = (i: number, field: keyof BtnItem, val: string) => {
const btns = [...form.buttons]; btns[i] = { ...btns[i], [field]: val }; set({ buttons: btns })
}
return (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30 resize-none"
placeholder="Corpo da mensagem" value={form.btnText} onChange={e => set({ btnText: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Rodapé</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
placeholder="Texto de rodapé (opcional)" value={form.btnFooter} onChange={e => set({ btnFooter: e.target.value })} />
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Botões (máx. 3)</label>
{form.buttons.map((btn, i) => (
<div key={btn.id} className="flex gap-2">
<input className="flex-1 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
placeholder="ID do botão" value={btn.id} onChange={e => update(i, 'id', e.target.value)} />
<input className="flex-[2] rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
placeholder="Texto visível" value={btn.text} onChange={e => update(i, 'text', e.target.value)} />
<button onClick={() => remove(i)} disabled={form.buttons.length <= 1}
className="p-2 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{form.buttons.length < 3 && (
<button onClick={add} className="flex items-center gap-1 text-xs text-[#0ea5e9] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar botão
</button>
)}
</div>
</div>
)
}
function InteractiveForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const add = () => {
if (form.ctaButtons.length >= 3) return
set({ ctaButtons: [...form.ctaButtons, { type: 'url', text: '', extra: '' }] })
}
const remove = (i: number) => set({ ctaButtons: form.ctaButtons.filter((_, idx) => idx !== i) })
const update = (i: number, field: keyof CtaItem, val: string) => {
const btns = [...form.ctaButtons]; btns[i] = { ...btns[i], [field]: val as any }; set({ ctaButtons: btns })
}
const placeholders: Record<string, string> = { url: 'https://...', copy: 'Código do cupom', call: '5511999999999' }
const labels: Record<string, string> = { url: 'URL', copy: 'Código a copiar', call: 'Número de telefone' }
return (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30 resize-none"
placeholder="Corpo da mensagem" value={form.ctaText} onChange={e => set({ ctaText: e.target.value })} />
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Botões CTA (máx. 3)</label>
{form.ctaButtons.map((btn, i) => (
<div key={i} className="rounded-xl border border-gray-200 bg-[#f8f9fa] p-3 space-y-2">
<div className="flex gap-2 items-start">
<select value={btn.type} onChange={e => update(i, 'type', e.target.value)}
className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30">
<option value="url">🌐 URL</option>
<option value="copy">📋 Copiar</option>
<option value="call">📞 Ligar</option>
</select>
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"
placeholder="Texto do botão" value={btn.text} onChange={e => update(i, 'text', e.target.value)} />
<button onClick={() => remove(i)} disabled={form.ctaButtons.length <= 1}
className="p-1.5 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<input className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"
placeholder={placeholders[btn.type]} value={btn.extra} onChange={e => update(i, 'extra', e.target.value)} />
<span className="text-[10px] text-gray-400">{labels[btn.type]}</span>
</div>
))}
{form.ctaButtons.length < 3 && (
<button onClick={add} className="flex items-center gap-1 text-xs text-[#8b5cf6] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar botão CTA
</button>
)}
</div>
</div>
)
}
function ListForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const addSection = () => {
set({ listSections: [...form.listSections, { title: `Seção ${form.listSections.length + 1}`, rows: [{ id: uid(), title: '', description: '' }] }] })
}
const removeSection = (si: number) => set({ listSections: form.listSections.filter((_, i) => i !== si) })
const updateSection = (si: number, title: string) => {
const ss = [...form.listSections]; ss[si] = { ...ss[si], title }; set({ listSections: ss })
}
const addRow = (si: number) => {
const ss = [...form.listSections]
ss[si] = { ...ss[si], rows: [...ss[si].rows, { id: uid(), title: '', description: '' }] }
set({ listSections: ss })
}
const removeRow = (si: number, ri: number) => {
const ss = [...form.listSections]
ss[si] = { ...ss[si], rows: ss[si].rows.filter((_, i) => i !== ri) }
set({ listSections: ss })
}
const updateRow = (si: number, ri: number, field: keyof ListRow, val: string) => {
const ss = [...form.listSections]
const rows = [...ss[si].rows]; rows[ri] = { ...rows[ri], [field]: val }
ss[si] = { ...ss[si], rows }; set({ listSections: ss })
}
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none resize-none"
placeholder="Corpo da mensagem" value={form.listText} onChange={e => set({ listText: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto do Botão</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
placeholder="Ver opções" value={form.listButtonText} onChange={e => set({ listButtonText: e.target.value })} />
</div>
</div>
<div className="space-y-3">
{form.listSections.map((sec, si) => (
<div key={si} className="rounded-xl border border-amber-100 bg-amber-50/50 p-3 space-y-2">
<div className="flex items-center gap-2">
<input className="flex-1 rounded-lg border border-amber-200 bg-white px-2 py-1.5 text-xs font-medium focus:outline-none"
placeholder="Título da seção" value={sec.title} onChange={e => updateSection(si, e.target.value)} />
<button onClick={() => removeSection(si)} disabled={form.listSections.length <= 1}
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{sec.rows.map((row, ri) => (
<div key={ri} className="flex gap-2">
<input className="w-20 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="ID" value={row.id} onChange={e => updateRow(si, ri, 'id', e.target.value)} />
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Título" value={row.title} onChange={e => updateRow(si, ri, 'title', e.target.value)} />
<input className="flex-[2] rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Descrição" value={row.description} onChange={e => updateRow(si, ri, 'description', e.target.value)} />
<button onClick={() => removeRow(si, ri)} disabled={sec.rows.length <= 1}
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
<button onClick={() => addRow(si)} className="flex items-center gap-1 text-xs text-amber-600 hover:underline font-medium">
<Plus className="w-3 h-3" /> Adicionar item
</button>
</div>
))}
<button onClick={addSection} className="flex items-center gap-1 text-xs text-amber-600 hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar seção
</button>
</div>
</div>
)
}
function PollForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const addOption = () => set({ pollOptions: [...form.pollOptions, ''] })
const removeOption = (i: number) => {
if (form.pollOptions.length <= 2) return
set({ pollOptions: form.pollOptions.filter((_, idx) => idx !== i) })
}
const updateOption = (i: number, val: string) => {
const opts = [...form.pollOptions]; opts[i] = val; set({ pollOptions: opts })
}
return (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Pergunta</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
placeholder="Qual a sua preferência?" value={form.pollName} onChange={e => set({ pollName: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Máx. seleções</label>
<input type="number" min={1} max={form.pollOptions.filter(Boolean).length || 1}
className="w-24 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
value={form.pollSelectable} onChange={e => set({ pollSelectable: parseInt(e.target.value) || 1 })} />
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Opções (mín. 2)</label>
{form.pollOptions.map((opt, i) => (
<div key={i} className="flex gap-2">
<input className="flex-1 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
placeholder={`Opção ${i + 1}`} value={opt} onChange={e => updateOption(i, e.target.value)} />
<button onClick={() => removeOption(i)} disabled={form.pollOptions.length <= 2}
className="p-2 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{form.pollOptions.length < 12 && (
<button onClick={addOption} className="flex items-center gap-1 text-xs text-[#10b981] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar opção
</button>
)}
</div>
</div>
)
}
function CarouselForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const addCard = () => {
if (form.cards.length >= 10) return
set({ cards: [...form.cards, { title: '', body: '', footer: '', imageUrl: '', buttons: [{ id: uid(), text: '' }] }] })
}
const removeCard = (ci: number) => set({ cards: form.cards.filter((_, i) => i !== ci) })
const updateCard = (ci: number, field: keyof CarouselCard, val: any) => {
const cards = [...form.cards]; cards[ci] = { ...cards[ci], [field]: val }; set({ cards })
}
const addCardBtn = (ci: number) => {
const cards = [...form.cards]; cards[ci].buttons.push({ id: uid(), text: '' }); set({ cards })
}
const removeCardBtn = (ci: number, bi: number) => {
const cards = [...form.cards]; cards[ci].buttons = cards[ci].buttons.filter((_, i) => i !== bi); set({ cards })
}
const updateCardBtn = (ci: number, bi: number, field: keyof CarouselBtn, val: string) => {
const cards = [...form.cards]; cards[ci].buttons[bi] = { ...cards[ci].buttons[bi], [field]: val }; set({ cards })
}
const [expanded, setExpanded] = useState<number[]>([0])
const toggle = (i: number) => setExpanded(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i])
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto acima</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
placeholder="Ofertas especiais" value={form.carouselText} onChange={e => set({ carouselText: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Rodapé</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
placeholder="Rodapé geral" value={form.carouselFooter} onChange={e => set({ carouselFooter: e.target.value })} />
</div>
</div>
<div className="space-y-2 max-h-[320px] overflow-y-auto pr-1">
{form.cards.map((card, ci) => (
<div key={ci} className="rounded-xl border border-gray-200 bg-[#f8f9fa] overflow-hidden">
<button onClick={() => toggle(ci)}
className="w-full flex items-center justify-between px-3 py-2.5 text-sm font-medium text-[#111b21] hover:bg-gray-100 transition-colors">
<span>Card {ci + 1}{card.title ? `${card.title}` : ''}</span>
<div className="flex items-center gap-2">
{form.cards.length > 1 && (
<button onClick={(e) => { e.stopPropagation(); removeCard(ci) }}
className="p-1 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{expanded.includes(ci) ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
</div>
</button>
{expanded.includes(ci) && (
<div className="px-3 pb-3 space-y-2 border-t border-gray-100 pt-2">
<div className="grid grid-cols-2 gap-2">
<input className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Título" value={card.title} onChange={e => updateCard(ci, 'title', e.target.value)} />
<input className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Rodapé" value={card.footer} onChange={e => updateCard(ci, 'footer', e.target.value)} />
</div>
<textarea rows={2} className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none resize-none"
placeholder="Descrição/corpo" value={card.body} onChange={e => updateCard(ci, 'body', e.target.value)} />
<input className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="URL da imagem (opcional)" value={card.imageUrl} onChange={e => updateCard(ci, 'imageUrl', e.target.value)} />
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-gray-400 uppercase">Botões do card</span>
{card.buttons.map((btn, bi) => (
<div key={bi} className="flex gap-1.5">
<input className="w-20 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="ID" value={btn.id} onChange={e => updateCardBtn(ci, bi, 'id', e.target.value)} />
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="Texto" value={btn.text} onChange={e => updateCardBtn(ci, bi, 'text', e.target.value)} />
<button onClick={() => removeCardBtn(ci, bi)} disabled={card.buttons.length <= 1}
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
<button onClick={() => addCardBtn(ci)} className="flex items-center gap-1 text-[11px] text-red-500 hover:underline font-medium">
<Plus className="w-3 h-3" /> Botão no card
</button>
</div>
</div>
)}
</div>
))}
</div>
{form.cards.length < 10 && (
<button onClick={addCard} className="flex items-center gap-1 text-xs text-[#ef4444] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar card
</button>
)}
</div>
)
}
// ─── Modal principal ──────────────────────────────────────────────────────────
interface Props {
isOpen: boolean
onClose: () => void
instanceId: string | null
jid: string
onSent?: () => void
onSaveTemplate?: (type: TemplateType, payload: Record<string, unknown>, name?: string) => void
onSchedule?: (type: TemplateType, payload: Record<string, unknown>) => void
initialPayload?: Record<string, unknown>
initialType?: TemplateType
}
export default function RichMessageComposer({
isOpen, onClose, instanceId, jid, onSent, onSaveTemplate, onSchedule,
initialPayload, initialType = 'TEXT',
}: Props) {
const [type, setType] = useState<MessageType>(initialType)
const [form, setFormState] = useState<FormState>(defaultForm)
const [sending, setSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [savedName, setSavedName] = useState('')
const [showSaveName, setShowSaveName] = useState(false)
const set = useCallback((partial: Partial<FormState>) => {
setFormState(prev => ({ ...prev, ...partial }))
}, [])
const handleSend = async () => {
if (!instanceId || !jid) return
// Validações de frontend antes de enviar
if (type === 'CAROUSEL' && form.cards.length < 2) {
setError('Carrossel requer no mínimo 2 cards')
return
}
if (type === 'CAROUSEL' && form.cards.some(c => c.buttons.filter(b => b.id && b.text).length === 0)) {
setError('Cada card do carrossel precisa ter pelo menos 1 botão')
return
}
setError(null)
setSending(true)
try {
const payload = buildPayload(type, form)
await richMessageApi.send(instanceId, jid, payload)
onSent?.()
onClose()
} catch (e: any) {
setError(e?.response?.data?.error ?? e?.message ?? 'Erro ao enviar')
} finally {
setSending(false)
}
}
const handleSave = async () => {
if (!savedName.trim()) { setShowSaveName(true); return }
setSaving(true)
try {
const payload = buildPayload(type, form) as Record<string, unknown>
await templateApi.create({ name: savedName.trim(), type, payload, tags: [] })
setShowSaveName(false)
setSavedName('')
} finally {
setSaving(false)
}
}
const handleSchedule = () => {
const payload = buildPayload(type, form) as Record<string, unknown>
onSchedule?.(type, payload)
}
const activeTab = TYPE_TABS.find(t => t.type === type)!
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
if (!mounted) return null
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 40, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-xl max-h-[92vh] flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
<div>
<h2 className="text-base font-semibold text-[#111b21]">Mensagem Rica</h2>
<p className="text-xs text-[#667781] mt-0.5">Compose um tipo avançado de mensagem WhatsApp</p>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Type tabs */}
<div className="px-5 pt-3 pb-2 shrink-0">
<div className="flex gap-1 bg-[#f0f2f5] rounded-xl p-1">
{TYPE_TABS.map(tab => (
<button
key={tab.type}
onClick={() => setType(tab.type)}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-[11px] font-semibold transition-all ${
type === tab.type
? 'bg-white shadow-sm text-[#111b21]'
: 'text-[#667781] hover:text-[#111b21]'
}`}
style={type === tab.type ? { color: tab.color } : {}}
>
{tab.icon}
<span className="hidden sm:block">{tab.label}</span>
</button>
))}
</div>
</div>
{/* Form area */}
<div className="flex-1 overflow-y-auto px-5 py-3">
{type === 'TEXT' && <TextForm form={form} set={set} />}
{type === 'BUTTONS' && <ButtonsForm form={form} set={set} />}
{type === 'INTERACTIVE' && <InteractiveForm form={form} set={set} />}
{type === 'LIST' && <ListForm form={form} set={set} />}
{type === 'POLL' && <PollForm form={form} set={set} />}
{type === 'CAROUSEL' && <CarouselForm form={form} set={set} />}
</div>
{/* Save name input */}
<AnimatePresence>
{showSaveName && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="px-5 py-2 border-t border-gray-100 bg-[#f8f9fa] overflow-hidden shrink-0"
>
<div className="flex gap-2 items-center">
<input
autoFocus
className="flex-1 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30"
placeholder="Nome do template..."
value={savedName}
onChange={e => setSavedName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') setShowSaveName(false) }}
/>
<button onClick={handleSave} disabled={!savedName.trim() || saving}
className="px-3 py-2 bg-[#00a884] text-white rounded-xl text-sm font-medium disabled:opacity-50 hover:bg-[#008f72] transition-colors">
{saving ? '...' : 'Salvar'}
</button>
<button onClick={() => setShowSaveName(false)} className="p-2 rounded-full hover:bg-gray-200 text-gray-400">
<X className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Error */}
{error && (
<div className="mx-5 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-xl text-xs text-red-600">
{error}
</div>
)}
{/* Footer actions */}
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-between shrink-0">
<div className="flex items-center gap-1">
<button
onClick={() => setShowSaveName(!showSaveName)}
title="Salvar como template"
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium text-[#667781] hover:bg-gray-100 transition-colors"
>
<BookmarkPlus className="w-4 h-4" />
<span className="hidden sm:block">Salvar template</span>
</button>
{onSchedule && (
<button
onClick={handleSchedule}
title="Agendar envio"
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium text-[#667781] hover:bg-gray-100 transition-colors"
>
<Calendar className="w-4 h-4" />
<span className="hidden sm:block">Agendar</span>
</button>
)}
</div>
<div className="flex items-center gap-2">
<button onClick={onClose}
className="px-4 py-2 rounded-xl text-sm font-medium text-[#667781] hover:bg-gray-100 transition-colors">
Cancelar
</button>
<button
onClick={handleSend}
disabled={sending || !instanceId || !jid}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold text-white disabled:opacity-50 transition-all active:scale-95"
style={{ backgroundColor: activeTab.color }}
>
{sending ? (
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Enviar
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
)
}
@@ -0,0 +1,506 @@
'use client';
/**
* ScheduleMessageModal modal completo de agendamentos de mensagem.
*
* Modos:
* ONCE data/hora específica (datetime-local)
* RECURRING expressão cron com presets visuais
* EVENT BIRTHDAY / ANNIVERSARY / SIGNUP_DAYS
* vinculado a dados de recipients (birthday, anniversary, signupDate)
*
* Suporte a variáveis de personalização: {{name}}, {{birthday}}, etc.
* Destinatários: lista manual de JIDs com nome e dados de evento.
* Variáveis substituídas em runtime pelo SchedulerService.
*/
import React, { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Calendar, RotateCw, Gift, Plus, Trash2, Clock,
CheckCircle2, AlertTriangle, ChevronRight, Users,
} from 'lucide-react'
import {
scheduleApi, instanceApi,
type ScheduleType, type EventType, type ScheduleRecipient, type TemplateType,
} from '../services/chatApiService'
// ─── CRON presets ─────────────────────────────────────────────────────────────
const CRON_PRESETS = [
{ label: 'Diariamente às 8h', expr: '0 8 * * *' },
{ label: 'Seg a Sex às 9h', expr: '0 9 * * 1-5' },
{ label: 'Toda segunda às 10h', expr: '0 10 * * 1' },
{ label: 'Quinzenal (dia 1/15)', expr: '0 9 1,15 * *' },
{ label: 'Mensal (dia 1)', expr: '0 9 1 * *' },
{ label: 'Semanal domingo 11h', expr: '0 11 * * 0' },
]
// ─── Tipos locais ─────────────────────────────────────────────────────────────
interface RecipientRow extends ScheduleRecipient {
_key: string
}
interface Props {
isOpen: boolean
onClose: () => void
payload: Record<string, unknown>
messageType: TemplateType
onSaved?: () => void
}
function uid() { return `k_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` }
// ─── Component ────────────────────────────────────────────────────────────────
export default function ScheduleMessageModal({ isOpen, onClose, payload, messageType, onSaved }: Props) {
const [step, setStep] = useState<1 | 2 | 3>(1) // 1=tipo, 2=destinatários, 3=confirmação
// Step 1 — configuração de schedule
const [scheduleType, setScheduleType] = useState<ScheduleType>('ONCE')
const [scheduledAt, setScheduledAt] = useState('')
const [cronExpr, setCronExpr] = useState('0 9 * * 1-5')
const [eventType, setEventType] = useState<EventType>('BIRTHDAY')
const [daysAfter, setDaysAfter] = useState('30')
const [name, setName] = useState('')
const [timezone] = useState('America/Sao_Paulo')
// Step 2 — destinatários
const [recipients, setRecipients] = useState<RecipientRow[]>([
{ _key: uid(), jid: '', name: '', birthday: '', anniversary: '', signupDate: '' },
])
// Step 3 — resultado
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [instanceId, setInstanceId] = useState('')
const [instances, setInstances] = useState<Array<{ id: string; name: string; status: string }>>([])
useEffect(() => {
if (isOpen) {
instanceApi.list().then((list: any[]) => setInstances(list))
}
}, [isOpen])
const addRecipient = () => {
setRecipients(prev => [...prev, { _key: uid(), jid: '', name: '', birthday: '', anniversary: '', signupDate: '' }])
}
const removeRecipient = (key: string) => {
setRecipients(prev => prev.filter(r => r._key !== key))
}
const updateRecipient = (key: string, field: keyof ScheduleRecipient, val: string) => {
setRecipients(prev => prev.map(r => r._key === key ? { ...r, [field]: val } : r))
}
const isStep1Valid = () => {
if (!name.trim() || !instanceId) return false
if (scheduleType === 'ONCE' && !scheduledAt) return false
if (scheduleType === 'RECURRING' && !cronExpr.trim()) return false
return true
}
const validRecipients = recipients.filter(r => r.jid.trim())
const buildScheduleData = () => ({
instanceId,
name: name.trim(),
payload,
recipients: validRecipients.map(({ _key, ...r }) => ({
...r,
jid: r.jid.includes('@') ? r.jid : `${r.jid.replace(/\D/g, '')}@s.whatsapp.net`,
})),
scheduleType,
scheduledAt: scheduleType === 'ONCE' ? scheduledAt : undefined,
cronExpr: scheduleType === 'RECURRING' ? cronExpr
: scheduleType === 'EVENT' && eventType === 'SIGNUP_DAYS' ? daysAfter
: undefined,
eventType: scheduleType === 'EVENT' ? eventType : undefined,
timezone,
})
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await scheduleApi.create(buildScheduleData())
setSaved(true)
onSaved?.()
setTimeout(() => { setSaved(false); onClose(); setStep(1) }, 2000)
} catch (e: any) {
setError(e?.response?.data?.error ?? e?.message ?? 'Erro ao salvar')
} finally {
setSaving(false)
}
}
// Rótulo do tipo de mensagem
const typeLabels: Record<TemplateType, string> = {
TEXT: 'Texto', BUTTONS: 'Botões', INTERACTIVE: 'CTA', LIST: 'Lista', POLL: 'Enquete', CAROUSEL: 'Carrossel',
}
const minDateTime = new Date(Date.now() + 60_000).toISOString().slice(0, 16)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
if (!mounted) return null
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 30, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-[#fef3c7] flex items-center justify-center">
<Calendar className="w-5 h-5 text-[#f59e0b]" />
</div>
<div>
<h2 className="text-base font-semibold text-[#111b21]">Agendar Mensagem</h2>
<p className="text-xs text-[#667781]">Tipo: {typeLabels[messageType]}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Step indicator */}
<div className="px-5 py-3 flex items-center gap-2 shrink-0 border-b border-gray-100">
{[1, 2, 3].map(s => (
<React.Fragment key={s}>
<button
onClick={() => s < step ? setStep(s as any) : undefined}
className={`w-7 h-7 rounded-full text-xs font-bold flex items-center justify-center transition-all ${
step === s ? 'bg-[#f59e0b] text-white shadow-md scale-105' :
step > s ? 'bg-[#10b981] text-white' : 'bg-gray-100 text-gray-400'
}`}
>
{step > s ? <CheckCircle2 className="w-4 h-4" /> : s}
</button>
{s < 3 && <ChevronRight className="w-4 h-4 text-gray-300" />}
</React.Fragment>
))}
<span className="ml-2 text-xs text-[#667781]">
{step === 1 ? 'Configurar disparo' : step === 2 ? 'Destinatários' : 'Confirmar'}
</span>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{/* ── STEP 1: schedule config ─────────────────────────────── */}
{step === 1 && (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Nome do agendamento</label>
<input
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#f59e0b]/30"
placeholder="Ex: Aniversários de Abril"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Instância WhatsApp</label>
<select
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#f59e0b]/30"
value={instanceId}
onChange={e => setInstanceId(e.target.value)}
>
<option value="">Selecionar instância...</option>
{instances.map((inst: any) => (
<option key={inst.id} value={inst.id} disabled={inst.status !== 'CONNECTED'}>
{inst.name} {inst.status !== 'CONNECTED' ? '(desconectada)' : ''}
</option>
))}
</select>
</div>
{/* Schedule type selector */}
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Tipo de agendamento</label>
<div className="grid grid-cols-3 gap-2">
{[
{ type: 'ONCE' as ScheduleType, label: 'Uma vez', icon: <Clock className="w-4 h-4" />, color: '#0ea5e9' },
{ type: 'RECURRING' as ScheduleType, label: 'Recorrente', icon: <RotateCw className="w-4 h-4" />, color: '#8b5cf6' },
{ type: 'EVENT' as ScheduleType, label: 'Evento', icon: <Gift className="w-4 h-4" />, color: '#ef4444' },
].map(opt => (
<button
key={opt.type}
onClick={() => setScheduleType(opt.type)}
className={`flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 transition-all text-sm font-semibold ${
scheduleType === opt.type
? 'border-current shadow-sm'
: 'border-gray-200 text-gray-400 hover:border-gray-300'
}`}
style={scheduleType === opt.type ? { borderColor: opt.color, color: opt.color, backgroundColor: opt.color + '10' } : {}}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
{/* ONCE — datetime */}
{scheduleType === 'ONCE' && (
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Data e hora</label>
<input
type="datetime-local"
min={minDateTime}
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
value={scheduledAt}
onChange={e => setScheduledAt(e.target.value)}
/>
</div>
)}
{/* RECURRING — cron */}
{scheduleType === 'RECURRING' && (
<div className="space-y-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Expressão Cron</label>
<input
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"
placeholder="0 9 * * 1-5"
value={cronExpr}
onChange={e => setCronExpr(e.target.value)}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-[#667781] font-medium">Presets:</p>
<div className="flex flex-wrap gap-1.5">
{CRON_PRESETS.map(p => (
<button
key={p.expr}
onClick={() => setCronExpr(p.expr)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${
cronExpr === p.expr
? 'bg-[#8b5cf6] text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{p.label}
</button>
))}
</div>
</div>
</div>
)}
{/* EVENT — tipo */}
{scheduleType === 'EVENT' && (
<div className="space-y-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Tipo de evento</label>
<div className="grid grid-cols-3 gap-2">
{[
{ type: 'BIRTHDAY' as EventType, label: '🎂 Aniversário' },
{ type: 'ANNIVERSARY' as EventType, label: '💍 Aniversariante' },
{ type: 'SIGNUP_DAYS' as EventType, label: '📅 X dias após cadastro' },
].map(opt => (
<button
key={opt.type}
onClick={() => setEventType(opt.type)}
className={`py-2.5 px-2 rounded-xl border-2 text-xs font-semibold transition-all ${
eventType === opt.type
? 'border-[#ef4444] bg-[#fee2e2] text-[#ef4444]'
: 'border-gray-200 text-gray-500 hover:border-gray-300'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{eventType === 'SIGNUP_DAYS' && (
<div className="flex items-center gap-3">
<label className="text-xs font-semibold text-[#667781] uppercase tracking-wider whitespace-nowrap">Dias após cadastro</label>
<input
type="number" min="1" max="365"
className="w-24 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#ef4444]/30"
value={daysAfter}
onChange={e => setDaysAfter(e.target.value)}
/>
</div>
)}
<div className="rounded-xl bg-amber-50 border border-amber-200 px-3 py-2.5 text-xs text-amber-700">
<strong>Variáveis disponíveis:</strong> {'{{name}}, {{birthday}}, {{anniversary}}'}<br />
São substituídas automaticamente por contato no envio.
</div>
</div>
)}
</div>
)}
{/* ── STEP 2: recipients ─────────────────────────────────── */}
{step === 2 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-[#667781]" />
<span className="text-sm font-semibold text-[#111b21]">Destinatários</span>
<span className="text-xs text-[#667781]">({validRecipients.length} válido{validRecipients.length !== 1 ? 's' : ''})</span>
</div>
<button onClick={addRecipient} className="flex items-center gap-1 text-xs text-[#00a884] font-medium hover:underline">
<Plus className="w-3.5 h-3.5" /> Adicionar
</button>
</div>
<div className="space-y-3 max-h-[380px] overflow-y-auto pr-1">
{recipients.map((r) => (
<div key={r._key} className="rounded-xl border border-gray-200 bg-[#f8f9fa] p-3 space-y-2">
<div className="flex gap-2 items-start">
<input
className="flex-[2] rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-[#00a884]"
placeholder="Número ou JID ex: 5511999999999"
value={r.jid}
onChange={e => updateRecipient(r._key, 'jid', e.target.value)}
/>
<input
className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-[#00a884]"
placeholder="Nome ({{name}})"
value={r.name ?? ''}
onChange={e => updateRecipient(r._key, 'name', e.target.value)}
/>
<button onClick={() => removeRecipient(r._key)} disabled={recipients.length <= 1}
className="p-1.5 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{scheduleType === 'EVENT' && (
<div className="grid grid-cols-3 gap-1.5">
{(eventType === 'BIRTHDAY' || eventType === 'ANNIVERSARY') && (
<>
<input
type="date"
className="rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="Aniversário"
value={eventType === 'BIRTHDAY' ? (r.birthday ?? '') : (r.anniversary ?? '')}
onChange={e => updateRecipient(r._key, eventType === 'BIRTHDAY' ? 'birthday' : 'anniversary', e.target.value)}
/>
</>
)}
{eventType === 'SIGNUP_DAYS' && (
<input
type="date"
className="col-span-2 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="Data de cadastro"
value={r.signupDate ?? ''}
onChange={e => updateRecipient(r._key, 'signupDate', e.target.value)}
/>
)}
</div>
)}
</div>
))}
</div>
{validRecipients.length === 0 && (
<div className="flex items-center gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
Adicione ao menos um destinatário com número válido.
</div>
)}
</div>
)}
{/* ── STEP 3: confirm ─────────────────────────────────────── */}
{step === 3 && (
<div className="space-y-4">
{saved ? (
<div className="flex flex-col items-center justify-center py-10 gap-3">
<CheckCircle2 className="w-14 h-14 text-[#10b981]" />
<p className="text-base font-semibold text-[#111b21]">Agendamento criado!</p>
<p className="text-xs text-[#667781] text-center">O disparo será executado conforme configurado.</p>
</div>
) : (
<>
<div className="rounded-xl bg-[#f8f9fa] border border-gray-200 p-4 space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-[#667781]">Nome</span>
<span className="font-medium text-[#111b21]">{name}</span>
</div>
<div className="flex justify-between">
<span className="text-[#667781]">Tipo de mensagem</span>
<span className="font-medium text-[#111b21]">{typeLabels[messageType]}</span>
</div>
<div className="flex justify-between">
<span className="text-[#667781]">Disparo</span>
<span className="font-medium text-[#111b21]">
{scheduleType === 'ONCE' && `Uma vez — ${scheduledAt.replace('T', ' ')}`}
{scheduleType === 'RECURRING' && `Recorrente — ${cronExpr}`}
{scheduleType === 'EVENT' && `Evento: ${eventType}`}
</span>
</div>
<div className="flex justify-between">
<span className="text-[#667781]">Destinatários</span>
<span className="font-medium text-[#111b21]">{validRecipients.length} contato{validRecipients.length !== 1 ? 's' : ''}</span>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
</>
)}
</div>
)}
</div>
{/* Footer */}
{!saved && (
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-between shrink-0">
<button
onClick={() => step > 1 ? setStep(prev => (prev - 1) as any) : onClose()}
className="px-4 py-2 rounded-xl text-sm font-medium text-[#667781] hover:bg-gray-100 transition-colors"
>
{step > 1 ? 'Voltar' : 'Cancelar'}
</button>
<button
onClick={() => {
if (step === 1) setStep(2)
else if (step === 2) setStep(3)
else handleSave()
}}
disabled={
(step === 1 && !isStep1Valid()) ||
(step === 2 && validRecipients.length === 0) ||
saving
}
className="flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-semibold text-white bg-[#f59e0b] hover:bg-[#d97706] disabled:opacity-50 transition-all active:scale-95"
>
{saving && <span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />}
{step < 3 ? 'Continuar' : 'Confirmar e Salvar'}
</button>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
)
}
@@ -0,0 +1,134 @@
// Liga/desliga a Secretária IA APENAS desta sessão (instância aberta no /wa-inbox).
// Rate-limit 1x/hora, motivo obrigatório ao desligar, histórico de quem apertou.
// Fica na thin sidebar; abre um modal com o textarea de motivo + histórico.
import React, { useState, useEffect, useCallback } from 'react';
import { Power, X, Loader2, History, AlertTriangle } from 'lucide-react';
import { secSessionPowerApi, SessionPower } from '../services/secretariaApi';
const userName = (() => {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}')?.nome || 'Usuário'; }
catch { return 'Usuário'; }
})();
const fmt = (iso?: string | null) => {
if (!iso) return '';
try { return new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); }
catch { return ''; }
};
export const SessaoSecretariaPower: React.FC<{ instanceId: string | null }> = ({ instanceId }) => {
const [sp, setSp] = useState<SessionPower | null>(null);
const [open, setOpen] = useState(false);
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [erro, setErro] = useState<string | null>(null);
const carregar = useCallback(async () => {
if (!instanceId) return;
try { setSp(await secSessionPowerApi.get(instanceId)); } catch { /* silencioso */ }
}, [instanceId]);
useEffect(() => { carregar(); }, [carregar]);
if (!instanceId) return null;
const enabled = sp ? sp.enabled : true;
const toggle = async () => {
if (!sp?.can_toggle) return;
const proximo = !enabled;
if (proximo === false && !reason.trim()) { setErro('Escreva o motivo para desligar.'); return; }
setBusy(true); setErro(null);
try {
await secSessionPowerApi.set(instanceId, proximo, reason.trim(), userName);
setReason('');
await carregar();
} catch (e: any) {
setErro(e?.message || 'Não foi possível alterar.');
await carregar();
} finally { setBusy(false); }
};
return (
<>
<button
title={enabled ? 'Secretária IA ligada nesta sessão — clique para gerenciar' : 'Secretária IA DESLIGADA nesta sessão'}
onClick={() => { setErro(null); setOpen(true); }}
className={`w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors ${
enabled ? 'text-emerald-600 hover:bg-[#d9dbdf]/60' : 'text-white bg-red-600 hover:bg-red-700 animate-pulse'
}`}
>
<Power className="w-5 h-5" />
</button>
{open && (
<div className="fixed inset-0 bg-black/60 z-[80] flex items-center justify-center backdrop-blur-md p-4" onClick={() => setOpen(false)}>
<div className="bg-white w-full max-w-md rounded-[1.5rem] shadow-2xl border border-gray-100 flex flex-col max-h-[90vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="px-6 py-5 border-b border-gray-50 flex items-center justify-between bg-gradient-to-br from-gray-50 to-white">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-xl ${enabled ? 'bg-emerald-500' : 'bg-red-600'} shadow`}><Power size={18} className="text-white" /></div>
<div>
<h2 className="text-base font-black text-gray-900 uppercase tracking-tighter leading-none">Secretária desta sessão</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-1">{enabled ? 'Ligada' : 'Desligada'} · este número</p>
</div>
</div>
<button onClick={() => setOpen(false)} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={20} /></button>
</div>
{erro && <div className="px-6 py-2 text-xs font-bold bg-red-50 text-red-600">{erro}</div>}
<div className="p-6 space-y-4 overflow-y-auto">
{!enabled && sp?.reason && (
<div className="text-xs bg-red-50 text-red-700 rounded-xl p-3">
<span className="font-black">Desligada.</span> Motivo: {sp.reason}
{sp.changed_by && <div className="text-red-400 mt-1">por {sp.changed_by} · {fmt(sp.changed_at)}</div>}
</div>
)}
{/* Motivo (só ao desligar) */}
{enabled && (
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Motivo para desligar</label>
<textarea value={reason} onChange={(e) => setReason(e.target.value)} rows={3}
placeholder="Ex.: revisando as respostas da IA; horário de pico; treinamento…"
className="w-full bg-gray-50 border-2 border-transparent focus:border-red-500 rounded-xl px-3 py-2 text-sm outline-none resize-y" />
</div>
)}
{/* Rate-limit */}
{!sp?.can_toggle && (
<p className="text-[11px] text-amber-600 font-bold flex items-center gap-1.5"><AlertTriangle size={13} /> é possível alternar 1x por hora. Disponível às {fmt(sp?.next_toggle_at)}.</p>
)}
<button onClick={toggle} disabled={busy || !sp?.can_toggle}
className={`w-full py-2.5 rounded-xl text-sm font-black uppercase tracking-wide flex items-center justify-center gap-2 disabled:opacity-40 ${
enabled ? 'bg-red-600 hover:bg-red-700 text-white' : 'bg-emerald-600 hover:bg-emerald-700 text-white'
}`}>
{busy ? <Loader2 size={16} className="animate-spin" /> : <Power size={16} />}
{enabled ? 'Desligar nesta sessão' : 'Ligar nesta sessão'}
</button>
{/* Histórico */}
{!!sp?.history?.length && (
<div>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5 mb-2"><History size={12} /> Histórico</p>
<div className="space-y-1.5 max-h-40 overflow-y-auto">
{sp.history.map((h, i) => (
<div key={i} className="text-[11px] flex items-start gap-2">
<span className={`mt-1 w-1.5 h-1.5 rounded-full shrink-0 ${h.enabled ? 'bg-emerald-500' : 'bg-red-500'}`} />
<div className="min-w-0">
<span className="font-bold text-gray-700">{h.enabled ? 'Ligou' : 'Desligou'}</span>
<span className="text-gray-400"> · {h.by || '—'} · {fmt(h.at)}</span>
{!h.enabled && h.reason && <div className="text-gray-500 leading-snug">{h.reason}</div>}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
</>
);
};
@@ -0,0 +1,198 @@
import React, { useEffect, useState, useCallback } from 'react';
import { ShieldCheck, LayoutGrid, Check, Loader2, Tag, AlertTriangle, Briefcase } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
interface SettingsPanelProps {
instanceId: string | null;
}
// Engines oferecidas no painel. A 'official' (Baileys 6, que dá 401) fica de fora.
// baileys7 → Baileys 7 oficial (a que conecta) → rótulo "API estável"
// infinite → InfinityAPI (com botões)
// titulo = rótulo genérico (todos os usuários). tituloTec = rótulo técnico
// (Baileys/Infinity), exibido apenas para admin/ruibto@gmail.com.
const ENGINES: {
value: string; titulo: string; tituloTec?: string; desc: string; icon: any;
iconCls: string; activeBorder: string; activeBg: string; checkCls: string; btnCls: string;
}[] = [
{
value: 'baileys7', titulo: 'API estável', tituloTec: 'API estável (Baileys 7)',
desc: 'Conexão recomendada do WhatsApp',
icon: ShieldCheck, iconCls: 'text-emerald-500',
activeBorder: 'border-emerald-400', activeBg: 'bg-emerald-50', checkCls: 'text-emerald-600',
btnCls: 'bg-emerald-500 hover:bg-emerald-600',
},
{
value: 'infinite', titulo: 'API com botões', tituloTec: 'InfinityAPI',
desc: 'Com botões interativos (experimental)',
icon: LayoutGrid, iconCls: 'text-amber-500',
activeBorder: 'border-amber-400', activeBg: 'bg-amber-50', checkCls: 'text-amber-600',
btnCls: 'bg-amber-500 hover:bg-amber-600',
},
];
export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
const user = useAuthStore((s) => s.user);
// Termos técnicos (Baileys/Infinity) só para admin ou ruibto@gmail.com.
const canSeeTech = user?.role === 'ADMIN' || user?.email === 'ruibto@gmail.com';
const [engine, setEngine] = useState<string | null>(null);
const [isBusiness, setIsBusiness] = useState<boolean | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState<any>(null);
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8008';
const authHeader = (): Record<string, string> => {
const t = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
return t ? { Authorization: `Bearer ${t}` } : {};
};
// Versão do sistema (servida em /version.json)
useEffect(() => {
fetch('/version.json')
.then((r) => (r.ok ? r.json() : null))
.then(setVersion)
.catch(() => {});
}, []);
// Engine atual da instância
useEffect(() => {
if (!instanceId) return;
setLoading(true);
fetch(`${API_URL}/api/instances`, { headers: authHeader() })
.then((r) => (r.ok ? r.json() : []))
.then((list) => {
const inst = Array.isArray(list) ? list.find((i: any) => i.id === instanceId) : null;
setEngine(inst?.engine ?? null);
setIsBusiness(inst ? !!inst.isBusiness : null);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [instanceId]);
const selectEngine = useCallback(async (value: string) => {
if (!instanceId || saving || value === engine) return;
// Trocar de engine invalida a sessão (credenciais não migram entre APIs):
// confirma porque exige escanear o QR Code novamente.
if (typeof window !== 'undefined' && !window.confirm(
'Trocar de API vai DESCONECTAR a sessão atual.\n\n' +
'Você precisará escanear o QR Code novamente para reconectar. Continuar?'
)) return;
setSaving(value);
setError(null);
try {
const res = await fetch(`${API_URL}/api/instances/${instanceId}/engine`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({ engine: value }),
});
if (res.status === 429) {
const d = await res.json().catch(() => ({}));
throw new Error(d.error ?? 'Aguarde o intervalo entre trocas.');
}
if (!res.ok) throw new Error('Falha ao trocar a API.');
setEngine(value);
} catch (e: any) {
setError(e?.message ?? 'Erro ao trocar a API.');
} finally {
setSaving(null);
}
}, [instanceId, saving, engine]);
return (
<div className="relative z-[1] flex-1 h-full overflow-y-auto bg-[#f0f2f5] p-6 md:p-10">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-light text-[#41525d] mb-2">Configurações</h1>
<p className="text-base text-[#667781] mb-4">Escolha a API de conexão do WhatsApp para esta instância.</p>
<div className="flex items-start gap-3 rounded-xl bg-amber-50 border border-amber-200 p-4 mb-6">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<p className="text-sm text-[#5b4b1f]">
Trocar de API <strong>desconecta a sessão</strong> será necessário <strong>escanear o QR Code novamente</strong> para reconectar.
</p>
</div>
{!instanceId && (
<p className="text-base text-[#667781] mb-6">Selecione uma instância primeiro.</p>
)}
{/* ── Engines ─────────────────────────────────────────────── */}
<div className="flex flex-col gap-4 mb-6">
{ENGINES.map((opt) => {
const Icon = opt.icon;
const ativa = engine === opt.value;
const salvando = saving === opt.value;
return (
<div
key={opt.value}
className={`rounded-2xl border-2 bg-white p-8 transition-colors ${ativa ? `${opt.activeBorder} ${opt.activeBg}` : 'border-[#e9edef]'}`}
>
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${ativa ? 'bg-white' : 'bg-[#f0f2f5]'}`}>
<Icon className={`w-8 h-8 ${opt.iconCls}`} />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-semibold text-[#111b21]">{canSeeTech && opt.tituloTec ? opt.tituloTec : opt.titulo}</h2>
<p className="text-base text-[#667781]">{opt.desc}</p>
</div>
{ativa ? (
<span className={`flex items-center gap-1 font-medium text-lg ${opt.checkCls}`}>
<Check className="w-6 h-6" /> Ativa
</span>
) : (
<button
onClick={() => selectEngine(opt.value)}
disabled={!instanceId || !!saving}
className={`inline-flex items-center gap-2 rounded-xl text-white text-lg font-medium px-6 py-3 disabled:opacity-60 ${opt.btnCls}`}
>
{salvando ? <Loader2 className="w-5 h-5 animate-spin" /> : <Icon className="w-5 h-5" />}
Ativar
</button>
)}
</div>
</div>
);
})}
</div>
{loading && <p className="text-sm text-[#8696a0] mb-4">Carregando configuração atual</p>}
{error && <p className="text-sm text-red-500 mb-4">{error}</p>}
{/* ── Conta Business ──────────────────────────────────────── */}
<div className="rounded-2xl border-2 border-[#e9edef] bg-white p-8 mb-6">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-[#f0f2f5] flex items-center justify-center">
<Briefcase className="w-8 h-8 text-[#54656f]" />
</div>
<div className="flex-1">
<h2 className="text-2xl font-semibold text-[#111b21]">Conta Business</h2>
<p className="text-xl font-medium text-[#41525d] mt-1">
{isBusiness === null ? '—' : isBusiness ? 'Sim (WhatsApp Business)' : 'Não (conta pessoal)'}
</p>
<p className="text-xs text-[#8696a0] mt-1">
Business App Business API botões são garantidos na Business API oficial.
</p>
</div>
</div>
</div>
{/* ── Versão ──────────────────────────────────────────────── */}
<div className="rounded-2xl border-2 border-[#e9edef] bg-white p-8">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-[#f0f2f5] flex items-center justify-center">
<Tag className="w-8 h-8 text-[#54656f]" />
</div>
<div className="flex-1">
<h2 className="text-2xl font-semibold text-[#111b21]">Versão</h2>
<p className="text-3xl font-light text-[#41525d] mt-1">{version?.version ?? '—'}</p>
{version?.buildCount != null && (
<p className="text-base text-[#667781]">build {version.buildCount}</p>
)}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,228 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Heart, Clock, Loader2 } from 'lucide-react';
import { stickerApi, type StickerRecord } from '../services/chatApiService';
import { getStickerFromCache, setStickerInCache } from '../hooks/useStickerCache';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003';
const LIST_VERSION_KEY = 'nw:sticker-list-version';
interface StickerPanelProps {
instanceId: string;
jid: string;
onSend: (blob: Blob) => void;
}
// Converte URL Wasabi → URL do proxy do backend
function stickerSrcUrl(wasabiPath: string): string {
const clean = wasabiPath.startsWith('/') ? wasabiPath.slice(1) : wasabiPath;
return `${API}/api/storage/view/${clean}`;
}
// Carrega sticker: tenta IndexedDB, depois proxy, depois persiste no cache
function useStickerBlob(sticker: StickerRecord) {
const [dataUrl, setDataUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getStickerFromCache(sticker.sha256).then((cached) => {
if (cancelled) return;
if (cached) { setDataUrl(cached); return; }
// Carrega do proxy e converte para dataURL via canvas
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
if (cancelled) return;
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || 96;
canvas.height = img.naturalHeight || 96;
const ctx = canvas.getContext('2d');
if (!ctx) { setDataUrl(img.src); return; }
ctx.drawImage(img, 0, 0);
const url = canvas.toDataURL('image/webp', 0.9);
setDataUrl(url);
setStickerInCache(sticker.sha256, url);
} catch {
setDataUrl(img.src);
}
};
img.onerror = () => { if (!cancelled) setDataUrl(null); };
img.src = stickerSrcUrl(sticker.wasabiPath);
});
return () => { cancelled = true; };
}, [sticker.sha256, sticker.wasabiPath]);
return dataUrl;
}
// Item individual de sticker
function StickerItem({
sticker,
onSend,
onToggleFavorite,
}: {
sticker: StickerRecord;
onSend: (s: StickerRecord) => void;
onToggleFavorite: (s: StickerRecord) => void;
}) {
const dataUrl = useStickerBlob(sticker);
const [hovering, setHovering] = useState(false);
return (
<div
className="relative group cursor-pointer"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={() => dataUrl && onSend(sticker)}
>
<div className="w-16 h-16 flex items-center justify-center rounded-xl hover:bg-black/5 transition-all active:scale-90 p-1">
{dataUrl ? (
<img
src={dataUrl}
alt=""
className="w-full h-full object-contain"
loading="lazy"
/>
) : (
<div className="w-10 h-10 bg-gray-100 rounded-lg animate-pulse" />
)}
</div>
{/* Botão de favoritar — aparece no hover */}
{hovering && (
<button
className={`absolute top-0.5 right-0.5 p-0.5 rounded-full transition-all z-10 ${
sticker.isFavorite
? 'text-red-400 bg-white/90'
: 'text-gray-300 bg-white/80 hover:text-red-400'
}`}
onClick={(e) => { e.stopPropagation(); onToggleFavorite(sticker); }}
title={sticker.isFavorite ? 'Remover dos favoritos' : 'Favoritar'}
>
<Heart className="w-3 h-3" fill={sticker.isFavorite ? 'currentColor' : 'none'} />
</button>
)}
</div>
);
}
export default function StickerPanel({ instanceId, jid, onSend }: StickerPanelProps) {
const [tab, setTab] = useState<'recent' | 'favorites'>('recent');
const [stickers, setStickers] = useState<StickerRecord[]>([]);
const [loading, setLoading] = useState(true);
const fetchedRef = useRef(false);
const load = useCallback(async (force = false) => {
setLoading(true);
try {
const { listVersion, stickers: list } = await stickerApi.list();
if (!force) {
const cached = localStorage.getItem(LIST_VERSION_KEY);
if (cached === listVersion) {
// versão igual — sem novidades, mas ainda mostra o que temos
}
localStorage.setItem(LIST_VERSION_KEY, listVersion);
}
setStickers(list);
} catch {
// silencioso — sem stickers ainda
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
load();
}, [load]);
const handleSend = useCallback(async (sticker: StickerRecord) => {
const dataUrl = await getStickerFromCache(sticker.sha256);
if (!dataUrl) return;
// Converte dataURL → Blob → envia
const res = await fetch(dataUrl);
const blob = await res.blob();
onSend(blob);
}, [onSend]);
const handleToggleFavorite = useCallback(async (sticker: StickerRecord) => {
const result = await stickerApi.toggleFavorite(sticker.id);
setStickers((prev) =>
prev.map((s) => s.id === sticker.id ? { ...s, isFavorite: result.isFavorite } : s)
);
}, []);
const displayed =
tab === 'favorites'
? stickers.filter((s) => s.isFavorite)
: stickers; // recent = all sorted by lastSeenAt (backend already sorts)
return (
<div className="w-72 bg-white rounded-2xl shadow-2xl border border-gray-100 overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-gray-100">
<button
onClick={() => setTab('recent')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
tab === 'recent' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
}`}
>
<Clock className="w-3.5 h-3.5" />
Recentes
</button>
<button
onClick={() => setTab('favorites')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
tab === 'favorites' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
}`}
>
<Heart className="w-3.5 h-3.5" />
Favoritos
</button>
</div>
{/* Content */}
<div className="h-[220px] overflow-y-auto p-2">
{loading ? (
<div className="flex items-center justify-center h-full text-gray-300">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : displayed.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-gray-300">
{tab === 'favorites' ? (
<>
<Heart className="w-8 h-8" />
<span className="text-xs">Nenhum favorito ainda</span>
</>
) : (
<>
<span className="text-3xl">🙂</span>
<span className="text-xs text-center">
As figurinhas que você receber<br />aparecem aqui automaticamente
</span>
</>
)}
</div>
) : (
<div className="grid grid-cols-4 gap-0.5">
{displayed.map((s) => (
<StickerItem
key={s.id}
sticker={s}
onSend={handleSend}
onToggleFavorite={handleToggleFavorite}
/>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,271 @@
'use client';
/**
* TemplateLibrary modal de biblioteca de templates de mensagens.
*
* Permite:
* - Navegar templates por tipo
* - Pesquisar por nome
* - Selecionar um template para usar (retorna o payload ao pai)
* - Deletar templates
* - Ver contagem de uso
*/
import React, { useEffect, useState, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Search, Trash2, Clock, Type, MousePointerClick,
Link, List, BarChart2, LayoutGrid, BookOpen, CheckCircle2,
} from 'lucide-react'
import { templateApi, type MessageTemplate, type TemplateType } from '../services/chatApiService'
// ─── Helpers ─────────────────────────────────────────────────────────────────
const TYPE_META: Record<TemplateType, { label: string; icon: React.ReactNode; color: string; bg: string }> = {
TEXT: { label: 'Texto', icon: <Type className="w-3.5 h-3.5" />, color: '#667781', bg: '#f0f2f5' },
BUTTONS: { label: 'Botões', icon: <MousePointerClick className="w-3.5 h-3.5" />, color: '#0ea5e9', bg: '#e0f2fe' },
INTERACTIVE: { label: 'CTA', icon: <Link className="w-3.5 h-3.5" />, color: '#8b5cf6', bg: '#ede9fe' },
LIST: { label: 'Lista', icon: <List className="w-3.5 h-3.5" />, color: '#f59e0b', bg: '#fef3c7' },
POLL: { label: 'Enquete', icon: <BarChart2 className="w-3.5 h-3.5" />, color: '#10b981', bg: '#d1fae5' },
CAROUSEL: { label: 'Carrossel', icon: <LayoutGrid className="w-3.5 h-3.5" />, color: '#ef4444', bg: '#fee2e2' },
}
function TypeBadge({ type }: { type: TemplateType }) {
const meta = TYPE_META[type]
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold"
style={{ color: meta.color, backgroundColor: meta.bg }}>
{meta.icon}
{meta.label}
</span>
)
}
function PayloadPreview({ template }: { template: MessageTemplate }) {
const p = template.payload
if (template.type === 'TEXT') return <span className="line-clamp-2">{(p as any).text || '—'}</span>
if (template.type === 'BUTTONS') return <span className="line-clamp-1">{(p as any).text || ''} [{((p as any).nativeButtons ?? []).length} botões]</span>
if (template.type === 'INTERACTIVE') return <span className="line-clamp-1">{(p as any).text || ''} [{((p as any).nativeButtons ?? []).length} CTAs]</span>
if (template.type === 'LIST') return <span className="line-clamp-1">{(p as any).text || ''} {(p as any).nativeList?.buttonText}</span>
if (template.type === 'POLL') return <span className="line-clamp-1">📊 {(p as any).poll?.name} ({((p as any).poll?.values ?? []).length} opções)</span>
if (template.type === 'CAROUSEL') return <span className="line-clamp-1">🎠 {((p as any).nativeCarousel?.cards ?? []).length} cards</span>
return null
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface Props {
isOpen: boolean
onClose: () => void
onSelect: (template: MessageTemplate) => void
}
// ─── Component ────────────────────────────────────────────────────────────────
const ALL = 'ALL'
type FilterType = TemplateType | typeof ALL
export default function TemplateLibrary({ isOpen, onClose, onSelect }: Props) {
const [templates, setTemplates] = useState<MessageTemplate[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<FilterType>(ALL)
const [deleting, setDeleting] = useState<string | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
const load = useCallback(async () => {
setLoading(true)
try {
const data = await templateApi.list({
...(filter !== ALL ? { type: filter } : {}),
...(search ? { search } : {}),
})
setTemplates(data)
} finally {
setLoading(false)
}
}, [filter, search])
useEffect(() => {
if (isOpen) load()
}, [isOpen, load])
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
if (!confirm('Remover este template?')) return
setDeleting(id)
try {
await templateApi.delete(id)
setTemplates(prev => prev.filter(t => t.id !== id))
} finally {
setDeleting(null)
}
}
const handleSelect = (template: MessageTemplate) => {
setSelected(template.id)
setTimeout(() => {
onSelect(template)
setSelected(null)
onClose()
}, 200)
}
const filterTabs: Array<{ key: FilterType; label: string }> = [
{ key: ALL, label: 'Todos' },
...Object.entries(TYPE_META).map(([k, v]) => ({ key: k as TemplateType, label: v.label })),
]
if (!mounted) return null
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 30, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-[#e0f2fe] flex items-center justify-center">
<BookOpen className="w-5 h-5 text-[#0ea5e9]" />
</div>
<div>
<h2 className="text-base font-semibold text-[#111b21]">Biblioteca de Templates</h2>
<p className="text-xs text-[#667781]">{templates.length} template{templates.length !== 1 ? 's' : ''} salvos</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Search */}
<div className="px-5 py-3 border-b border-gray-100 shrink-0">
<div className="relative">
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
<input
className="w-full pl-9 pr-4 py-2 rounded-xl bg-[#f0f2f5] border-none text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30"
placeholder="Pesquisar templates..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
</div>
{/* Filter tabs */}
<div className="px-5 py-2 flex gap-1.5 overflow-x-auto shrink-0 border-b border-gray-100 pb-2">
{filterTabs.map(tab => (
<button
key={tab.key}
onClick={() => setFilter(tab.key)}
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${
filter === tab.key
? 'bg-[#111b21] text-white'
: 'bg-[#f0f2f5] text-[#667781] hover:bg-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Template list */}
<div className="flex-1 overflow-y-auto p-5">
{loading ? (
<div className="flex items-center justify-center py-16">
<span className="w-6 h-6 border-2 border-gray-200 border-t-[#00a884] rounded-full animate-spin" />
</div>
) : templates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<BookOpen className="w-12 h-12 text-gray-200 mb-3" />
<p className="text-sm font-medium text-[#667781]">Nenhum template encontrado</p>
<p className="text-xs text-gray-400 mt-1">Crie um template no compositor de mensagens</p>
</div>
) : (
<div className="space-y-2">
{templates.map(tmpl => (
<motion.button
key={tmpl.id}
layout
onClick={() => handleSelect(tmpl)}
className={`w-full text-left rounded-xl border p-4 transition-all hover:shadow-md active:scale-[0.99] group ${
selected === tmpl.id
? 'border-[#00a884] bg-[#e7f8f2]'
: 'border-gray-200 hover:border-gray-300 bg-white'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<TypeBadge type={tmpl.type} />
{tmpl.usageCount > 0 && (
<span className="flex items-center gap-1 text-[10px] text-gray-400">
<Clock className="w-3 h-3" />
{tmpl.usageCount}x usado
</span>
)}
</div>
<p className="text-sm font-semibold text-[#111b21] truncate">{tmpl.name}</p>
{tmpl.description && (
<p className="text-xs text-[#667781] mt-0.5 line-clamp-1">{tmpl.description}</p>
)}
<p className="text-xs text-gray-400 mt-1.5 line-clamp-2">
<PayloadPreview template={tmpl} />
</p>
{tmpl.tags.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{tmpl.tags.map(tag => (
<span key={tag} className="px-1.5 py-0.5 bg-gray-100 text-[10px] text-gray-500 rounded">
#{tag}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{selected === tmpl.id ? (
<CheckCircle2 className="w-5 h-5 text-[#00a884]" />
) : (
<>
<button
onClick={(e) => handleDelete(e, tmpl.id)}
disabled={deleting === tmpl.id}
className="p-1.5 rounded-full opacity-0 group-hover:opacity-100 hover:bg-red-50 text-gray-400 hover:text-red-500 transition-all"
>
{deleting === tmpl.id
? <span className="w-3.5 h-3.5 border border-gray-300 border-t-gray-500 rounded-full animate-spin block" />
: <Trash2 className="w-3.5 h-3.5" />
}
</button>
</>
)}
</div>
</div>
</motion.button>
))}
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
)
}
@@ -0,0 +1,110 @@
import React from 'react';
import {
MessageCircle,
Megaphone,
Users,
Settings,
BrainCircuit,
} from 'lucide-react';
import { getAvatarProxyUrl } from '../utils/inboxUtils';
import { SessaoSecretariaPower } from './SessaoSecretariaPower';
export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings';
interface ThinSidebarProps {
activeTab: TabType;
setActiveTab: (tab: TabType) => void;
activeInstance: any; // We'll pass the active instance to show the avatar
onNavigate?: (view: string) => void; // satélite: navegação entre as views do plugin
}
export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, onNavigate }: ThinSidebarProps) {
const isConnected = activeInstance?.status === 'connected' || activeInstance?.status === 'open';
const renderIcon = (id: TabType, IconComponent: any, title: string) => {
const isActive = activeTab === id;
return (
<button
key={id}
title={title}
onClick={() => {
if (id === 'groups') {
onNavigate?.('wa-sessions');
} else {
setActiveTab(id);
}
}}
className={`w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors ${
isActive ? 'bg-[#d9dbdf]' : 'hover:bg-[#d9dbdf]/60'
}`}
>
<IconComponent
className={`w-6 h-6 ${isActive ? 'text-[#111b21]' : 'text-[#54656f]'}`}
fill={isActive ? 'currentColor' : 'none'}
/>
</button>
);
};
return (
<div className="w-[60px] h-full bg-[#f0f2f5] border-r border-[#d1d7db] flex flex-col items-center py-4 flex-shrink-0 z-30">
{/* Top Icons */}
<div className="flex-1 w-full flex flex-col items-center">
{renderIcon('chats', MessageCircle, 'Conversas')}
{renderIcon('broadcasts', Megaphone, 'Transmissões')}
{renderIcon('groups', Users, 'Sessões')}
{/* Secretária IA — abre a tela de configuração da secretária virtual */}
<button
title="Secretária IA"
onClick={() => onNavigate?.('wa-secretaria')}
className="w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors hover:bg-[#d9dbdf]/60"
>
<BrainCircuit className="w-6 h-6 text-[#54656f]" />
</button>
</div>
{/* Bottom Icons */}
<div className="w-full flex flex-col items-center pb-2">
{/* Liga/desliga a Secretária IA APENAS desta sessão (número atual) */}
<SessaoSecretariaPower instanceId={activeInstance?.instance ?? null} />
{/* Configurações — abre o painel na área central (aba 'settings') */}
{renderIcon('settings', Settings, 'Configurações')}
<div className="relative mt-2 p-1 cursor-pointer">
<div className="w-8 h-8 rounded-full overflow-hidden bg-gray-200 border border-gray-300">
{(() => {
// Avatar da instância SEMPRE via proxy do backend. Nunca usar a URL
// crua do CDN do WhatsApp (pps.whatsapp.net) como fallback: ela expira
// e o navegador recebe 403 (hotlink). Sem proxy resolvível → ícone.
const avatarSrc = activeInstance?.instance
? getAvatarProxyUrl({
instance_name: activeInstance.instance,
remote_jid: activeInstance.phone ? `${activeInstance.phone}@s.whatsapp.net` : null,
})
: null;
return avatarSrc ? (
<img
src={avatarSrc}
alt="Status"
className="w-full h-full object-cover"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-[#dfe5e7] text-[#54656f]">
<Users className="w-5 h-5" />
</div>
);
})()}
</div>
{/* Status indicator badge */}
<div
className={`absolute bottom-0.5 right-0.5 w-3 h-3 rounded-full border-2 border-[#f0f2f5] ${
isConnected ? 'bg-emerald-500' : 'bg-red-500'
}`}
title={isConnected ? 'Conectado' : 'Desconectado'}
></div>
</div>
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
// Button do motor NewWhats (de @/components/ui) — portado para o satélite.
import React from 'react';
import { Loader2 } from 'lucide-react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'premium';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
icon?: React.ReactNode;
children?: React.ReactNode;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-brand-500 text-white font-bold shadow-lg shadow-brand-500/20 hover:bg-brand-600 active:scale-[0.98]',
secondary: 'bg-white/5 border border-white/10 text-white font-semibold hover:bg-white/10 backdrop-blur-sm',
ghost: 'text-slate-400 hover:text-white hover:bg-white/5',
danger: 'bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20',
premium: 'bg-gradient-to-r from-brand-500 to-blue-500 text-white font-bold shadow-glow-brand hover:shadow-lg active:scale-[0.98] relative overflow-hidden',
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-xs rounded-lg gap-1.5',
md: 'px-4 py-2.5 text-sm rounded-xl gap-2',
lg: 'px-6 py-3.5 text-sm rounded-xl gap-2',
};
export function Button({
variant = 'primary',
size = 'md',
loading,
icon,
children,
className = '',
disabled,
...props
}: ButtonProps) {
return (
<button
disabled={disabled || loading}
className={`
inline-flex items-center justify-center transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
${variantClasses[variant]} ${sizeClasses[size]} ${className}
`}
{...props}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon ? icon : null}
{children}
{variant === 'premium' && (
<div className="absolute inset-0 -translate-x-full animate-shimmer bg-gradient-to-r from-transparent via-white/10 to-transparent pointer-events-none" />
)}
</button>
);
}
export default Button;
@@ -0,0 +1,147 @@
'use client';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { getAvatarInitials, getAvatarColor } from '../../utils/inboxUtils';
import { getAvatarFromCache, setAvatarInCache } from '../../hooks/useAvatarCache';
import { avatarApi } from '../../services/chatApiService';
interface AvatarProps {
chat: any;
size?: number;
className?: string;
}
export default function Avatar({ chat, size = 46, className = '' }: AvatarProps) {
const [displayUrl, setDisplayUrl] = useState<string | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
const [showInitials, setShowInitials] = useState(false);
const cacheAttempted = useRef(false);
const refreshTried = useRef(false);
const imageUrl = chat?.avatar_url || null;
const version = chat?.avatar_version || null;
const remoteJid = chat?.remote_jid || '';
const instanceName = chat?.instance_name || '';
// Quando jid ou version muda, tenta servir do cache IndexedDB antes de fazer request HTTP.
// Se não houver cache (primeira vez), usa a URL do proxy normalmente.
useEffect(() => {
cacheAttempted.current = false;
refreshTried.current = false;
setIsLoaded(false);
if (!imageUrl) {
setShowInitials(true);
setDisplayUrl(null);
return;
}
setShowInitials(false);
// Sem versão (ex.: satélite, cujo ext /inbox não envia avatar_version) →
// usa a URL do CDN direto, sem cache IndexedDB (o cache depende da versão
// para invalidar). A imagem ainda carrega normalmente.
if (!version) {
setDisplayUrl(imageUrl);
cacheAttempted.current = true;
return;
}
// Tenta cache IndexedDB — resultado síncrono do ponto de vista do usuário
getAvatarFromCache(remoteJid, version).then((cached) => {
if (cached) {
setDisplayUrl(cached);
setIsLoaded(true); // já temos os bytes — sem skeleton
} else {
setDisplayUrl(imageUrl); // busca pelo proxy normalmente
}
cacheAttempted.current = true;
}).catch(() => {
setDisplayUrl(imageUrl);
cacheAttempted.current = true;
});
}, [remoteJid, version, imageUrl]);
const handleLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
setIsLoaded(true);
// Salva no IndexedDB após primeira carga bem-sucedida do proxy.
// Só salva se a versão é conhecida e o displayUrl não é já um dataUrl (evita re-cache).
if (!version || !remoteJid || !displayUrl || displayUrl.startsWith('data:')) return;
try {
const img = e.currentTarget;
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || size;
canvas.height = img.naturalHeight || size;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
setAvatarInCache(remoteJid, version, dataUrl);
} catch { /* CORS ou canvas tainted — ignora, cache não é crítico */ }
};
const handleError = async () => {
// Self-heal: a URL do CDN do WhatsApp pode ter expirado (404). Tenta UMA
// vez re-buscar a foto atual pela conexão viva do motor antes de desistir.
if (!refreshTried.current && instanceName && remoteJid) {
refreshTried.current = true;
const fresh = await avatarApi.refresh(instanceName, remoteJid);
if (fresh && fresh !== displayUrl) {
setIsLoaded(false);
setDisplayUrl(fresh);
return;
}
}
setShowInitials(true);
setDisplayUrl(null);
};
const initials = useMemo(() => getAvatarInitials(chat), [chat]);
const bgColor = useMemo(
() => getAvatarColor(chat?.remote_jid || chat?.phone || chat?.displayName || 'anon'),
[chat]
);
return (
<div
className={`relative flex-shrink-0 rounded-full overflow-hidden bg-gray-100 flex items-center justify-center border border-black/5 ${className}`}
style={{ width: size, height: size }}
>
<AnimatePresence mode="wait">
{!showInitials && displayUrl ? (
<motion.img
key={displayUrl}
src={displayUrl}
alt=""
loading="lazy"
decoding="async"
crossOrigin="anonymous"
initial={{ opacity: 0 }}
animate={{ opacity: isLoaded ? 1 : 0 }}
transition={{ duration: 0.3 }}
onLoad={handleLoad}
onError={handleError}
className="w-full h-full object-cover"
/>
) : (
<motion.div
key="initials"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="w-full h-full flex items-center justify-center text-white font-semibold"
style={{ backgroundColor: bgColor, fontSize: size * 0.4 }}
>
{initials}
</motion.div>
)}
</AnimatePresence>
{/* Skeleton apenas quando buscando pelo proxy (não quando servindo do cache) */}
{!isLoaded && !showInitials && displayUrl && !displayUrl.startsWith('data:') && (
<div className="absolute inset-0 bg-gray-200 animate-pulse" />
)}
</div>
);
}
@@ -0,0 +1,35 @@
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, AlertCircle } from 'lucide-react';
interface ToastProps {
message: string;
type: 'success' | 'error';
visible: boolean;
}
export default function Toast({ message, type, visible }: ToastProps) {
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 40 }}
className={`
fixed top-6 right-6 z-[300] flex items-center gap-3 px-5 py-3.5 rounded-2xl
glass-elevated shadow-premium text-sm font-semibold text-white
${type === 'success' ? 'border-l-4 border-l-emerald-500' : 'border-l-4 border-l-red-500'}
`}
>
{type === 'success' ? (
<Check className="w-4 h-4 text-emerald-400" />
) : (
<AlertCircle className="w-4 h-4 text-red-400" />
)}
{message}
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,87 @@
/**
* useChatFilter gerencia toda a lógica de filtro/escopo de chats no inbox.
*
* Extrai de inbox/index.tsx:
* - searchTerm, activeFilter, chatScope, favoriteJids
* - derivados: favoriteSet, filterCounts, chatsByScope, isFavoriteChat
*/
import { useState, useMemo, useCallback } from 'react'
import { normalizeJid } from '../../utils/inboxUtils'
import type { NewWhatsChat } from '../../types/inboxTypes'
export type ChatScope = 'inbox' | 'favorites' | 'archived'
export interface CustomChatList { id: string; name: string; jids: string[] }
export function useChatFilter(chatsStore: NewWhatsChat[]) {
const [searchTerm, setSearchTerm] = useState('')
const [activeFilter, setActiveFilter] = useState('all')
const [chatScope, setChatScope] = useState<ChatScope>('inbox')
const [favoriteJids, setFavoriteJids] = useState<string[]>([])
const [customLists, setCustomLists] = useState<CustomChatList[]>([])
const [activeCustomListId, setActiveCustomListId] = useState<string | null>(null)
// ── Derivados ──────────────────────────────────────────────────────────────
const favoriteSet = useMemo(() => new Set(favoriteJids), [favoriteJids])
const filterCounts = useMemo(() => ({
all: chatsStore.length,
unread: chatsStore.filter(c => c.unread_count > 0).length,
groups: chatsStore.filter(c => c.remote_jid.endsWith('@g.us')).length,
pinned: chatsStore.filter(c => c.is_pinned).length,
archived: chatsStore.filter(c => c.is_archived).length,
}), [chatsStore])
const chatsByScope = useMemo(() => {
let base = chatsStore
if (chatScope === 'favorites') {
base = base.filter(c => favoriteSet.has(normalizeJid(c.remote_jid) || c.remote_jid))
} else if (chatScope === 'archived') {
base = base.filter(c => Boolean(c.is_archived))
} else {
base = base.filter(c => !Boolean(c.is_archived))
}
if (activeFilter === 'unread') return base.filter(c => c.unread_count > 0)
if (activeFilter === 'groups') return base.filter(c => c.remote_jid.endsWith('@g.us'))
if (activeFilter === 'pinned') return base.filter(c => c.is_pinned)
return base
}, [chatScope, chatsStore, favoriteSet, activeFilter])
const isFavoriteChat = useCallback(
(chat: NewWhatsChat) => favoriteSet.has(normalizeJid(chat.remote_jid) || chat.remote_jid),
[favoriteSet]
)
const toggleFavorite = useCallback((jid: string) => {
const normalized = normalizeJid(jid) || jid
setFavoriteJids(prev =>
prev.includes(normalized) ? prev.filter(j => j !== normalized) : [...prev, normalized]
)
}, [])
return {
// Estado
searchTerm,
activeFilter,
chatScope,
favoriteJids,
customLists,
activeCustomListId,
// Setters
setSearchTerm,
setActiveFilter,
setChatScope,
setCustomLists,
setActiveCustomListId,
// Derivados
favoriteSet,
filterCounts,
chatsByScope,
isFavoriteChat,
toggleFavorite,
}
}
@@ -0,0 +1,144 @@
/**
* useChatWindow gerencia o estado e handlers da área de chat ativa.
*
* Extrai de inbox/index.tsx:
* - scroll + carregamento de histórico
* - indicador de digitação
* - reply / reaction
* - envio de mídia e áudio
* - sync de avatar
* - abertura de perfil / menu de chat
*/
import { useState, useRef, useCallback } from 'react'
import { mediaApi } from '../../services/chatApiService'
import type { NewWhatsChat } from '../../types/inboxTypes'
import type { Message } from '../../types/inboxTypes'
interface UseChatWindowOptions {
selectedChat: NewWhatsChat | null
activeInstanceId: string | null
onLoadMoreHistory: () => Promise<{ hasMore: boolean }>
}
export function useChatWindow({
selectedChat,
activeInstanceId,
onLoadMoreHistory,
}: UseChatWindowOptions) {
// ── Refs ────────────────────────────────────────────────────────────────────
const scrollRef = useRef<HTMLDivElement>(null)
const isTypingRef = useRef(false)
const typingTimeout = useRef<NodeJS.Timeout | null>(null)
// ── Estado ──────────────────────────────────────────────────────────────────
const [isTyping, setIsTyping] = useState(false)
const [replyingTo, setReplyingTo] = useState<Message | null>(null)
const [reactionPickerFor, setReactionPickerFor] = useState<string | null>(null)
const [hasMoreHistory, setHasMoreHistory] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [isProfileOpen, setIsProfileOpen] = useState(false)
const [isSyncingAvatar, setIsSyncingAvatar] = useState(false)
const [isChatMenuOpen, setIsChatMenuOpen] = useState(false)
// ── Handlers ────────────────────────────────────────────────────────────────
const handleTyping = useCallback(() => {
if (!selectedChat) return
if (!isTypingRef.current) {
isTypingRef.current = true
setIsTyping(true)
}
if (typingTimeout.current) clearTimeout(typingTimeout.current)
typingTimeout.current = setTimeout(() => {
isTypingRef.current = false
setIsTyping(false)
}, 3000)
}, [selectedChat])
const handleScroll = useCallback(async () => {
const el = scrollRef.current
if (!el || !hasMoreHistory || loadingMore) return
if (el.scrollTop < 100) {
setLoadingMore(true)
const { hasMore } = await onLoadMoreHistory()
setHasMoreHistory(hasMore)
setLoadingMore(false)
}
}, [hasMoreHistory, loadingMore, onLoadMoreHistory])
const handleSelectChat = useCallback(() => {
setHasMoreHistory(true)
}, [])
const handleReactToMessage = useCallback((_msg: Message, _emoji: string) => {
setReactionPickerFor(null)
// Será implementado via endpoint /api/instances/:id/react
}, [])
const handleSendMedia = useCallback(async (file: File, caption?: string) => {
if (!selectedChat || !activeInstanceId) return
try {
await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, caption || undefined, undefined)
} catch (err) {
console.error('Erro ao enviar mídia:', err)
}
}, [selectedChat, activeInstanceId])
const handleSendAudio = useCallback(async (blob: Blob) => {
if (!selectedChat || !activeInstanceId) return
try {
await mediaApi.sendAudio(activeInstanceId, selectedChat.remote_jid, blob)
} catch (err) {
console.error('Erro ao enviar áudio:', err)
}
}, [selectedChat, activeInstanceId])
const handleSyncAvatar = useCallback(async () => {
if (!selectedChat || !activeInstanceId) return
setIsSyncingAvatar(true)
try {
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003'
const token = localStorage.getItem('token')
await fetch(
`${API}/api/inbox/avatar/${encodeURIComponent(selectedChat.remote_jid)}?instance=${activeInstanceId}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
} finally {
setIsSyncingAvatar(false)
}
}, [selectedChat, activeInstanceId])
const toggleReactionPicker = useCallback((id: string) => {
setReactionPickerFor(prev => prev === id ? null : id)
}, [])
return {
// Refs
scrollRef,
// Estado
isTyping,
replyingTo,
reactionPickerFor,
hasMoreHistory,
loadingMore,
isProfileOpen,
isSyncingAvatar,
isChatMenuOpen,
// Setters simples
setReplyingTo,
setIsProfileOpen,
setIsChatMenuOpen,
// Handlers
handleTyping,
handleScroll,
handleSelectChat,
handleReactToMessage,
handleSendMedia,
handleSendAudio,
handleSyncAvatar,
toggleReactionPicker,
}
}
@@ -0,0 +1,129 @@
/**
* useInstanceSelector gerencia seleção e troca de instâncias WhatsApp.
*
* Extrai toda a lógica de instâncias do inbox/index.tsx.
* O componente visual consome apenas as variáveis retornadas.
*/
import { useRef, useCallback, useMemo, useEffect } from 'react'
import { useInstanceStore } from '../../store/instanceStore'
import { useChatStore } from '../../store/chatStore'
import { socketService } from '../../services/socketService'
// ─── Tipos ────────────────────────────────────────────────────────────────────
export interface LegacyInstance {
instance: string
nome: string
status: string
phone: string | null
avatar: string | null
profilePictureUrl: string | null
}
export interface MappedInstance {
id: string
nome: string
avatar: string | null
phone: string | null
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useInstanceSelector() {
const instances = useInstanceStore((s) => s.instances)
const activeId = useInstanceStore((s) => s.activeInstanceId)
const setActiveId = useInstanceStore((s) => s.setActiveInstance)
const loadInstances = useInstanceStore((s) => s.loadInstances)
const connectInstance = useInstanceStore((s) => s.connectInstance)
const qrByInstance = useInstanceStore((s) => s.qrByInstance)
const resetChats = useChatStore((s) => s.setChats)
const dropdownRef = useRef<HTMLDivElement>(null)
// ── Formato legado esperado pelo JSX do inbox ──────────────────────────────
const instancesLegacy = useMemo<LegacyInstance[]>(() =>
instances.map((i) => ({
instance: i.id,
nome: i.name,
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
phone: i.phone,
avatar: i.avatar ?? null,
profilePictureUrl: i.avatar ?? null,
})),
[instances]
)
const activeInstance = useMemo<LegacyInstance | null>(
() => instancesLegacy.find((i) => i.instance === activeId) ?? null,
[instancesLegacy, activeId]
)
// Versão com `id` usada por alguns componentes internos
const mappedActiveInstance = useMemo<MappedInstance | null>(
() => activeInstance
? { id: activeInstance.instance, nome: activeInstance.nome, avatar: activeInstance.avatar ?? null, phone: activeInstance.phone }
: null,
[activeInstance]
)
// Apenas instâncias conectadas (para o dropdown de troca rápida)
const connectedInstances = useMemo<MappedInstance[]>(() =>
instances
.filter((i) => i.status === 'CONNECTED')
.map((i) => ({ id: i.id, nome: i.name, avatar: i.avatar ?? null, phone: i.phone })),
[instances]
)
const isConnected = activeInstance?.status === 'connected'
// ── Auto-select primeira instância disponível ──────────────────────────────
useEffect(() => {
if (!activeId && instances.length > 0) {
const first = instances[0]
setActiveId(first.id)
socketService.joinInstance(first.id)
}
}, [instances, activeId, setActiveId])
// ── Troca de instância ────────────────────────────────────────────────────
const selectInstance = useCallback((legacyInstance: LegacyInstance | { instance: string }) => {
const id = legacyInstance.instance
if (id === activeId) return
resetChats([])
setActiveId(id)
socketService.joinInstance(id)
}, [activeId, setActiveId, resetChats])
// ── Refresh ────────────────────────────────────────────────────────────────
const refreshInstances = useCallback(async () => {
await loadInstances()
}, [loadInstances])
const handleConnect = useCallback(async (instanceId: string) => {
await connectInstance(instanceId)
}, [connectInstance])
return {
// Estado
instances: instancesLegacy,
activeInstance,
mappedActiveInstance,
connectedInstances,
isConnected,
qrByInstance,
activeInstanceId: activeId,
// Refs (para click-outside no dropdown)
dropdownRef,
// Handlers
selectInstance,
refreshInstances,
handleConnect,
}
}
@@ -0,0 +1,41 @@
/**
* useMessageInput gerencia o estado e envio da barra de mensagem.
*
* Extrai de inbox/index.tsx:
* - newMessage + setNewMessage
* - handleSendMessage
*/
import { useState, useCallback } from 'react'
import type { NewWhatsChat } from '../../types/inboxTypes'
interface UseMessageInputOptions {
selectedChat: NewWhatsChat | null
replyingTo?: { message_id: string } | null
onClearReply?: () => void
onSendText: (text: string, mentions?: string[], replyToMessageId?: string) => Promise<void>
}
export function useMessageInput({ selectedChat, replyingTo, onClearReply, onSendText }: UseMessageInputOptions) {
const [newMessage, setNewMessage] = useState('')
const [mentions, setMentions] = useState<string[]>([])
const handleSendMessage = useCallback(async (e?: React.FormEvent) => {
e?.preventDefault()
if (!selectedChat || !newMessage.trim()) return
const text = newMessage
const mentionJids = mentions.length > 0 ? [...mentions] : undefined
const replyToMessageId = replyingTo?.message_id
setNewMessage('')
setMentions([])
onClearReply?.()
await onSendText(text, mentionJids, replyToMessageId)
}, [selectedChat, newMessage, mentions, replyingTo, onSendText, onClearReply])
return {
newMessage,
setNewMessage,
mentions,
setMentions,
handleSendMessage,
}
}
@@ -0,0 +1,287 @@
/**
* useNewInboxBridge adapta chatStore + instanceStore para o formato
* que os componentes de display legados consomem.
* Nenhum componente visual precisa ser alterado.
*/
import { useMemo, useCallback, useEffect, useRef } from 'react'
import { useChatStore } from '../../store/chatStore'
import { useInstanceStore } from '../../store/instanceStore'
import { chatApi, messageApi, reconcileApi } from '../../services/chatApiService'
import { socketService } from '../../services/socketService'
import { formatPhone } from '../../utils/inboxUtils'
import type { NewWhatsChat } from '../../types/inboxTypes'
// ─── Mapeamentos legado ───────────────────────────────────────────────────────
function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]): NewWhatsChat {
const isGroup = c.jid.endsWith('@g.us')
const jidUser = c.jid.split('@')[0]
// Telefone formatado — sempre passa por formatPhone para normalizar E.164 → exibição
// c.contactPhone é o número cru do DB (ex: "5511999887766"); formatPhone aplica máscara
const formattedPhone = isGroup ? jidUser : formatPhone(c.contactPhone ?? jidUser, c.jid)
// 1. Nome de Exibição
// Regra estrita: grupos usam SEMPRE o subject (c.name) — nunca o
// pushName/contactName do último participante que enviou mensagem.
let displayName = isGroup
? (c.name || 'Grupo')
: (c.contactName || c.name || formattedPhone || jidUser)
// Limpeza final: se por algum motivo ainda vier o JID completo, removemos o sufixo
if (displayName.includes('@')) {
displayName = displayName.split('@')[0]
}
// 2. Avatar — o motor (ext /inbox) já devolve a URL pública do CDN do
// WhatsApp (pps.whatsapp.net) em contactAvatarUrl; usamos direto (o <img>
// carrega cross-origin sem CORS). Quando é null, Avatar.tsx mostra iniciais.
const avatar_url: string | null = c.contactAvatarUrl ?? null
const avatar_version: string | null = c.contactAvatarVersion ?? null
return {
id: c.id as any,
instance_name: c.instanceId,
remote_jid: c.jid,
phone: formattedPhone,
lead_name: c.contactName,
avatar_url,
avatar_version,
bio: isGroup ? 'Detalhes do Grupo' : (formattedPhone || 'Clique para informações'),
displayName,
subject: c.protocolNumber ? `#${c.protocolNumber}` : (isGroup ? null : formattedPhone),
last_message_text: c.lastMessageBody || null,
last_message_time: c.lastMessageAt || c.createdAt,
last_message_from_me: c.lastMessageFromMe ? 1 : 0,
last_message_status: c.lastMessageStatus?.toLowerCase() ?? null,
last_message_type: c.lastMessageType?.toLowerCase() ?? null,
last_message_sender: c.lastMessageSender ?? null,
unread_count: c.unreadCount,
is_pinned: c.isPinned,
is_archived: c.isArchived,
bot_paused: c.botPaused ?? null,
labels: [],
}
}
function messagesToLegacy(msgs: ReturnType<typeof useChatStore.getState>['messages'][string]) {
return (msgs ?? []).map((m) => ({
id: m.id as any,
chat_id: m.chatId as any,
message_id: m.messageId,
text: m.body ?? '',
direction: (m.fromMe ? 'out' : 'in') as 'in' | 'out',
timestamp: new Date(m.timestamp).getTime(),
status: m.status.toLowerCase(),
type: m.type.toLowerCase(),
media_url: m.mediaUrl,
// false só para mídias legadas sem WAMessage salvo (fromMe irrecuperável); default
// true para não bloquear mensagens vindas por outros caminhos (socket, etc.).
media_recoverable: (m as any).mediaRecoverable ?? true,
transcription: (m as any).transcription ?? null,
mime_type: m.mimeType,
reply_to_id: m.replyToId,
// Quoted message — mapeia replyTo do Prisma para o formato do MessageBubble
quoted_id: m.replyTo?.id ?? null,
quoted_message_id: m.replyTo?.messageId ?? null,
quoted_message_text: m.replyTo?.body ?? null,
quoted_participant: m.replyTo ? (m.replyTo.fromMe ? 'me' : null) : null,
participant: m.pushName ?? null,
senderJid: m.senderJid ?? null,
sender_jid: m.senderJid ?? null,
remote_jid: m.chatId,
from_me: m.fromMe ? 1 : 0,
rich_payload: (m as any).richPayload ?? null,
}))
}
// ─── Hook principal ───────────────────────────────────────────────────────────
export function useNewInboxBridge() {
const initialized = useRef(false)
const chatStore = useChatStore()
const instanceStore = useInstanceStore()
// ── Boot: socket + instâncias ─────────────────────────────────────────────
useEffect(() => {
if (initialized.current) return
initialized.current = true
// Satélite: o socketService lê o token do scoreodonto internamente.
socketService.connect()
chatStore.initSocketListeners()
instanceStore.initSocketListeners()
instanceStore.loadInstances()
}, [])
// ── Carregar chats quando instância ativa muda ─────────────────────────────
useEffect(() => {
const { activeInstanceId } = instanceStore
if (!activeInstanceId) return
chatStore.loadChats(activeInstanceId)
socketService.joinInstance(activeInstanceId)
// Reconcilia nomes em background (cache 30min — leve em escala)
reconcileApi.reconcileNames(activeInstanceId).catch(() => {})
}, [instanceStore.activeInstanceId])
// ── Adaptação: instâncias → legado ────────────────────────────────────────
const instancesLegacy = useMemo(
() =>
instanceStore.instances.map((i) => ({
instance: i.id,
nome: i.name,
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
phone: i.phone,
avatar: null,
profilePictureUrl: null,
})),
[instanceStore.instances]
)
const activeInstanceLegacy = useMemo(
() => instancesLegacy.find((i) => i.instance === instanceStore.activeInstanceId) ?? null,
[instancesLegacy, instanceStore.activeInstanceId]
)
// ── Adaptação: chats → legado ─────────────────────────────────────────────
const chatsLegacy = useMemo(() => chatStore.chats.map(chatToLegacy), [chatStore.chats])
const selectedChatLegacy = useMemo(() => {
if (!chatStore.activeChatId) return null
const c = chatStore.chats.find((c) => c.id === chatStore.activeChatId)
return c ? chatToLegacy(c) : null
}, [chatStore.activeChatId, chatStore.chats])
const messagesLegacy = useMemo(() => {
if (!chatStore.activeChatId) return []
const raw = chatStore.messages[chatStore.activeChatId] ?? []
const mapped = messagesToLegacy(raw)
console.log('[bridge] messagesLegacy recomputed:', { activeChatId: chatStore.activeChatId, rawCount: raw.length, mappedCount: mapped.length })
return mapped
}, [chatStore.activeChatId, chatStore.messages])
// ── Handlers ──────────────────────────────────────────────────────────────
const handleSelectChat = useCallback(async (chat: NewWhatsChat | null) => {
if (!chat) { useChatStore.getState().setActiveChat(null); return }
await chatStore.selectChat(String(chat.id))
// Assina a presença do contato (composing/recording) — só assim o motor emite.
chatApi.subscribePresence(String(chat.id)).catch(() => {})
}, [chatStore])
const handleSendText = useCallback(async (text: string, mentions?: string[], replyToMessageId?: string) => {
if (!chatStore.activeChatId || !instanceStore.activeInstanceId) return
const chat = chatStore.chats.find((c) => c.id === chatStore.activeChatId)
if (!chat) return
await chatStore.sendMessage(instanceStore.activeInstanceId, chat.jid, text, replyToMessageId, mentions)
}, [chatStore, instanceStore.activeInstanceId])
const handleLoadMoreHistory = useCallback(async () => {
if (!chatStore.activeChatId) return { hasMore: false }
const oldest = chatStore.messages[chatStore.activeChatId]?.[0]
if (!oldest) return { hasMore: false }
return chatStore.loadMessages(chatStore.activeChatId, { before: oldest.timestamp })
}, [chatStore])
const handleSetActiveInstance = useCallback((legacyInstance: any) => {
instanceStore.setActiveInstance(legacyInstance?.instance ?? null)
}, [instanceStore])
const handleRefreshInstances = useCallback(async () => {
await instanceStore.loadInstances()
}, [instanceStore])
const handleConnectInstance = useCallback(async (instanceId: string) => {
await instanceStore.connectInstance(instanceId)
}, [instanceStore])
const handleArchiveChat = useCallback(async (chatId: string, archived: boolean) => {
await chatApi.archive(chatId, archived)
useChatStore.getState().patchChat(chatId, { isArchived: archived })
}, [])
const handlePinChat = useCallback(async (chatId: string, pinned: boolean) => {
await chatApi.pin(chatId, pinned)
useChatStore.getState().patchChat(chatId, { isPinned: pinned })
}, [])
const handleDeleteChat = useCallback(async (chatId: string) => {
await chatApi.delete(chatId)
useChatStore.getState().setChats(
useChatStore.getState().chats.filter((c) => c.id !== chatId)
)
if (useChatStore.getState().activeChatId === chatId) {
useChatStore.getState().setActiveChat(null)
}
}, [])
const handleMarkRead = useCallback(async (chatId: string) => {
await chatApi.markRead(chatId)
useChatStore.getState().patchChat(chatId, { unreadCount: 0 })
}, [])
// Apaga a mensagem PARA TODOS (revoke no WhatsApp). Confirma antes; em erro (ex.:
// mensagem não é sua / instância offline) mostra o motivo. messageId = id interno.
const handleDeleteMessage = useCallback(async (chatId: string, messageId: string) => {
if (!chatId || !messageId) return
if (typeof window !== 'undefined' && !window.confirm('Apagar esta mensagem para todos? Esta ação não pode ser desfeita.')) return
try {
await useChatStore.getState().deleteMessage(chatId, messageId)
} catch (e: any) {
const m = e?.response?.data?.error || e?.message || 'Não foi possível apagar a mensagem.'
if (typeof window !== 'undefined') window.alert(m)
}
}, [])
const handleSearchMessages = useCallback(async (q: string) => {
if (!instanceStore.activeInstanceId || q.length < 2) return []
return messageApi.search(instanceStore.activeInstanceId, q)
}, [instanceStore.activeInstanceId])
// action é a string-key emitida por ChatListItem (ex: 'archive', 'pin', ...)
// onFavoriteToggle é injetado pelo componente pai (inbox page) pois favoritos são estado local
const handleMenuAction = useCallback((
action: string,
chat: NewWhatsChat,
onFavoriteToggle?: (jid: string) => void
) => {
const chatId = String(chat.id)
switch (action) {
case 'archive': handleArchiveChat(chatId, !chat.is_archived); break
case 'pin': handlePinChat(chatId, !chat.is_pinned); break
case 'mark_read': handleMarkRead(chatId); break
case 'delete': handleDeleteChat(chatId); break
case 'favorite': onFavoriteToggle?.(chat.remote_jid); break
}
}, [handleArchiveChat, handlePinChat, handleMarkRead, handleDeleteChat])
return {
instances: instancesLegacy,
activeInstance: activeInstanceLegacy,
chats: chatsLegacy,
selectedChat: selectedChatLegacy,
messages: messagesLegacy,
loadingChats: chatStore.chatsLoading,
loadingMessages: chatStore.messagesLoading,
activeInstanceId: instanceStore.activeInstanceId,
qrByInstance: instanceStore.qrByInstance,
setActiveInstance: handleSetActiveInstance,
refreshInstances: handleRefreshInstances,
handleSelectChat,
handleSendText,
handleLoadMoreHistory,
handleConnectInstance,
handleArchiveChat,
handlePinChat,
handleDeleteChat,
handleDeleteMessage,
handleMarkRead,
handleSearchMessages,
handleMenuAction,
presenceByJid: chatStore.presenceByJid as Record<string, any>,
isFavoriteChat: (_: NewWhatsChat) => false,
}
}
@@ -0,0 +1,89 @@
'use client'
/**
* useAvatarCache cache de avatares em IndexedDB.
*
* Chave de cache: `${jid}:${version}` (version = hash curto do avatarUrl no banco).
* Quando a versão muda (novo avatar no banco), a chave muda e o cache invalida
* automaticamente sem TTL fixo, sem stale data visível ao usuário.
*
* Armazenamento: dataURL base64 do JPEG/WebP (compatível com <img src>).
* Limpeza: entradas com mais de 30 dias são removidas na abertura do DB.
*/
const DB_NAME = 'nw-avatars'
const STORE_NAME = 'blobs'
const DB_VERSION = 1
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 dias
interface CacheEntry {
key: string // `${jid}:${version}`
dataUrl: string
savedAt: number
}
let dbPromise: Promise<IDBDatabase> | null = null
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'key' })
}
}
req.onsuccess = () => {
const db = req.result
pruneOldEntries(db)
resolve(db)
}
req.onerror = () => { dbPromise = null; reject(req.error) }
})
return dbPromise
}
function pruneOldEntries(db: IDBDatabase): void {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const cutoff = Date.now() - MAX_AGE_MS
const req = store.openCursor()
req.onsuccess = () => {
const cursor = req.result
if (!cursor) return
if ((cursor.value as CacheEntry).savedAt < cutoff) cursor.delete()
cursor.continue()
}
}
export async function getAvatarFromCache(jid: string, version: string): Promise<string | null> {
if (typeof window === 'undefined') return null
try {
const db = await openDB()
const key = `${jid}:${version}`
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).get(key)
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
req.onerror = () => resolve(null)
})
} catch {
return null
}
}
export async function setAvatarInCache(jid: string, version: string, dataUrl: string): Promise<void> {
if (typeof window === 'undefined') return
try {
const db = await openDB()
const key = `${jid}:${version}`
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const entry: CacheEntry = { key, dataUrl, savedAt: Date.now() }
const req = tx.objectStore(STORE_NAME).put(entry)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
} catch { /* quota exceeded ou IDB indisponível — ignora silenciosamente */ }
}
@@ -0,0 +1,173 @@
import { useState, useCallback, useEffect } from 'react'
import { protocolApi, sectorApi, type Sector, type Team } from '../services/chatApiService'
// ─── Types ────────────────────────────────────────────────────────────────────
export type ProtocolStatus =
| 'OPEN'
| 'IN_PROGRESS'
| 'WAITING_CLIENT'
| 'WAITING_AGENT'
| 'RESOLVED'
| 'CANCELLED'
export interface Protocol {
id: string
number: string
chatId: string
contactId: string
sectorId: string | null
teamId: string | null
agentId: string | null
status: ProtocolStatus
summary: string | null
deadline: string | null
resolvedAt: string | null
createdAt: string
updatedAt: string
sector?: { name: string } | null
team?: { name: string } | null
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useProtocolPanel(chatId: string | null) {
const [protocols, setProtocols] = useState<Protocol[]>([])
const [loading, setLoading] = useState(false)
// Sectors & teams for the "open protocol" form
const [sectors, setSectors] = useState<Sector[]>([])
const [teams, setTeams] = useState<Team[]>([])
const [selectedSectorId, setSelectedSectorId] = useState<string>('')
const [selectedTeamId, setSelectedTeamId] = useState<string>('')
const [loadingSectors, setLoadingSectors] = useState(false)
const [loadingTeams, setLoadingTeams] = useState(false)
// Opening a new protocol
const [isOpening, setIsOpening] = useState(false)
const [showOpenForm, setShowOpenForm] = useState(false)
// Updating status
const [updatingId, setUpdatingId] = useState<string | null>(null)
// Toast
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
const showToast = useCallback((message: string, type: 'success' | 'error') => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}, [])
// ── Load protocols ────────────────────────────────────────────────────────
const load = useCallback(async () => {
if (!chatId) { setProtocols([]); return }
setLoading(true)
try {
const data = await protocolApi.list(chatId)
setProtocols(data as Protocol[])
} catch {
// silently ignore
} finally {
setLoading(false)
}
}, [chatId])
useEffect(() => { load() }, [chatId])
// ── Load sectors (lazy, once) ─────────────────────────────────────────────
const loadSectors = useCallback(async () => {
if (sectors.length > 0) return
setLoadingSectors(true)
try {
const data = await sectorApi.list()
setSectors(data.filter((s) => s.isActive))
} catch {
// ignore
} finally {
setLoadingSectors(false)
}
}, [sectors.length])
// ── Load teams when sector changes ────────────────────────────────────────
useEffect(() => {
if (!selectedSectorId) { setTeams([]); setSelectedTeamId(''); return }
setLoadingTeams(true)
sectorApi.listTeams(selectedSectorId)
.then((data) => { setTeams(data.filter((t) => t.isActive)); setSelectedTeamId('') })
.catch(() => {})
.finally(() => setLoadingTeams(false))
}, [selectedSectorId])
const openForm = useCallback(() => {
setShowOpenForm(true)
setSelectedSectorId('')
setSelectedTeamId('')
loadSectors()
}, [loadSectors])
// ── Open new protocol ─────────────────────────────────────────────────────
const openProtocol = useCallback(async () => {
if (!chatId) return
setIsOpening(true)
try {
const protocol = await protocolApi.open(chatId, {
sectorId: selectedSectorId || undefined,
teamId: selectedTeamId || undefined,
})
setProtocols((prev) => [protocol as Protocol, ...prev])
setShowOpenForm(false)
showToast('Protocolo aberto.', 'success')
} catch (err: any) {
showToast(err?.response?.data?.error ?? 'Erro ao abrir protocolo.', 'error')
} finally {
setIsOpening(false)
}
}, [chatId, selectedSectorId, selectedTeamId, showToast])
// ── Update status ─────────────────────────────────────────────────────────
const updateStatus = useCallback(async (protocol: Protocol, status: ProtocolStatus) => {
if (!chatId) return
setUpdatingId(protocol.id)
try {
await protocolApi.update(chatId, protocol.id, { status })
setProtocols((prev) =>
prev.map((p) =>
p.id === protocol.id
? { ...p, status, resolvedAt: status === 'RESOLVED' ? new Date().toISOString() : p.resolvedAt }
: p
)
)
showToast('Status atualizado.', 'success')
} catch {
showToast('Erro ao atualizar status.', 'error')
} finally {
setUpdatingId(null)
}
}, [chatId, showToast])
// ── Derived ───────────────────────────────────────────────────────────────
const activeProtocol = protocols.find(
(p) => p.status !== 'RESOLVED' && p.status !== 'CANCELLED'
) ?? null
const hasActive = activeProtocol !== null
return {
protocols, loading, load,
activeProtocol, hasActive,
// open form
showOpenForm, setShowOpenForm,
openForm,
sectors, teams,
selectedSectorId, setSelectedSectorId,
selectedTeamId, setSelectedTeamId,
loadingSectors, loadingTeams,
isOpening, openProtocol,
// update
updatingId, updateStatus,
toast,
}
}
@@ -0,0 +1,185 @@
/**
* useSessionsLogic toda a lógica da página /sessions. Portado do motor.
* Adaptações no satélite: imports apontam p/ os shims; o bootstrap chama
* socketService.connect() sem depender de localStorage['token'] (o shim o
* token do scoreodonto internamente).
*/
import { useState, useEffect, useCallback } from 'react'
import { useInstanceStore, type Instance } from '../store/instanceStore'
import { socketService } from '../services/socketService'
interface QrModalState {
instanceId: string
instanceName: string
qrBase64: string | null
}
interface Toast {
msg: string
ok: boolean
}
export function useSessionsLogic() {
const storeInstances = useInstanceStore((s) => s.instances)
const storeLoading = useInstanceStore((s) => s.loading)
const loadInstances = useInstanceStore((s) => s.loadInstances)
const createInstance = useInstanceStore((s) => s.createInstance)
const connectInst = useInstanceStore((s) => s.connectInstance)
const disconnectInst = useInstanceStore((s) => s.disconnectInstance)
const deleteInst = useInstanceStore((s) => s.deleteInstance)
const patchInstance = useInstanceStore((s) => s.patchInstance)
const setQr = useInstanceStore((s) => s.setQr)
const clearQr = useInstanceStore((s) => s.clearQr)
const qrByInstance = useInstanceStore((s) => s.qrByInstance)
const initListeners = useInstanceStore((s) => s.initSocketListeners)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [addOpen, setAddOpen] = useState(false)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [qrModal, setQrModal] = useState<QrModalState | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Instance | null>(null)
const [deleting, setDeleting] = useState(false)
const [toast, setToast] = useState<Toast | null>(null)
useEffect(() => {
socketService.connect() // shim lê o token do scoreodonto internamente
initListeners()
loadInstances()
}, [])
useEffect(() => {
const onStatus = ({ instanceId, status, qrBase64 }: { instanceId: string; status: Instance['status']; qrBase64?: string }) => {
patchInstance(instanceId, { status })
if (qrBase64) {
setQr(instanceId, qrBase64)
setQrModal((prev) => (prev?.instanceId === instanceId ? { ...prev, qrBase64 } : prev))
}
if (status === 'CONNECTED') {
clearQr(instanceId)
setQrModal((prev) => (prev?.instanceId === instanceId ? null : prev))
showToast('WhatsApp conectado!')
}
}
const onQr = ({ instanceId, qrBase64 }: { instanceId: string; qrBase64: string }) => {
setQr(instanceId, qrBase64)
patchInstance(instanceId, { status: 'QR_PENDING' })
setQrModal((prev) => (prev?.instanceId === instanceId ? { ...prev, qrBase64 } : prev))
}
socketService.on('instance:status', onStatus)
socketService.on('qr', onQr)
return () => {
socketService.off('instance:status', onStatus)
socketService.off('qr', onQr)
}
}, [])
const showToast = (msg: string, ok = true) => {
setToast({ msg, ok })
setTimeout(() => setToast(null), 3000)
}
const openAddModal = useCallback(() => setAddOpen(true), [])
const closeAddModal = useCallback(() => { setAddOpen(false); setNewName('') }, [])
const handleCreate = useCallback(async () => {
const name = newName.trim()
if (!name) return
setCreating(true)
try {
const created = await createInstance(name)
closeAddModal()
if (created?.id) {
socketService.joinInstance(created.id)
await connectInst(created.id)
setQrModal({ instanceId: created.id, instanceName: name, qrBase64: null })
}
} catch (err: any) {
showToast(err.response?.data?.error ?? 'Erro ao criar instância', false)
} finally {
setCreating(false)
}
}, [newName, createInstance, connectInst, closeAddModal])
const handleConnect = useCallback(async (inst: Instance) => {
setActionLoading(`connect-${inst.id}`)
try {
socketService.joinInstance(inst.id)
await connectInst(inst.id)
setQrModal({ instanceId: inst.id, instanceName: inst.name, qrBase64: null })
} catch (err: any) {
showToast(err.response?.data?.error ?? 'Erro ao conectar', false)
} finally {
setActionLoading(null)
}
}, [connectInst])
const handleDisconnect = useCallback(async (inst: Instance) => {
setActionLoading(`disconnect-${inst.id}`)
try {
await disconnectInst(inst.id)
showToast('Instância desconectada')
} catch (err: any) {
showToast(err.response?.data?.error ?? 'Erro ao desconectar', false)
} finally {
setActionLoading(null)
}
}, [disconnectInst])
const handleShowQr = useCallback(async (inst: Instance) => {
socketService.joinInstance(inst.id)
const cached = qrByInstance[inst.id]
if (cached) {
setQrModal({ instanceId: inst.id, instanceName: inst.name, qrBase64: cached })
return
}
setQrModal({ instanceId: inst.id, instanceName: inst.name, qrBase64: null })
}, [qrByInstance])
const handleDelete = useCallback(async () => {
if (!deleteTarget) return
setDeleting(true)
try {
await deleteInst(deleteTarget.id)
setDeleteTarget(null)
showToast('Instância removida')
} catch (err: any) {
showToast(err.response?.data?.error ?? 'Erro ao deletar', false)
} finally {
setDeleting(false)
}
}, [deleteTarget, deleteInst])
const handleNewQr = useCallback(async (instanceId: string) => {
socketService.joinInstance(instanceId)
await connectInst(instanceId)
}, [connectInst])
return {
instances: storeInstances,
loading: storeLoading,
actionLoading,
toast,
addOpen,
newName,
creating,
setNewName,
openAddModal,
closeAddModal,
handleCreate,
qrModal,
closeQrModal: () => setQrModal(null),
handleNewQr,
deleteTarget,
deleting,
setDeleteTarget,
closeDeleteModal: () => setDeleteTarget(null),
handleDelete,
handleConnect,
handleDisconnect,
handleShowQr,
refreshInstances: loadInstances,
}
}
@@ -0,0 +1,82 @@
'use client'
/**
* useStickerCache cache de figurinhas em IndexedDB.
*
* Chave de cache: sha256 hex do arquivo (conteúdo permanente nunca muda).
* Sem TTL: stickers são imutáveis, então o cache é eterno.
* O único motivo para uma entrada sumir é o usuário limpar dados do navegador.
*
* Armazenamento: dataURL base64 do WebP (compatível com <img src>).
*/
const DB_NAME = 'nw-stickers'
const STORE_NAME = 'blobs'
const DB_VERSION = 1
interface CacheEntry {
sha256: string
dataUrl: string
}
let dbPromise: Promise<IDBDatabase> | null = null
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'sha256' })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => { dbPromise = null; reject(req.error) }
})
return dbPromise
}
export async function getStickerFromCache(sha256: string): Promise<string | null> {
if (typeof window === 'undefined') return null
try {
const db = await openDB()
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).get(sha256)
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
req.onerror = () => resolve(null)
})
} catch {
return null
}
}
export async function setStickerInCache(sha256: string, dataUrl: string): Promise<void> {
if (typeof window === 'undefined') return
try {
const db = await openDB()
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const entry: CacheEntry = { sha256, dataUrl }
const req = tx.objectStore(STORE_NAME).put(entry)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
} catch { /* quota exceeded — ignora */ }
}
export async function hasStickerInCache(sha256: string): Promise<boolean> {
if (typeof window === 'undefined') return false
try {
const db = await openDB()
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).count(sha256)
req.onsuccess = () => resolve(req.result > 0)
req.onerror = () => resolve(false)
})
} catch {
return false
}
}
@@ -0,0 +1,251 @@
// Shims dos services do motor sobre a ext API do satélite (/api/nw/v1/*).
// Mantém a MESMA interface que o chatApiService.ts do motor expõe, normalizando
// os shapes da ext API para o formato que os stores/hooks portados esperam.
import { nw } from './nwClient';
// ─── Instâncias ── (ext /sessions) ──────────────────────────────────────────
export const instanceApi = {
list: () => nw.get('/sessions'),
create: (name: string) => nw.post('/sessions', { name }),
connect: (id: string) => nw.post(`/sessions/${id}/connect`),
disconnect: (id: string) => nw.post(`/sessions/${id}/disconnect`),
getQr: (id: string) => nw.get(`/sessions/${id}/qr`),
delete: (id: string) => nw.delete(`/sessions/${id}`),
};
// ─── Avatar self-heal ── (ext /contacts/:jid/avatar/refresh) ─────────────────
// A URL do CDN do WhatsApp expira e passa a 404; aqui pedimos ao motor para
// re-buscar a foto atual pela conexão viva. Retorna a URL nova ou null.
export const avatarApi = {
refresh: async (instanceId: string, jid: string): Promise<string | null> => {
try {
const r = await nw.get(`/contacts/${encodeURIComponent(jid)}/avatar/refresh?session=${encodeURIComponent(instanceId)}`);
return r?.avatar ?? null;
} catch { return null; }
},
};
// ─── Chats ── (ext /inbox → shape achatado; normaliza p/ mapBackendChat) ────
export const chatApi = {
list: async (instanceId: string, opts: { search?: string; archived?: boolean; limit?: number } = {}) => {
const q = new URLSearchParams({ session: instanceId });
if (opts.search) q.set('search', opts.search);
if (opts.limit) q.set('limit', String(opts.limit));
const raw = await nw.get(`/inbox?${q.toString()}`);
const arr = Array.isArray(raw) ? raw : [];
// ext: { id, jid, displayName, avatar, phone, unreadCount, lastMessageAt,
// isPinned, isArchived, lastMessage } → shape esperado pelo chatStore.
return arr.map((c: any) => ({
id: c.id,
jid: c.jid,
instanceId,
name: c.displayName ?? null,
contactName: c.displayName ?? null,
contactPhone: c.phone ?? null,
contactAvatarUrl: c.avatar ?? null,
unreadCount: c.unreadCount ?? 0,
lastMessageAt: c.lastMessageAt ?? null,
createdAt: c.lastMessageAt ?? null,
isPinned: c.isPinned ?? false,
isArchived: c.isArchived ?? false,
lastMessage: c.lastMessage ?? null,
}));
},
markRead: (chatId: string) => nw.post(`/inbox/${chatId}/read`),
archive: (chatId: string, archived: boolean) => nw.patch(`/inbox/${chatId}/archive`, { archived }),
pin: (chatId: string, pinned: boolean) => nw.patch(`/inbox/${chatId}/pin`, { pinned }),
delete: (chatId: string) => nw.delete(`/inbox/${chatId}`),
};
// ─── Mensagens ── (ext devolve array; envolve em {messages, hasMore}) ───────
export const messageApi = {
list: async (chatId: string, opts: { before?: string; after?: string; limit?: number } = {}) => {
const q = new URLSearchParams();
if (opts.before) q.set('before', opts.before);
if (opts.limit) q.set('limit', String(opts.limit));
const qs = q.toString();
const raw = await nw.get(`/inbox/${chatId}/messages${qs ? `?${qs}` : ''}`);
const arr = Array.isArray(raw) ? raw : (raw?.messages ?? []);
const limit = opts.limit ?? 50;
return {
messages: arr.map((m: any) => ({ ...m, chatId, replyToId: m.replyToId ?? null, replyTo: m.replyTo ?? null })),
hasMore: arr.length >= limit,
};
},
search: (instanceId: string, q: string, limit?: number) => {
const p = new URLSearchParams({ session: instanceId, q });
if (limit) p.set('limit', String(limit));
return nw.get(`/inbox/messages/search?${p.toString()}`);
},
// Envio por chatId (a ext resolve jid/instância). O motor chamava por
// (instanceId, jid, ...); o chatStore foi adaptado p/ passar (chatId, text, replyTo).
send: (chatId: string, text: string, replyToMessageId?: string) =>
nw.post(`/inbox/${chatId}/send`, { text, ...(replyToMessageId ? { replyToMessageId } : {}) }),
};
// ─── Mídia ── (ext /inbox/:chatId/send-media | send-audio) ──────────────────
export const mediaApi = {
sendMedia: (chatId: string, file: File, caption?: string, replyToMessageId?: string) => {
const fd = new FormData();
fd.append('file', file);
if (caption) fd.append('caption', caption);
if (replyToMessageId) fd.append('replyToMessageId', replyToMessageId);
return nw.post(`/inbox/${chatId}/send-media`, fd);
},
sendAudio: (chatId: string, blob: Blob) => {
const fd = new FormData();
fd.append('audio', blob, 'audio.ogg');
return nw.post(`/inbox/${chatId}/send-audio`, fd);
},
// Sticker: reaproveita o endpoint de mídia (a ext detecta image/webp → sticker).
sendSticker: (chatId: string, blob: Blob) => {
const fd = new FormData();
fd.append('file', new File([blob], 'sticker.webp', { type: 'image/webp' }));
return nw.post(`/inbox/${chatId}/send-media`, fd);
},
// Re-download sob demanda: ~95% das mídias vêm com mediaUrl NULL (lazy). O motor
// re-baixa do WhatsApp a partir do WAMessage salvo e devolve { mediaUrl }.
redownload: (_instanceId: string, messageDbId: string): Promise<{ mediaUrl: string }> =>
nw.post(`/media/${encodeURIComponent(messageDbId)}/download`),
};
// ─── Grupos (ext não expõe participantes — stub gracioso) ───────────────────
export const groupApi = {
getParticipants: (_instanceId: string, groupJid: string) =>
Promise.resolve({ groupJid, subject: '', participants: [] as any[] }),
};
// ─── Protocolo (ext ainda não expõe CRUD por chat — stub; task follow-up) ───
export const protocolApi = {
list: (_chatId: string) => Promise.resolve([] as any[]),
open: (_chatId: string, _data: { sectorId?: string; teamId?: string }) => Promise.resolve(null as any),
update: (_chatId: string, _protocolId: string, _data: { status?: string; summary?: string }) => Promise.resolve(null as any),
};
// ─── Reconciliação (ext não expõe — no-op) ──────────────────────────────────
export const reconcileApi = {
reconcileNames: (_instanceId: string) => Promise.resolve({ updated: 0 }),
};
// ─── Tipos (compat com o motor; usados como type-only pelos componentes) ────
export interface GroupParticipant { jid: string; admin: string | null; name: string | null; phone: string }
export interface Sector { id: string; tenantId: string; name: string; isActive: boolean; createdAt: string; updatedAt: string; _count?: { teams: number; protocols: number } }
export interface Team { id: string; tenantId: string; sectorId: string; name: string; isActive: boolean; createdAt: string; updatedAt: string; _count?: { protocols: number } }
export type TemplateType = 'TEXT' | 'BUTTONS' | 'INTERACTIVE' | 'LIST' | 'POLL' | 'CAROUSEL'
export interface MessageTemplate { id: string; tenantId: string; name: string; description: string | null; type: TemplateType; payload: Record<string, unknown>; tags: string[]; usageCount: number; createdAt: string; updatedAt: string }
export interface StickerRecord { id: string; sha256: string; wasabiPath: string; isAnimated: boolean; isFavorite: boolean; useCount?: number; lastSeenAt?: string }
export interface RichPayload { text?: string; footer?: string; nativeButtons?: any[]; nativeList?: any; nativeCarousel?: any; poll?: any }
// ─── Setores/Equipes (ext não expõe protocolo/setores — stubs) ──────────────
export const sectorApi = {
list: () => Promise.resolve([] as Sector[]),
create: (_name: string) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
listTeams: (_sectorId: string) => Promise.resolve([] as Team[]),
createTeam: (_sectorId: string, _name: string) => Promise.resolve(null as any),
updateTeam: (_sectorId: string, _teamId: string, _d: any) => Promise.resolve(null as any),
deleteTeam: (_sectorId: string, _teamId: string) => Promise.resolve({} as any),
};
// ─── Templates (stub simples) ───────────────────────────────────────────────
export const templateApi = {
list: (_opts: any = {}) => Promise.resolve([] as MessageTemplate[]),
get: (_id: string) => Promise.resolve(null as any),
create: (_d: any) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
markUsed: (_id: string) => Promise.resolve({} as any),
};
// ─── Stickers (ext não expõe catálogo — stub) ───────────────────────────────
export const stickerApi = {
list: () => Promise.resolve({ listVersion: '', stickers: [] as StickerRecord[] }),
recent: () => Promise.resolve({ stickers: [] as StickerRecord[] }),
toggleFavorite: (id: string) => Promise.resolve({ id, isFavorite: false }),
};
// ─── Rich message (composer passa instanceId/jid; sem chatId não mapeia na
// ext → stub que rejeita graciosamente) ─────────────────────────────────────
export const richMessageApi = {
send: (_instanceId: string, _jid: string, _payload: RichPayload) =>
Promise.reject(new Error('Envio rico indisponível no satélite')),
};
// ─── Demais exports do chatApiService do motor (stubs — a ext não os expõe;
// mantidos para os named imports da árvore não quebrarem em runtime) ─────────
export interface ApiKeyRecord { id: string; name: string; keyPreview: string; isActive: boolean; createdAt: string; expiresAt: string | null }
export interface BackendContact { id: string; instanceId: string; jid: string; name: string | null; verifiedName: string | null; notify: string | null; phone: string | null; avatarUrl: string | null; scoreReputacao: number; flagRestricao: boolean; isBlocked: boolean; createdAt: string; updatedAt: string }
export type EventType = 'BIRTHDAY' | 'ANNIVERSARY' | 'SIGNUP_DAYS'
export type RecipientMode = 'MANUAL' | 'ALL_CONTACTS' | 'TAG_FILTER'
export type ScheduleType = 'ONCE' | 'RECURRING' | 'EVENT'
export type ScheduleStatus = 'ACTIVE' | 'PAUSED' | 'DONE' | 'CANCELLED'
export interface ScheduleRecipient { jid: string; name?: string; birthday?: string; anniversary?: string; signupDate?: string }
export interface ScheduledMessage { id: string; tenantId: string; instanceId: string; name: string; templateId: string | null; payload: Record<string, unknown>; recipientMode: RecipientMode; recipients: ScheduleRecipient[]; scheduleType: ScheduleType; cronExpr: string | null; scheduledAt: string | null; eventType: EventType | null; timezone: string; status: ScheduleStatus; lastRunAt: string | null; nextRunAt: string | null; runCount: number; createdAt: string; updatedAt: string; template?: any; _count?: { logs: number } }
export interface ScheduleLog { id: string; scheduleId: string; status: string; sentCount: number; failedCount: number; error: string | null; runAt: string }
// Cliente axios-like genérico sobre o nw (alguns arquivos importam `api` direto).
export const api = {
get: (u: string) => nw.get(u).then((data: any) => ({ data })),
post: (u: string, body?: any) => nw.post(u, body).then((data: any) => ({ data })),
put: (u: string, body?: any) => nw.put(u, body).then((data: any) => ({ data })),
patch: (u: string, body?: any) => nw.patch(u, body).then((data: any) => ({ data })),
delete: (u: string) => nw.delete(u).then((data: any) => ({ data })),
};
export const authApi = {
register: (_d: any) => Promise.resolve({} as any),
login: (_d: any) => Promise.resolve({} as any),
logout: () => Promise.resolve({} as any),
me: () => Promise.resolve({} as any),
changePassword: (_d: any) => Promise.resolve({} as any),
};
export const apiKeyApi = {
list: () => Promise.resolve([] as ApiKeyRecord[]),
create: (_name: string, _e?: string) => Promise.resolve({} as any),
toggle: (_id: string, _a: boolean) => Promise.resolve({} as any),
delete: (_id: string) => Promise.resolve({} as any),
};
export const contactApi = {
list: (_opts: any = {}) => Promise.resolve({ total: 0, contacts: [] as BackendContact[] }),
get: (_id: string) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
stats: (_instanceId?: string) => Promise.resolve({ total: 0, blocked: 0, flagged: 0, active: 0 }),
};
export const broadcastApi = {
create: (_instanceId: string, _d: any) => Promise.resolve({ jobId: '', total: 0 }),
list: (_instanceId: string, _limit?: number) => Promise.resolve([] as any[]),
get: (_instanceId: string, _broadcastId: string) => Promise.resolve(null as any),
};
export const planApi = {
status: () => Promise.resolve({ daysRemaining: null as number | null, planName: '' }),
};
export const chatbotApi = {
getCredentials: (_instanceId: string) => Promise.resolve([] as any[]),
createCredential: (_instanceId: string, _d: any) => Promise.resolve({} as any),
deleteCredential: (_instanceId: string, _credId: string) => Promise.resolve({} as any),
getBots: (_instanceId: string) => Promise.resolve([] as any[]),
createBot: (_instanceId: string, _d: any) => Promise.resolve({} as any),
updateBot: (_instanceId: string, _botId: string, _d: any) => Promise.resolve({} as any),
deleteBot: (_instanceId: string, _botId: string) => Promise.resolve({} as any),
pauseChat: (_instanceId: string, _chatId: string) => Promise.resolve({} as any),
resumeChat: (_instanceId: string, _chatId: string) => Promise.resolve({} as any),
};
export const scheduleApi = {
list: (_opts: any = {}) => Promise.resolve([] as ScheduledMessage[]),
get: (_id: string) => Promise.resolve(null as any),
create: (_d: any) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
pause: (_id: string) => Promise.resolve({} as any),
resume: (_id: string) => Promise.resolve({} as any),
runNow: (_id: string) => Promise.resolve({} as any),
logs: (_id: string) => Promise.resolve([] as ScheduleLog[]),
};
@@ -0,0 +1,53 @@
// Cliente base do satélite NewWhats → /api/nw/v1/* (proxy do backend → motor
// /api/ext/v1/*). Auth: Bearer do scoreodonto (localStorage SCOREODONTO_AUTH_TOKEN).
// Retorna JSON já parseado; em erro, lança Error com `.response = { status, data }`
// (mesmo shape do axios, que o código portado do motor espera).
const API: string = (import.meta as any).env?.VITE_API_URL || '/api';
const TOKEN_KEY = 'SCOREODONTO_AUTH_TOKEN';
export function nwToken(): string | null {
try { return localStorage.getItem(TOKEN_KEY); } catch { return null; }
}
// Clínica do workspace ativo — escopo por-requisição (modelo de canal).
function activeClinicaId(): string | null {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_ACTIVE_WORKSPACE') || '{}')?.id || null; } catch { return null; }
}
function authHeaders(extra?: Record<string, string>): Record<string, string> {
const t = nwToken();
const c = activeClinicaId();
return {
...(t ? { Authorization: `Bearer ${t}` } : {}),
...(c ? { 'x-nw-clinica': c } : {}),
...(extra || {}),
};
}
async function req(method: string, path: string, body?: any): Promise<any> {
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
const res = await fetch(`${API}/nw/v1${path}`, {
method,
headers: authHeaders(isForm ? undefined : (body != null ? { 'Content-Type': 'application/json' } : undefined)),
body: body == null ? undefined : (isForm ? body : JSON.stringify(body)),
});
const text = await res.text();
let data: any = null;
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
if (!res.ok) {
const err: any = new Error((data && data.error) || `${res.status}`);
err.response = { status: res.status, data };
throw err;
}
return data;
}
export const nw = {
get: (p: string) => req('GET', p),
post: (p: string, body?: any) => req('POST', p, body),
put: (p: string, body?: any) => req('PUT', p, body),
patch: (p: string, body?: any) => req('PATCH', p, body),
delete: (p: string) => req('DELETE', p),
// URL base ('/api' relativo ou absoluta) — usada pelo socketService p/ o WS.
base: () => API,
};

Some files were not shown because too many files have changed in this diff Show More