wip: inbox/agenda diversos (trabalho anterior à sessão)

Consolida WIP acumulado que não faz parte das features de hoje: melhorias do
inbox (WhatsChatDrawer, InputBar, MessageBubble, ProtocolPanel, StickerPanel,
LinkPreview, hooks, socketService, chatStore, nwClient), componentes novos
(ConversationSearch, ForwardModal, LabelManagerModal, roleMeta), AgendaPresence,
ComunicacaoInternaModal, Sidebar, GestaoEquipeView, SessionsView, appTime e o
asset chat-bg.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-10 06:08:07 +02:00
parent 86cf253161
commit 8ea8faa4c2
24 changed files with 995 additions and 171 deletions
+33 -34
View File
@@ -65,42 +65,41 @@ export const AgendaPresence: React.FC = () => {
</span>
</button>
{/* Grupo oculto que expande PARA BAIXO dentro da div (inline) — largura cheia
da coluna/gaveta, sem flutuar; assim não é cortado pelo overflow no desktop. */}
{open && (
<>
<div className="fixed inset-0 z-[55]" onClick={() => setOpen(false)} />
<div className="absolute right-0 mt-2 w-64 bg-white rounded-2xl shadow-2xl border border-gray-100 z-[56] overflow-hidden">
<div className="px-4 py-3 border-b border-gray-50">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-1.5">
<Circle size={8} className="fill-emerald-500 text-emerald-500" /> Online agora ({online.length})
</p>
</div>
<div className="max-h-72 overflow-y-auto custom-scrollbar">
{online.length === 0 && (
<p className="px-4 py-3 text-[11px] text-gray-400 font-bold uppercase"> você por aqui.</p>
)}
{online.map(o => (
<div key={o.usuario_id} className="flex items-center gap-2.5 px-4 py-2.5">
<span className="relative w-7 h-7 rounded-full flex items-center justify-center text-[9px] font-black text-white shrink-0" style={{ backgroundColor: colorFor(o.nome) }}>
{initials(o.nome)}
<Circle size={9} className="absolute -bottom-0.5 -right-0.5 fill-emerald-500 text-white" />
</span>
<span className="text-xs font-bold text-gray-700 uppercase truncate">{o.nome}</span>
</div>
))}
{ultimasOffline.length > 0 && (
<div className="px-4 py-2 border-t border-gray-50">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-1">Últimas visitas</p>
{ultimasOffline.slice(0, 6).map(u => (
<div key={u.usuario_id} className="flex items-center justify-between py-1">
<span className="text-[11px] font-bold text-gray-500 uppercase truncate">{u.usuario_nome}</span>
<span className="text-[9px] text-gray-300 font-bold whitespace-nowrap ml-2">{relTime(u.last_view_at)}</span>
</div>
))}
</div>
)}
</div>
<div className="mt-2 w-full bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b border-gray-50">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-1.5">
<Circle size={8} className="fill-emerald-500 text-emerald-500" /> Online agora ({online.length})
</p>
</div>
</>
<div className="max-h-72 overflow-y-auto custom-scrollbar">
{online.length === 0 && (
<p className="px-4 py-3 text-[11px] text-gray-400 font-bold uppercase"> você por aqui.</p>
)}
{online.map(o => (
<div key={o.usuario_id} className="flex items-center gap-2.5 px-4 py-2.5">
<span className="relative w-7 h-7 rounded-full flex items-center justify-center text-[9px] font-black text-white shrink-0" style={{ backgroundColor: colorFor(o.nome) }}>
{initials(o.nome)}
<Circle size={9} className="absolute -bottom-0.5 -right-0.5 fill-emerald-500 text-white" />
</span>
<span className="text-xs font-bold text-gray-700 uppercase truncate">{o.nome}</span>
</div>
))}
{ultimasOffline.length > 0 && (
<div className="px-4 py-2 border-t border-gray-50">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-1">Últimas visitas</p>
{ultimasOffline.slice(0, 6).map(u => (
<div key={u.usuario_id} className="flex items-center justify-between py-1">
<span className="text-[11px] font-bold text-gray-500 uppercase truncate">{u.usuario_nome}</span>
<span className="text-[9px] text-gray-300 font-bold whitespace-nowrap ml-2">{relTime(u.last_view_at)}</span>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
@@ -3,6 +3,7 @@ import { X, Plus, Trash2, MapPin, Phone, Clock, CalendarClock, Save, Loader2, Me
import { useToast } from '../contexts/ToastContext.tsx';
import { buscarCep } from '../services/viacep.ts';
import { HorariosConfig } from './HorariosConfig.tsx';
import { HybridBackend } from '../services/backend.ts';
// Comunicação interna: perfil de contato de não-pacientes (dentistas/funcionários).
// Números de WhatsApp múltiplos (cada um com descrição, situações e janelas de horário
@@ -83,6 +84,12 @@ export const ComunicacaoInternaModal: React.FC<{
const [jornadaOk, setJornadaOk] = useState(false); // jornada real (dentistas_horarios) configurada
const [horarioClinicaOk, setHorarioClinicaOk] = useState(false); // clinicas_horarios configurado
const [horariosTab, setHorariosTab] = useState<'dentistas' | 'clinica' | null>(null); // abre "Horários de Funcionamento"
const [trocarOpen, setTrocarOpen] = useState(false); // dropdown "Trocar de clínica" (escape do gate forçado)
// Escape do gate forçado: o gate é POR clínica; travar ESTA não deve prender o
// usuário no sistema. Lista as OUTRAS clínicas (exceto a atual e salas) para trocar.
const outrasClinicas = (HybridBackend.getWorkspaces() || [])
.filter((w: any) => w.tipo !== 'sala' && w.id !== clinicaId);
const load = useCallback(async () => {
setLoading(true);
@@ -316,6 +323,30 @@ export const ComunicacaoInternaModal: React.FC<{
Lembrar mais tarde
</button>
)}
{/* Escape do gate forçado: sair para OUTRA clínica sem preencher esta. */}
{forced && outrasClinicas.length > 0 && (
<div className="relative">
<button type="button" onClick={() => setTrocarOpen(o => !o)}
className="px-4 py-3 rounded-xl text-xs font-black uppercase text-gray-600 border border-gray-200 hover:bg-gray-50 flex items-center gap-1.5">
Trocar de clínica
</button>
{trocarOpen && (
<>
<div className="fixed inset-0 z-[71]" onClick={() => setTrocarOpen(false)} />
<div className="absolute right-0 bottom-full mb-2 z-[72] min-w-[220px] bg-white border border-gray-200 rounded-2xl shadow-xl py-2 max-h-[50vh] overflow-y-auto">
<p className="px-4 py-1.5 text-[9px] font-black text-gray-400 uppercase tracking-widest">Ir para outra clínica</p>
{outrasClinicas.map((w: any) => (
<button key={w.id} type="button" onClick={() => HybridBackend.switchWorkspace(w.id)}
className="w-full flex items-center gap-2.5 px-4 py-2 hover:bg-gray-50 text-left">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: w.cor || '#2563eb' }} />
<span className="text-xs font-bold text-gray-700 uppercase truncate">{w.nome}</span>
</button>
))}
</div>
</>
)}
</div>
)}
<button onClick={salvar} disabled={saving}
className="px-6 py-3 text-white rounded-xl text-xs font-black uppercase hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2" style={{ backgroundColor: completo ? '#16a34a' : COLOR }}>
{saving ? <Loader2 size={15} className="animate-spin" /> : completo ? <CheckCircle2 size={15} /> : <Save size={15} />}
+5 -4
View File
@@ -35,8 +35,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
}
return () => { alive = false; };
}, [activeWorkspace?.id]);
const canSeeInbox = !waAccess || waAccess.isOwner || waAccess.inbox;
const canSeeSecretaria = !waAccess || waAccess.isOwner || waAccess.secretaria;
// Fail-CLOSED: enquanto waAccess não carregou (null) ou negou, NÃO mostra as áreas.
const canSeeInbox = !!waAccess && (waAccess.isOwner || waAccess.inbox);
const canSeeSecretaria = !!waAccess && (waAccess.isOwner || waAccess.secretaria);
const pluginMenuItems: Array<{ id: string; label: string; icon: any; color: string }> = [];
if (activePluginIds.includes('rx-scoreodonto')) {
@@ -54,8 +55,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
// ativo (a CONFIG do plugin fica no catálogo PLUGINS, exclusivo do superadmin).
if (isPluginActive('newwhats')) {
if (canSeeInbox) pluginMenuItems.push({ id: 'wa-inbox', label: 'WHATSAPP', icon: MessageCircle, color: '#16a34a' });
// INSTÂNCIAS (gestão/QR) só para o dono; os demais operam via Inbox.
if (!waAccess || waAccess.isOwner) pluginMenuItems.push({ id: 'wa-sessions', label: 'INSTÂNCIAS', icon: Smartphone, color: '#0ea5e9' });
// INSTÂNCIAS (gestão/QR) só para o dono; os demais operam via Inbox. Fail-closed.
if (waAccess?.isOwner) pluginMenuItems.push({ id: 'wa-sessions', label: 'INSTÂNCIAS', icon: Smartphone, color: '#0ea5e9' });
// SECRETÁRIA IA saiu do menu — vive agora como aba DENTRO do WhatsApp (wa-inbox).
}
// Locação de Salas: menus de LOCADOR (administra as próprias salas) e LOCATÁRIO (aluga de
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+11
View File
@@ -16,3 +16,14 @@ export const fmtDataCurta = (v?: string | number | Date | null): string => {
export const fmtDataHora = (v?: string | number | Date | null): string => {
const d = D(v); return d ? d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: STORAGE_TZ }) : '—';
};
// Converte um instante (ISO/Date) para o WALL-CLOCK naive no fuso canônico, no formato
// "YYYY-MM-DDTHH:mm:ss" (SEM offset). Uso: alimentar o FullCalendar, que — sem o plugin
// de timezone (luxon) — IGNORA o prop `timeZone` nomeado e cai no fuso do NAVEGADOR.
// Passando a string naive, o calendário exibe o horário literal em qualquer navegador
// (mesma garantia do fmtHora, mas para os eventos do grid). Retorna undefined se vazio.
export const toCalendarNaive = (v?: string | number | Date | null): string | undefined => {
const d = D(v); if (!d) return undefined;
// 'sv-SE' produz "YYYY-MM-DD HH:mm:ss" (ISO-like); trocamos o espaço por 'T'.
return d.toLocaleString('sv-SE', { timeZone: STORAGE_TZ, hour12: false }).replace(' ', 'T');
};
+7
View File
@@ -204,6 +204,7 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
role: editing.role,
setor_id: editing.setor_id || null,
funcao_id: editing.funcao_id || null,
nome: editing.nome?.trim() || undefined,
});
if (!res.success) { toast.error(res.message || 'ERRO AO ATUALIZAR.'); return; }
toast.success('MEMBRO ATUALIZADO!');
@@ -372,6 +373,12 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
{editing && (
<Modal title={`EDITAR — ${editing.nome}`} onClose={() => setEditing(null)}>
<div className="space-y-4">
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">NOME</label>
<input value={editing.nome || ''} onChange={e => setEditing({ ...editing, nome: e.target.value.toUpperCase() })}
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-bold uppercase focus:outline-none focus:border-teal-400" />
<p className="text-[10px] text-gray-400 mt-1 leading-snug">Usado na assinatura das mensagens do WhatsApp. Troque quando a pessoa desta conta mudar (ex.: nova secretária).</p>
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">ACESSO NO SISTEMA</label>
<select value={editing.role} onChange={e => setEditing({ ...editing, role: e.target.value })}
+23 -14
View File
@@ -435,11 +435,20 @@ const SessionCard = React.forwardRef<HTMLDivElement, {
<UserCog className="w-3.5 h-3.5" /> Acesso
</button>
)}
<button onClick={onDisconnect}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-600 hover:text-gray-800 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-100"
>
<LogOut className="w-3.5 h-3.5" /> Desconectar
</button>
{/* Desconectar = gestão da conexão (o backend exige dono ou can_rescan). */}
{rescanLocked ? (
<div title="Somente o dono pode desconectar esta sessão — peça autorização de re-scan."
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-400 bg-gray-500/5 rounded-xl border border-gray-200 cursor-not-allowed"
>
<Lock className="w-3.5 h-3.5" /> Desconectar
</div>
) : (
<button onClick={onDisconnect}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-600 hover:text-gray-800 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-100"
>
<LogOut className="w-3.5 h-3.5" /> Desconectar
</button>
)}
</>
) : rescanLocked ? (
<div className="flex-1 flex items-center justify-center gap-2 bg-gray-500/5 text-gray-400 font-black py-3 rounded-xl text-[11px] uppercase tracking-widest border border-gray-200 cursor-not-allowed"
@@ -652,9 +661,9 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
// ── Ownership & permissões por sessão ──────────────────────────────────────
const [authzByInstance, setAuthzByInstance] = useState<Record<string, SessionPerms>>({});
const [workspaceOwner, setWorkspaceOwner] = useState<{ isOwner: boolean; ownerNome: string | null }>({ isOwner: false, ownerNome: null });
// Endpoint de authz disponível? Se não (backend antigo/erro), a UI NÃO bloqueia —
// o enforcement real é do proxy do backend. Começa true (otimista) até saber.
const [authzAvailable, setAuthzAvailable] = useState(true);
// Endpoint de authz disponível? Fail-CLOSED: se não (backend antigo/erro), a UI TRAVA as
// ações destrutivas (o proxy segue sendo a autoridade). Começa false até confirmar.
const [authzAvailable, setAuthzAvailable] = useState(false);
const [permsModalFor, setPermsModalFor] = useState<Instance | null>(null);
const instanceIdsKey = instances.map((i) => i.id).join(',');
@@ -680,7 +689,7 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
if (r?.available) available = true;
map[id] = r
? { isOwner: r.isOwner, canRescan: r.me.can_rescan, canDelete: r.me.can_delete, ownerNome: r.ownerNome }
: { isOwner: false, canRescan: true, canDelete: true, ownerNome: null };
: { isOwner: false, canRescan: false, canDelete: false, ownerNome: null }; // fail-closed em erro
if (r?.available && r.isOwner) owner = { isOwner: true, ownerNome: r.ownerNome };
else if (r?.available && !owner.ownerNome && r.ownerNome) owner.ownerNome = r.ownerNome;
}
@@ -692,18 +701,18 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
}, [instanceIdsKey]);
// Permissões efetivas de uma instância.
// Sem authz disponível → permissivo (não bloqueia; backend é a autoridade).
// Fail-CLOSED: sem authz disponível (endpoint em erro), trava as ações na UI — o backend
// segue sendo a autoridade, mas a UI não deve expor botões destrutivos por falha transitória.
const permsFor = (inst: Instance): SessionPerms => {
if (!authzAvailable) return { isOwner: false, canRescan: true, canDelete: true, ownerNome: null };
if (!authzAvailable) return { isOwner: false, canRescan: false, canDelete: false, ownerNome: null };
return authzByInstance[inst.id]
?? (workspaceOwner.isOwner
? { ...OWNER_PERMS, ownerNome: workspaceOwner.ownerNome }
: { isOwner: false, canRescan: false, canDelete: false, ownerNome: workspaceOwner.ownerNome });
};
// "Nova Instância" (scan inicial): só o dono. Mas se o endpoint não está
// disponível ainda, não bloqueia na UI (o backend decide).
const canCreateInstance = !authzAvailable || workspaceOwner.isOwner;
// "Nova Instância" (scan inicial): só o dono (fail-closed — o backend também exige dono).
const canCreateInstance = authzAvailable && workspaceOwner.isOwner;
// Já existe uma sessão ativa no motor para esta conta → mostra mensagem de
// sucesso em vez de sugerir novo pareamento.
+143 -47
View File
@@ -15,10 +15,13 @@
*/
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronDown, User, Phone, Send, MessageCircle, Loader2, WifiOff, QrCode } from 'lucide-react';
import { X, ChevronDown, User, Users, Phone, Send, MessageCircle, Loader2, WifiOff, QrCode } from 'lucide-react';
import { HybridBackend } from '../../services/backend';
import { useChatStore } from './store/chatStore';
import { chatApi } from './services/chatApiService';
import ForwardModal from './components/ForwardModal';
import LabelManagerModal from './components/LabelManagerModal';
import { useInstanceStore } from './store/instanceStore';
import { formatPhone } from './utils/inboxUtils';
import { nw } from './services/nwClient';
@@ -29,6 +32,7 @@ import { useChatWindow } from './hooks/inbox/useChatWindow';
import { useMessageInput } from './hooks/inbox/useMessageInput';
import InboxHeader from './components/InboxHeader';
import ConversationSearch from './components/ConversationSearch';
import MessageArea from './components/MessageArea';
import InputBar from './components/InputBar';
import ContactProfile from './components/ContactProfile';
@@ -39,6 +43,10 @@ export interface WhatsChatDrawerProps {
onClose: () => void;
pacienteId?: string | null;
pacienteNome?: string | null;
// Telefone CRU direto (ex.: pedido da fila da Secretária, que pode ser um lead SEM
// cadastro). Quando fornecido, entra como 1ª opção de número e resolve o chat sem
// depender de lookup por cadastro.
phone?: string | null;
onNavigate?: (view: string) => void;
}
@@ -65,8 +73,16 @@ function samePhone(a: string, b: string): boolean {
return da.slice(-8) === db.slice(-8);
}
// Nome curto p/ o header: 1º nome + um pouco do 2º (até 5 letras + … se cortar).
function shortContactName(full: string): string {
const parts = String(full || '').trim().split(/\s+/).filter(Boolean);
if (parts.length <= 1) return parts[0] || full;
const second = parts[1];
return `${parts[0]} ${second.length > 5 ? second.slice(0, 5) + '…' : second}`;
}
// ─── Conteúdo interno (só monta quando aberto → hooks/socket sob demanda) ──────
const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose, pacienteId, pacienteNome, onNavigate }) => {
const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose, pacienteId, pacienteNome, phone, onNavigate }) => {
const { activeInstance, instances, connectedInstances } = useInstanceSelector();
const loadingInstances = useInstanceStore((s) => s.loading);
// Há WhatsApp utilizável? (pelo menos uma instância conectada)
@@ -77,32 +93,65 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
loadingMessages,
handleSendText,
handleLoadMoreHistory,
handleDeleteMessage,
presenceByJid,
activeInstanceId,
} = useNewInboxBridge();
// Permissão de apagar mensagem (dono ou liberado). Fail-safe: false enquanto carrega.
const [canDeleteMsg, setCanDeleteMsg] = useState(false);
// "Limpar conversa" é só do DONO do workspace. Fail-closed.
const [isWaOwner, setIsWaOwner] = useState(false);
const [labelModalOpen, setLabelModalOpen] = useState(false);
useEffect(() => {
let alive = true;
HybridBackend.getWaAccess().then(a => { if (alive) { setCanDeleteMsg(!!(a.isOwner || a.delete_msg)); setIsWaOwner(!!a.isOwner); useChatStore.getState().setOperatorSignature(a.signature ?? null); } }).catch(() => {});
return () => { alive = false; };
}, []);
const {
scrollRef, isTyping, replyingTo, reactionPickerFor, hasMoreHistory, loadingMore,
scrollRef, replyingTo, reactionPickerFor, hasMoreHistory, loadingMore,
isProfileOpen, isSyncingAvatar, isChatMenuOpen,
setReplyingTo, setIsProfileOpen, setIsChatMenuOpen,
handleTyping, handleScroll, handleSelectChat: onChatWindowSelect,
handleReactToMessage, handleSendMedia, handleSendAudio, handleSyncAvatar, toggleReactionPicker,
handleClearConversation,
editingMessage, setEditingMessage, forwardingMessage, setForwardingMessage,
} = useChatWindow({
selectedChat,
activeInstanceId: activeInstance?.instance ?? null,
onLoadMoreHistory: handleLoadMoreHistory,
});
// "digitando..." do CONTATO (presença composing/recording do chat aberto).
const activePresence = selectedChat?.remote_jid ? presenceByJid[selectedChat.remote_jid] : undefined;
const isTyping = !!activePresence && (Date.now() - activePresence.ts <= 8000) && (activePresence.state === 'composing' || activePresence.state === 'recording');
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
selectedChat,
replyingTo,
onClearReply: () => setReplyingTo(null),
onSendText: handleSendText,
editingMessage,
onClearEdit: () => setEditingMessage(null),
onEditText: async (messageId, text) => {
if (!selectedChat?.id) return;
try {
await chatApi.editMessage(String(selectedChat.id), messageId, text);
useChatStore.getState().patchMessage(messageId, { body: text });
} catch (e) { console.error('Erro ao editar:', e); }
},
});
const handleEnterEdit = useCallback((msg: any) => {
setEditingMessage(msg); setReplyingTo(null); setNewMessage(msg?.text ?? msg?.body ?? '');
}, [setEditingMessage, setReplyingTo, setNewMessage]);
// ── Números disponíveis (paciente + grupo familiar) ──────────────────────
const [numeros, setNumeros] = useState<NumeroOpcao[]>([]);
const [selectedPhone, setSelectedPhone] = useState<string>('');
const [numDropdownOpen, setNumDropdownOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// ── Estado de resolução da conversa ──────────────────────────────────────
const [resolving, setResolving] = useState(false);
@@ -126,6 +175,8 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
opts.push({ phone: d, nome, relacao });
};
// 0) Telefone CRU direto (pedido da fila): 1ª opção, funciona mesmo sem cadastro.
if (phone) push(phone, pacienteNome || 'Contato', 'Contato');
// 1) Telefone do próprio paciente — via cadastro (desambigua por id).
if (pacienteNome) {
try {
@@ -150,7 +201,7 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
setSelectedPhone(opts[0]?.phone || '');
})();
return () => { alive = false; };
}, [pacienteId, pacienteNome]);
}, [pacienteId, pacienteNome, phone]);
// Resolve a conversa (chatId) a partir do telefone selecionado, sem enviar nada.
const resolveChat = useCallback(async (phoneDigits: string) => {
@@ -230,48 +281,59 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="fixed inset-y-0 right-0 z-[191] w-full sm:w-[440px] bg-[#f0f2f5] shadow-2xl flex flex-col overflow-hidden"
>
{/* Barra superior do drawer: contato + dropdown de números + fechar */}
<div className="shrink-0 bg-[#008069] text-white px-3 py-2.5 flex items-center gap-2">
{/* Barra superior: nome + dropdown do GRUPO FAMILIAR (troca a conversa) + fechar */}
<div className="shrink-0 bg-[#008069] text-white px-3 py-2 flex items-center gap-2.5">
<MessageCircle className="w-5 h-5 shrink-0" />
<div className="min-w-0 flex-1 relative">
<button
type="button"
onClick={() => numeros.length > 1 && setNumDropdownOpen((v) => !v)}
className={`flex items-center gap-1.5 max-w-full ${numeros.length > 1 ? 'cursor-pointer' : 'cursor-default'}`}
>
<span className="font-bold text-sm truncate">{tituloContato}</span>
{selectedNumero && (
<span className="text-[11px] text-white/70 truncate">
{formatPhone(normalizeSendPhone(selectedNumero.phone))}
</span>
)}
{numeros.length > 1 && <ChevronDown className={`w-4 h-4 shrink-0 transition-transform ${numDropdownOpen ? 'rotate-180' : ''}`} />}
</button>
{/* Nome (truncado) + pill dropdown do grupo familiar — NA MESMA LINHA */}
<div className="flex items-center gap-2 min-w-0">
<p className="font-bold text-sm truncate leading-tight" title={tituloContato}>{shortContactName(tituloContato)}</p>
<button
type="button"
onClick={() => setNumDropdownOpen((v) => !v)}
className="shrink-0 inline-flex items-center gap-1.5 rounded-full bg-white/15 hover:bg-white/25 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide transition-colors"
>
<Users className="w-3.5 h-3.5 shrink-0" />
<span className="truncate max-w-[90px]">{selectedNumero?.relacao || 'Paciente'}</span>
<ChevronDown className={`w-3.5 h-3.5 shrink-0 transition-transform ${numDropdownOpen ? 'rotate-180' : ''}`} />
</button>
</div>
<AnimatePresence>
{numDropdownOpen && numeros.length > 1 && (
<motion.div
initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}
className="absolute left-0 top-full mt-1 w-72 bg-white text-gray-800 rounded-xl shadow-2xl border border-gray-100 py-1.5 z-10"
>
<p className="px-3 py-1 text-[9px] font-black uppercase tracking-widest text-gray-400">Número / grupo familiar</p>
{numeros.map((n) => (
<button
key={n.phone}
onClick={() => { setSelectedPhone(n.phone); setNumDropdownOpen(false); }}
className={`w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2.5 ${n.phone === selectedPhone ? 'bg-teal-50' : ''}`}
>
<div className="w-8 h-8 rounded-full bg-teal-50 flex items-center justify-center shrink-0">
<User size={15} className="text-teal-600" />
</div>
<div className="min-w-0">
<p className="text-xs font-black uppercase truncate">{n.nome}</p>
<p className="text-[10px] text-gray-400 font-bold">
<span className="text-teal-500">{n.relacao}</span> · {formatPhone(normalizeSendPhone(n.phone))}
</p>
</div>
</button>
))}
</motion.div>
{numDropdownOpen && (
<>
<div className="fixed inset-0 z-[5]" onClick={() => setNumDropdownOpen(false)} />
<motion.div
initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}
className="absolute left-0 top-full mt-1.5 w-72 bg-white text-gray-800 rounded-xl shadow-2xl border border-gray-100 py-1.5 z-10 overflow-hidden"
>
<p className="px-3 py-1.5 text-[9px] font-black uppercase tracking-widest text-gray-400 flex items-center gap-1.5">
<Users size={11} className="text-teal-500" /> Grupo familiar
</p>
{numeros.map((n) => (
<button
key={n.phone}
onClick={() => { setSelectedPhone(n.phone); setNumDropdownOpen(false); }}
className={`w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2.5 transition-colors ${n.phone === selectedPhone ? 'bg-teal-50' : ''}`}
>
<div className={`w-9 h-9 rounded-full flex items-center justify-center shrink-0 ${n.phone === selectedPhone ? 'bg-teal-500 text-white' : 'bg-teal-50 text-teal-600'}`}>
<User size={16} />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-black uppercase truncate">{n.nome}</p>
<p className="text-[10px] text-gray-400 font-bold truncate">
<span className="text-teal-500">{n.relacao}</span> · {formatPhone(normalizeSendPhone(n.phone))}
</p>
</div>
{n.phone === selectedPhone && <span className="text-[9px] font-black text-teal-600 uppercase shrink-0">ativo</span>}
</button>
))}
{numeros.length <= 1 && (
<p className="px-3 pt-2 pb-1 mt-1 border-t border-gray-50 text-[10px] text-gray-400 leading-snug">
Cadastre o <b className="text-gray-500">grupo familiar</b> no paciente (mãe, filho, cônjuge) para trocar de conversa aqui e notificar sobre o paciente.
</p>
)}
</motion.div>
</>
)}
</AnimatePresence>
</div>
@@ -378,9 +440,21 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
onSyncAvatar={handleSyncAvatar}
isChatMenuOpen={isChatMenuOpen}
isSyncingAvatar={isSyncingAvatar}
onSearchClick={() => {}}
isOwner={isWaOwner}
onClearConversation={handleClearConversation}
onManageLabels={() => setLabelModalOpen(true)}
onSearchClick={() => setIsSearchOpen(true)}
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
/>
{isSearchOpen && (
<ConversationSearch
messages={messages as any}
onClose={() => setIsSearchOpen(false)}
onQueryChange={setSearchQuery}
hasOlder={hasMoreHistory}
loadOlder={async () => { const r = await handleLoadMoreHistory(); return !!r?.hasMore; }}
/>
)}
<MessageArea
messages={messages as any}
selectedChat={selectedChat}
@@ -388,14 +462,16 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
msgLoading={loadingMessages}
onScroll={handleScroll}
historyLoadingMore={loadingMore}
highlightTerm={isSearchOpen ? searchQuery : ''}
hasMoreHistory={hasMoreHistory}
reactionPickerFor={reactionPickerFor}
onReply={setReplyingTo}
onToggleReactionPicker={toggleReactionPicker}
onReact={handleReactToMessage}
onForward={() => {}}
onDelete={() => {}}
onEdit={() => {}}
onForward={(msg: any) => setForwardingMessage(msg)}
onDelete={(msg: any) => { if (selectedChat && msg?.id) handleDeleteMessage(String(selectedChat.id), String(msg.id)); }}
canDelete={canDeleteMsg}
onEdit={handleEnterEdit}
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
getGroupSenderLabel={(msg: any) => {
if (msg.direction === 'out') return 'Você';
@@ -415,6 +491,8 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
onPresence={handleTyping}
onCancelReply={() => setReplyingTo(null)}
replyingTo={replyingTo}
editingMessage={editingMessage}
onCancelEdit={() => { setEditingMessage(null); setNewMessage(''); }}
selectedChat={selectedChat}
isMobile={true}
onSendMedia={handleSendMedia}
@@ -438,6 +516,24 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
)}
</div>
</motion.aside>
<LabelManagerModal
isOpen={labelModalOpen}
onClose={() => setLabelModalOpen(false)}
chat={selectedChat as any}
onChanged={() => { if (activeInstance?.instance) useChatStore.getState().loadChats(activeInstance.instance); }}
/>
<ForwardModal
isOpen={!!forwardingMessage}
onClose={() => setForwardingMessage(null)}
chats={useChatStore.getState().chats as any}
onForward={async (toChatIds: string[]) => {
if (!forwardingMessage || !selectedChat?.id) return;
try { await chatApi.forward(String(selectedChat.id), String((forwardingMessage as any).id), toChatIds); }
catch (e) { console.error('Erro ao encaminhar:', e); }
setForwardingMessage(null);
}}
/>
</>
);
};
@@ -0,0 +1,107 @@
'use client';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Search, ChevronUp, ChevronDown, X, Loader2 } from 'lucide-react';
interface Msg { message_id?: string; id?: string | number; text?: string; transcription?: string | null }
// Barra de busca DENTRO da conversa: filtra as mensagens por texto (corpo +
// transcrição), rola até o resultado e o realça. Navega com ↑/↓ (ou Enter/Shift+
// Enter). Se o termo não estiver nas mensagens já carregadas, CARREGA o histórico
// antigo automaticamente (uma página por vez, até achar ou acabar). Usa o id do DOM
// já existente na bolha (id="msg-<message_id>").
const MAX_PAGES = 40; // teto de páginas antigas a carregar por busca (~evita runaway)
const ConversationSearch: React.FC<{
messages: Msg[];
onClose: () => void;
loadOlder?: () => Promise<boolean>; // carrega 1 página antiga; retorna se ainda há mais
hasOlder?: boolean;
onQueryChange?: (q: string) => void; // avisa o pai p/ realçar o termo nas bolhas
}> = ({ messages, onClose, loadOlder, hasOlder, onQueryChange }) => {
const [q, setQ] = useState('');
const [idx, setIdx] = useState(0);
const [autoLoading, setAutoLoading] = useState(false);
const [pagesLoaded, setPagesLoaded] = useState(0);
const loadingRef = useRef(false);
const matches = useMemo(() => {
const t = q.trim().toLowerCase();
if (!t) return [] as string[];
return messages
.filter((m) => `${m.text ?? ''} ${m.transcription ?? ''}`.toLowerCase().includes(t))
.map((m) => String(m.message_id ?? m.id ?? ''))
.filter(Boolean);
}, [q, messages]);
// Reinicia navegação e o contador de páginas ao mudar o termo; avisa o pai.
useEffect(() => { setIdx(0); setPagesLoaded(0); onQueryChange?.(q); }, [q]);
// Ao desmontar (fechar busca), limpa o realce.
useEffect(() => () => onQueryChange?.(''), []);
// Sem resultados nas mensagens carregadas → puxa mais histórico antigo (1 página
// por vez; o React recomputa `matches` e o efeito re-roda até achar ou acabar).
useEffect(() => {
if (!q.trim() || !loadOlder || !hasOlder || loadingRef.current) return;
if (matches.length > 0 || pagesLoaded >= MAX_PAGES) return;
loadingRef.current = true;
setAutoLoading(true);
let cancelled = false;
loadOlder()
.then(() => { if (!cancelled) setPagesLoaded((p) => p + 1); })
.finally(() => { if (!cancelled) { loadingRef.current = false; setAutoLoading(false); } });
return () => { cancelled = true; loadingRef.current = false; };
}, [q, matches.length, hasOlder, pagesLoaded, loadOlder]);
// Rola até o resultado atual e o realça brevemente.
useEffect(() => {
if (!matches.length) return;
const id = matches[Math.min(idx, matches.length - 1)];
const el = document.getElementById(`msg-${id}`);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('bg-[#00a884]/15', 'rounded-xl', 'transition-colors');
const t = setTimeout(() => el.classList.remove('bg-[#00a884]/15', 'rounded-xl'), 1400);
return () => clearTimeout(t);
}, [idx, matches]);
const go = (d: number) => { if (matches.length) setIdx((i) => (i + d + matches.length) % matches.length); };
// Texto do contador: buscando / n de N / nenhum.
const counter = !q.trim()
? ''
: matches.length
? `${idx + 1}/${matches.length}`
: (autoLoading ? '' : '0');
return (
<div className="shrink-0 flex items-center gap-2 px-3 py-2 bg-[#f0f2f5] border-b border-[#d1d7db] z-20">
<Search className="w-4 h-4 text-[#54656f] shrink-0" />
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') go(e.shiftKey ? -1 : 1);
if (e.key === 'Escape') onClose();
}}
placeholder="Buscar na conversa..."
className="flex-1 min-w-0 bg-transparent outline-none text-sm text-[#111b21] placeholder:text-[#8696a0]"
/>
{autoLoading && !matches.length && (
<span className="flex items-center gap-1 text-[10px] text-[#667781] font-bold shrink-0">
<Loader2 className="w-3 h-3 animate-spin" /> antigas
</span>
)}
{counter && <span className="text-[11px] text-[#667781] font-bold shrink-0 tabular-nums">{counter}</span>}
<button onClick={() => go(-1)} disabled={!matches.length} title="Anterior"
className="p-1 rounded-full hover:bg-black/10 disabled:opacity-40 text-[#54656f] transition-colors"><ChevronUp className="w-4 h-4" /></button>
<button onClick={() => go(1)} disabled={!matches.length} title="Próximo"
className="p-1 rounded-full hover:bg-black/10 disabled:opacity-40 text-[#54656f] transition-colors"><ChevronDown className="w-4 h-4" /></button>
<button onClick={onClose} title="Fechar busca"
className="p-1 rounded-full hover:bg-black/10 text-[#54656f] transition-colors"><X className="w-4 h-4" /></button>
</div>
);
};
export default ConversationSearch;
@@ -0,0 +1,103 @@
import React, { useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { X, Search, Send, Check } from 'lucide-react';
interface ForwardModalProps {
isOpen: boolean;
onClose: () => void;
chats: any[];
onForward: (toChatIds: string[]) => Promise<void> | void;
}
// Modal para escolher um ou mais chats de destino e encaminhar a mensagem selecionada.
const ForwardModal: React.FC<ForwardModalProps> = ({ isOpen, onClose, chats, onForward }) => {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sending, setSending] = useState(false);
const label = (c: any) => c.displayName || c.subject || c.name || c.phone || c.remote_jid || String(c.id);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return chats;
return chats.filter((c) => label(c).toLowerCase().includes(q));
}, [chats, query]);
if (!isOpen || typeof document === 'undefined') return null;
const toggle = (id: string) => {
setSelected((prev) => {
const n = new Set(prev);
n.has(id) ? n.delete(id) : n.add(id);
return n;
});
};
const handleForward = async () => {
if (selected.size === 0 || sending) return;
setSending(true);
try { await onForward(Array.from(selected)); }
finally { setSending(false); setSelected(new Set()); setQuery(''); }
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
<div className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h3 className="text-[15px] font-bold text-[#111b21]">Encaminhar para</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-700 rounded-full transition-colors"><X className="w-5 h-5" /></button>
</div>
<div className="px-4 py-2.5 border-b border-gray-100">
<div className="flex items-center gap-2 bg-[#f0f2f5] rounded-lg px-3 py-2">
<Search className="w-4 h-4 text-[#667781] shrink-0" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Pesquisar conversa"
className="w-full bg-transparent text-[14px] text-[#111b21] placeholder:text-[#667781] focus:outline-none"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filtered.length === 0 && (
<div className="px-4 py-6 text-center text-[13px] text-[#667781]">Nenhuma conversa encontrada.</div>
)}
{filtered.map((c) => {
const id = String(c.id);
const isSel = selected.has(id);
return (
<button
key={id}
onClick={() => toggle(id)}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-[#f5f6f6] transition-colors text-left"
>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors ${isSel ? 'bg-[#00a884] border-[#00a884]' : 'border-gray-300'}`}>
{isSel && <Check className="w-3 h-3 text-white" />}
</div>
<span className="text-[14px] text-[#111b21] truncate">{label(c)}</span>
</button>
);
})}
</div>
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
<span className="text-[12px] text-[#667781]">{selected.size} selecionada(s)</span>
<button
onClick={handleForward}
disabled={selected.size === 0 || sending}
className="flex items-center gap-2 bg-[#00a884] text-white text-[14px] font-bold px-4 py-2 rounded-lg disabled:opacity-40 hover:bg-[#008f6f] transition-colors"
>
<Send className="w-4 h-4" />
{sending ? 'Encaminhando…' : 'Encaminhar'}
</button>
</div>
</div>
</div>,
document.body,
);
};
export default ForwardModal;
+61 -24
View File
@@ -43,6 +43,8 @@ interface InputBarProps {
onSendAudio: (blob: Blob) => Promise<void>;
replyingTo: Message | null;
onCancelReply: () => void;
editingMessage?: Message | null;
onCancelEdit?: () => void;
isMobile: boolean;
selectedChat: any;
instanceId?: string | null;
@@ -59,6 +61,8 @@ const InputBar: React.FC<InputBarProps> = ({
onSendAudio,
replyingTo,
onCancelReply,
editingMessage,
onCancelEdit,
isMobile,
selectedChat,
instanceId,
@@ -205,10 +209,11 @@ const InputBar: React.FC<InputBarProps> = ({
}, []);
const handleSendSticker = useCallback(async (blob: Blob) => {
if (!instanceId || !selectedChat?.remote_jid) return;
if (!selectedChat?.id) return;
setShowStickerPanel(false);
await mediaApi.sendSticker(instanceId, selectedChat.remote_jid, blob);
}, [instanceId, selectedChat]);
// mediaApi do satélite é por chatId (não instanceId/jid).
await mediaApi.sendSticker(String(selectedChat.id), blob);
}, [selectedChat]);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Mention dropdown navigation
@@ -405,6 +410,32 @@ const InputBar: React.FC<InputBarProps> = ({
)}
</AnimatePresence>
{/* Edit banner */}
<AnimatePresence>
{editingMessage && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="mx-4 mb-2 bg-white/80 backdrop-blur-sm border-l-[4px] border-[#f59e0b] rounded-lg px-4 py-3 flex items-start justify-between gap-4 shadow-sm"
>
<div className="min-w-0">
<div className="text-[12px] font-medium text-[#b45309] mb-1">Editando mensagem</div>
<div className="text-sm text-gray-500 font-medium truncate italic">
{editingMessage.text || 'Mensagem'}
</div>
</div>
<button
type="button"
className="bg-gray-100 hover:bg-gray-200 text-gray-500 p-1 rounded-full transition-colors"
onClick={onCancelEdit}
>
<X className="w-3.5 h-3.5 stroke-[3px]" />
</button>
</motion.div>
)}
</AnimatePresence>
{/* Reply Preview */}
<AnimatePresence>
{replyingTo && (
@@ -513,30 +544,36 @@ const InputBar: React.FC<InputBarProps> = ({
Mensagem Rica
</span>
</button>
{/* Emoji */}
<button
onClick={() => { setShowEmojiPicker(true); setShowStickerPanel(false); setShowAttachMenu(false); }}
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
title="Emojis"
>
<Smile className="w-[22px] h-[22px]" />
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
Emoji
</span>
</button>
{/* Figurinhas */}
{instanceId && (
<button
onClick={() => { setShowStickerPanel(true); setShowEmojiPicker(false); setShowAttachMenu(false); }}
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
title="Figurinhas"
>
<Sticker className="w-[22px] h-[22px]" />
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
Figurinhas
</span>
</button>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Emoji — mantém no lugar */}
<button
onClick={() => { setShowEmojiPicker(!showEmojiPicker); setShowStickerPanel(false); }}
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showEmojiPicker ? 'text-[#00a884]' : 'text-[#54656f]'}`}
title="Emojis"
>
<Smile className="w-[26px] h-[26px]" />
</button>
{/* Figurinhas */}
{instanceId && (
<button
onClick={() => { setShowStickerPanel(!showStickerPanel); setShowEmojiPicker(false); }}
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showStickerPanel ? 'text-[#00a884]' : 'text-[#54656f]'}`}
title="Figurinhas"
>
<Sticker className="w-[24px] h-[24px]" />
</button>
)}
</div>
<div className="flex-1 relative">
@@ -589,7 +626,7 @@ const InputBar: React.FC<InputBarProps> = ({
ref={textareaRef}
rows={1}
placeholder="Digite uma mensagem"
className="w-full px-3 py-2.5 bg-white rounded-lg text-[15px] text-[#111b21] border-none focus:ring-0 focus:outline-none placeholder:text-[#667781] resize-none shadow-sm transition-all overflow-hidden leading-[1.4]"
className="w-full px-3.5 py-3 bg-white rounded-xl text-[15px] text-[#111b21] border-none focus:ring-0 focus:outline-none placeholder:text-[#667781] resize-none shadow-sm transition-all overflow-hidden leading-[1.4]"
value={newMessage}
onChange={(e) => {
onNewMessageChange(e.target.value);
@@ -0,0 +1,148 @@
import React, { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { X, Plus, Trash2, Check, Tag } from 'lucide-react';
import { labelApi, type Label } from '../services/chatApiService';
interface LabelManagerModalProps {
isOpen: boolean;
onClose: () => void;
// Se fornecido, mostra atribuição de etiquetas para ESTA conversa.
chat?: { id: any; labels?: Label[] } | null;
// Chamado após qualquer mudança (para o inbox recarregar os chips).
onChanged?: () => void;
}
const PALETTE = ['#00a884', '#ef4444', '#f59e0b', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6', '#64748b'];
const LabelManagerModal: React.FC<LabelManagerModalProps> = ({ isOpen, onClose, chat, onChanged }) => {
const [labels, setLabels] = useState<Label[]>([]);
const [loading, setLoading] = useState(false);
const [newName, setNewName] = useState('');
const [newColor, setNewColor] = useState(PALETTE[0]);
const [assigned, setAssigned] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const list = await labelApi.list();
setLabels(Array.isArray(list) ? list : []);
} catch { /* ignore */ } finally { setLoading(false); }
}, []);
useEffect(() => {
if (!isOpen) return;
load();
setAssigned(new Set((chat?.labels ?? []).map((l) => l.id)));
}, [isOpen, chat, load]);
if (!isOpen || typeof document === 'undefined') return null;
const create = async () => {
if (!newName.trim()) return;
try {
await labelApi.create(newName.trim(), newColor);
setNewName('');
await load();
onChanged?.();
} catch { /* ignore */ }
};
const remove = async (id: string) => {
try { await labelApi.delete(id); setAssigned((s) => { const n = new Set(s); n.delete(id); return n; }); await load(); onChanged?.(); }
catch { /* ignore */ }
};
const toggleAssign = (id: string) => {
setAssigned((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
};
const saveAssignments = async () => {
if (!chat?.id || saving) return;
setSaving(true);
try { await labelApi.setForChat(String(chat.id), Array.from(assigned)); onChanged?.(); onClose(); }
catch { /* ignore */ } finally { setSaving(false); }
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
<div className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[85vh]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h3 className="text-[15px] font-bold text-[#111b21] flex items-center gap-2"><Tag className="w-4 h-4 text-[#00a884]" /> Etiquetas</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-700 rounded-full transition-colors"><X className="w-5 h-5" /></button>
</div>
<div className="flex-1 overflow-y-auto">
{/* Atribuição à conversa */}
{chat && (
<div className="px-4 py-3 border-b border-gray-100">
<div className="text-[12px] font-bold text-[#667781] uppercase tracking-wide mb-2">Nesta conversa</div>
{labels.length === 0 && <div className="text-[13px] text-[#667781]">Nenhuma etiqueta criada ainda.</div>}
<div className="flex flex-wrap gap-2">
{labels.map((l) => {
const on = assigned.has(l.id);
return (
<button key={l.id} onClick={() => toggleAssign(l.id)}
className={`flex items-center gap-1.5 pl-2 pr-2.5 py-1 rounded-full text-[13px] font-medium border transition-all ${on ? 'text-white' : 'text-[#111b21] bg-white'}`}
style={on ? { backgroundColor: l.color, borderColor: l.color } : { borderColor: l.color }}>
{on && <Check className="w-3.5 h-3.5" />}
<span style={!on ? { color: l.color } : undefined}>{l.name}</span>
</button>
);
})}
</div>
</div>
)}
{/* Lista/gestão */}
<div className="px-4 py-3">
<div className="text-[12px] font-bold text-[#667781] uppercase tracking-wide mb-2">Todas as etiquetas</div>
{loading && <div className="text-[13px] text-[#667781]">Carregando</div>}
<div className="space-y-1.5">
{labels.map((l) => (
<div key={l.id} className="flex items-center gap-2.5 py-1">
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ backgroundColor: l.color }} />
<span className="flex-1 text-[14px] text-[#111b21] truncate">{l.name}</span>
<button onClick={() => remove(l.id)} className="p-1 text-gray-300 hover:text-red-500 transition-colors"><Trash2 className="w-4 h-4" /></button>
</div>
))}
</div>
</div>
{/* Criar */}
<div className="px-4 py-3 border-t border-gray-100">
<div className="text-[12px] font-bold text-[#667781] uppercase tracking-wide mb-2">Nova etiqueta</div>
<div className="flex items-center gap-2 mb-2">
<input value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && create()}
placeholder="Nome da etiqueta"
className="flex-1 bg-[#f0f2f5] rounded-lg px-3 py-2 text-[14px] text-[#111b21] placeholder:text-[#667781] focus:outline-none" />
<button onClick={create} disabled={!newName.trim()}
className="flex items-center gap-1 bg-[#00a884] text-white text-[13px] font-bold px-3 py-2 rounded-lg disabled:opacity-40 hover:bg-[#008f6f] transition-colors">
<Plus className="w-4 h-4" /> Criar
</button>
</div>
<div className="flex items-center gap-2">
{PALETTE.map((c) => (
<button key={c} onClick={() => setNewColor(c)}
className={`w-6 h-6 rounded-full transition-transform ${newColor === c ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''}`}
style={{ backgroundColor: c }} />
))}
</div>
</div>
</div>
{chat && (
<div className="px-4 py-3 border-t border-gray-100 flex justify-end">
<button onClick={saveAssignments} disabled={saving}
className="bg-[#00a884] text-white text-[14px] font-bold px-4 py-2 rounded-lg disabled:opacity-40 hover:bg-[#008f6f] transition-colors">
{saving ? 'Salvando…' : 'Salvar nesta conversa'}
</button>
</div>
)}
</div>
</div>,
document.body,
);
};
export default LabelManagerModal;
@@ -118,7 +118,22 @@ export const extractFirstUrl = (text: string | null | undefined): string | null
return m ? m[0] : null;
};
// Renders plain text with URLs converted to clickable <a> tags
// Formatação inline estilo WhatsApp: *negrito*, _itálico_, ~tachado~. Guarda contra
// falso-positivo exigindo não-espaço colado às marcas (evita "3 * 4" virar negrito).
const INLINE_RE = /(\*\S(?:[^*\n]*\S)?\*|_\S(?:[^_\n]*\S)?_|~\S(?:[^~\n]*\S)?~)/g;
const renderInline = (text: string, keyBase: string): React.ReactNode[] => {
if (!text) return [];
return text.split(INLINE_RE).map((part, i) => {
if (!part) return null;
const key = `${keyBase}-${i}`;
if (/^\*\S(?:[^*\n]*\S)?\*$/.test(part)) return <strong key={key} className="font-semibold">{part.slice(1, -1)}</strong>;
if (/^_\S(?:[^_\n]*\S)?_$/.test(part)) return <em key={key}>{part.slice(1, -1)}</em>;
if (/^~\S(?:[^~\n]*\S)?~$/.test(part)) return <s key={key}>{part.slice(1, -1)}</s>;
return <React.Fragment key={key}>{part}</React.Fragment>;
});
};
// Renders text with URLs as <a> tags + WhatsApp inline formatting (bold/italic/strike)
export const renderTextWithLinks = (text: string): React.ReactNode[] => {
const parts = text.split(/(https?:\/\/[^\s<>"{}|\\^`[\]]{4,})/g);
return parts.map((part, i) => {
@@ -136,7 +151,7 @@ export const renderTextWithLinks = (text: string): React.ReactNode[] => {
</a>
);
}
return <span key={i}>{part}</span>;
return <React.Fragment key={i}>{renderInline(part, String(i))}</React.Fragment>;
});
};
@@ -104,7 +104,7 @@ const AudioBubble: React.FC<AudioBubbleProps> = ({ msg, fullMediaUrl, isOut, Tim
onEnded={() => { setPlaying(false); setCurrentTime(0) }}
preload="metadata"
/>
<div className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px]">
<div className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] max-w-full">
{/* Play/pause */}
<button
onClick={toggle}
@@ -341,7 +341,9 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
// o trecho casado vira <mark>. Sem termo/sem match → comportamento normal.
const renderTextHL = (text?: string | null): React.ReactNode => {
const t = (highlightTerm ?? '').trim();
const src = text ?? '';
// Assinatura do operador: 1ª linha "*NOME*" (negrito WhatsApp) seguida de \n vira
// "*NOME:*" — exibe "NOME:" em negrito antes da quebra. Idempotente (não duplica :).
const src = (text ?? '').replace(/^\*([^*\n]+?):?\*(\n)/, '*$1:*$2');
if (!t) return renderTextWithLinks(src);
const lower = src.toLowerCase();
const lt = t.toLowerCase();
@@ -750,13 +752,13 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
<div>
<div
onClick={mediaUnavailable ? undefined : handleRedownload}
className={`flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed]'}`}
className={`flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] max-w-full transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed]'}`}
>
{isRedownloading
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin shrink-0" />
: <div className="w-10 h-10 rounded-full bg-[#8696a0] flex items-center justify-center shrink-0"><Mic className="w-4 h-4 text-white" /></div>
}
<span className="text-[12px] text-[#667781]">
<span className="text-[12px] text-[#667781] min-w-0 truncate">
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Áudio não disponível' : 'Toque para baixar áudio'}
</span>
</div>
@@ -898,7 +900,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
{/* Authentic WhatsApp Message Menu Button */}
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className={`absolute top-1 right-1 p-1 text-slate-500 opacity-0 group-hover:opacity-100 hover:text-slate-300 transition-opacity z-30`}
className={`absolute top-1 right-1 p-1 text-slate-500 hover:text-slate-300 transition-opacity z-30 ${(isMenuOpen || reactionPickerFor === msg.message_id) ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100'}`}
>
<ChevronDown className="w-5 h-5" />
</button>
@@ -121,6 +121,8 @@ function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: Protoc
<AnimatePresence>
{showActions && (
<>
<div className="fixed inset-0 z-[9]" onClick={() => setShowActions(false)} />
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
@@ -141,6 +143,7 @@ function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: Protoc
)
})}
</motion.div>
</>
)}
</AnimatePresence>
</div>
@@ -42,7 +42,9 @@ export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState<any>(null);
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8008';
// Same-origin (NÃO localhost do PC — isso disparava o prompt de "acessar rede local").
// A troca de engine é recurso do standalone; no satélite degrada gracioso (404).
const API_URL = '';
const authHeader = (): Record<string, string> => {
const t = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
return t ? { Authorization: `Bearer ${t}` } : {};
@@ -6,7 +6,6 @@ import { Heart, Clock, Loader2 } from 'lucide-react';
import { stickerApi, type StickerRecord } from '../services/chatApiService';
import { getStickerFromCache, setStickerInCache } from '../hooks/useStickerCache';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003';
const LIST_VERSION_KEY = 'nw:sticker-list-version';
interface StickerPanelProps {
@@ -15,10 +14,10 @@ interface StickerPanelProps {
onSend: (blob: Blob) => void;
}
// Converte URL Wasabi → URL do proxy do backend
// Converte path Wasabi → URL same-origin do proxy nw (NÃO localhost do PC).
function stickerSrcUrl(wasabiPath: string): string {
const clean = wasabiPath.startsWith('/') ? wasabiPath.slice(1) : wasabiPath;
return `${API}/api/storage/view/${clean}`;
return `/api/nw/media/${clean}`;
}
// Carrega sticker: tenta IndexedDB, depois proxy, depois persiste no cache
@@ -10,7 +10,8 @@
* - abertura de perfil / menu de chat
*/
import { useState, useRef, useCallback } from 'react'
import { mediaApi } from '../../services/chatApiService'
import { mediaApi, chatApi, avatarApi } from '../../services/chatApiService'
import { useChatStore } from '../../store/chatStore'
import type { NewWhatsChat } from '../../types/inboxTypes'
import type { Message } from '../../types/inboxTypes'
@@ -39,20 +40,32 @@ export function useChatWindow({
const [isProfileOpen, setIsProfileOpen] = useState(false)
const [isSyncingAvatar, setIsSyncingAvatar] = useState(false)
const [isChatMenuOpen, setIsChatMenuOpen] = useState(false)
const [editingMessage, setEditingMessage] = useState<Message | null>(null)
const [forwardingMessage, setForwardingMessage] = useState<Message | null>(null)
// ── Handlers ────────────────────────────────────────────────────────────────
// Chamado quando O OPERADOR digita no input. NÃO deve acender o "digitando" — esse
// indicador é da presença `composing` do CONTATO (outro lado), não da minha. Antes,
// digitar no input marcava isTyping=true localmente, mostrando "digitando" para mim
// mesmo. Mantido como no-op até o motor repassar a presença do contato pelo stream.
// Envia MINHA presença "composing" ao contato ao digitar (throttle ~3s) e "paused"
// depois de ~4s de ociosidade. Não altera o indicador local (que é do CONTATO).
const lastComposingRef = useRef(0)
const pausedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleTyping = useCallback(() => {
if (!selectedChat) return
if (!isTypingRef.current) {
isTypingRef.current = true
setIsTyping(true)
const chatId = selectedChat?.id ? String(selectedChat.id) : null
if (!chatId) return
const now = Date.now()
if (now - lastComposingRef.current > 3000) {
lastComposingRef.current = now
chatApi.typing(chatId, 'composing').catch(() => {})
}
if (typingTimeout.current) clearTimeout(typingTimeout.current)
typingTimeout.current = setTimeout(() => {
isTypingRef.current = false
setIsTyping(false)
}, 3000)
if (pausedTimerRef.current) clearTimeout(pausedTimerRef.current)
pausedTimerRef.current = setTimeout(() => {
lastComposingRef.current = 0
chatApi.typing(chatId, 'paused').catch(() => {})
}, 4000)
}, [selectedChat])
const handleScroll = useCallback(async () => {
@@ -70,39 +83,54 @@ export function useChatWindow({
setHasMoreHistory(true)
}, [])
const handleReactToMessage = useCallback((_msg: Message, _emoji: string) => {
// Reage a uma mensagem: envia ao WhatsApp e atualiza o store na hora (otimista).
// Toggle: clicar no mesmo emoji já reagido remove a reação. Reverte em caso de erro.
const handleReactToMessage = useCallback(async (msg: Message, emoji: string) => {
setReactionPickerFor(null)
// Será implementado via endpoint /api/instances/:id/react
}, [])
const handleSendMedia = useCallback(async (file: File, caption?: string) => {
if (!selectedChat || !activeInstanceId) return
const chatId = selectedChat?.id ? String(selectedChat.id) : null
if (!chatId || !msg?.id) return
const current = (msg as any).reactions?.me as string | undefined
const next = current === emoji ? '' : emoji
useChatStore.getState().reactMessage(chatId, String(msg.id), next)
try {
await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, caption || undefined, undefined)
await chatApi.react(chatId, String(msg.id), next)
} catch (err) {
console.error('Erro ao reagir:', err)
useChatStore.getState().reactMessage(chatId, String(msg.id), current || '') // reverte
}
}, [selectedChat])
// O mediaApi do satélite é por chatId (a ext resolve jid/instância): sendMedia
// (chatId, file, caption?) e sendAudio(chatId, blob). Antes passávamos a assinatura
// antiga do motor (instanceId, jid, ...) e o blob caía no lugar errado → o FormData
// recebia o jid (string) em vez do Blob e quebrava o envio.
const handleSendMedia = useCallback(async (file: File, caption?: string) => {
if (!selectedChat?.id) return
try {
await mediaApi.sendMedia(String(selectedChat.id), file, caption || undefined)
} catch (err) {
console.error('Erro ao enviar mídia:', err)
}
}, [selectedChat, activeInstanceId])
}, [selectedChat])
const handleSendAudio = useCallback(async (blob: Blob) => {
if (!selectedChat || !activeInstanceId) return
if (!selectedChat?.id) return
try {
await mediaApi.sendAudio(activeInstanceId, selectedChat.remote_jid, blob)
await mediaApi.sendAudio(String(selectedChat.id), blob)
} catch (err) {
console.error('Erro ao enviar áudio:', err)
}
}, [selectedChat, activeInstanceId])
}, [selectedChat])
const handleSyncAvatar = useCallback(async () => {
if (!selectedChat || !activeInstanceId) return
setIsSyncingAvatar(true)
try {
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003'
const token = localStorage.getItem('token')
await fetch(
`${API}/api/inbox/avatar/${encodeURIComponent(selectedChat.remote_jid)}?instance=${activeInstanceId}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
// Via proxy nw (NÃO localhost do PC — isso disparava o prompt de "acessar rede local").
const avatar = await avatarApi.refresh(activeInstanceId, selectedChat.remote_jid)
if (avatar && selectedChat.id) useChatStore.getState().patchChat(String(selectedChat.id), { contactAvatarUrl: avatar } as any)
} catch (err) {
console.error('Erro ao sincronizar foto:', err)
} finally {
setIsSyncingAvatar(false)
}
@@ -112,6 +140,23 @@ export function useChatWindow({
setReactionPickerFor(prev => prev === id ? null : id)
}, [])
// "Limpar conversa" (só o dono — a visibilidade do botão já é gateada por isOwner).
// Remove todas as mensagens do chat no motor e esvazia a lista local. Não revoga no
// WhatsApp; é limpeza local do inbox (análogo ao "Limpar conversa" do WhatsApp).
const handleClearConversation = useCallback(async () => {
if (!selectedChat?.id) return
const id = String(selectedChat.id)
if (typeof window !== 'undefined' &&
!window.confirm('Limpar esta conversa? Todas as mensagens serão removidas do inbox. Esta ação não pode ser desfeita.')) return
try {
await chatApi.clearConversation(id)
useChatStore.getState().setMessages(id, [])
} catch (err) {
console.error('Erro ao limpar conversa:', err)
if (typeof window !== 'undefined') window.alert('Não foi possível limpar a conversa.')
}
}, [selectedChat])
return {
// Refs
scrollRef,
@@ -130,6 +175,10 @@ export function useChatWindow({
setReplyingTo,
setIsProfileOpen,
setIsChatMenuOpen,
editingMessage,
setEditingMessage,
forwardingMessage,
setForwardingMessage,
// Handlers
handleTyping,
@@ -140,5 +189,6 @@ export function useChatWindow({
handleSendAudio,
handleSyncAvatar,
toggleReactionPicker,
handleClearConversation,
}
}
@@ -18,6 +18,21 @@ export interface LegacyInstance {
phone: string | null
avatar: string | null
profilePictureUrl: string | null
// Seletor multi-conta:
role: string | null
label: string | null
area: string | null
notes: string | null
clinicaId: string | null
ownerNome: string | null
own: boolean
}
// Conta ativa da inbox — lida pelo nwClient p/ decidir o x-nw-clinica das rotas de
// inbox (clinicaId null = conta própria → o proxy fala como o próprio usuário).
const INBOX_ACCOUNT_KEY = 'nw:inbox-account'
function persistInboxAccount(clinicaId: string | null, instanceId: string) {
try { localStorage.setItem(INBOX_ACCOUNT_KEY, JSON.stringify({ clinicaId, instanceId })) } catch { /* ignore */ }
}
export interface MappedInstance {
@@ -46,11 +61,18 @@ export function useInstanceSelector() {
const instancesLegacy = useMemo<LegacyInstance[]>(() =>
instances.map((i) => ({
instance: i.id,
nome: i.name,
nome: i.label || i.name,
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
phone: i.phone,
avatar: i.avatar ?? null,
profilePictureUrl: i.avatar ?? null,
role: i.role ?? null,
label: i.label ?? null,
area: i.area ?? null,
notes: i.notes ?? null,
clinicaId: i.clinicaId ?? null,
ownerNome: i.ownerNome ?? null,
own: i.own ?? true,
})),
[instances]
)
@@ -83,20 +105,38 @@ export function useInstanceSelector() {
useEffect(() => {
if (!activeId && instances.length > 0) {
const first = instances[0]
persistInboxAccount(first.clinicaId ?? null, first.id)
setActiveId(first.id)
socketService.joinInstance(first.id)
}
}, [instances, activeId, setActiveId])
// Mantém nw:inbox-account SEMPRE em sincronia com a instância ativa — inclusive
// quando activeId é restaurado do localStorage (aí o auto-select acima não roda).
// Sem isto, o nw:inbox-account fica com a clínica de uma seleção anterior e o
// inbox da conta própria vai com x-nw-clinica errado → proxy reescreve → 404.
useEffect(() => {
if (!activeId || instances.length === 0) return
const inst = instances.find((i) => i.id === activeId)
if (inst) {
persistInboxAccount(inst.clinicaId ?? null, activeId)
socketService.syncAccount() // realtime no tenant certo (própria vs. clínica)
}
}, [activeId, instances])
// ── Troca de instância ────────────────────────────────────────────────────
const selectInstance = useCallback((legacyInstance: LegacyInstance | { instance: string }) => {
const id = legacyInstance.instance
if (id === activeId) return
// Fixa a conta ativa ANTES de carregar os chats: o nwClient usa isso p/ decidir
// o x-nw-clinica (conta própria vs. caixa da clínica) nas rotas de inbox.
const clinicaId = instances.find((i) => i.id === id)?.clinicaId ?? null
persistInboxAccount(clinicaId, id)
resetChats([])
setActiveId(id)
socketService.joinInstance(id)
}, [activeId, setActiveId, resetChats])
}, [activeId, setActiveId, resetChats, instances])
// ── Refresh ────────────────────────────────────────────────────────────────
@@ -11,11 +11,14 @@ import type { NewWhatsChat } from '../../types/inboxTypes'
interface UseMessageInputOptions {
selectedChat: NewWhatsChat | null
replyingTo?: { message_id: string } | null
editingMessage?: { id: string | number } | null
onEditText?: (messageId: string, text: string) => Promise<void>
onClearEdit?: () => void
onClearReply?: () => void
onSendText: (text: string, mentions?: string[], replyToMessageId?: string) => Promise<void>
}
export function useMessageInput({ selectedChat, replyingTo, onClearReply, onSendText }: UseMessageInputOptions) {
export function useMessageInput({ selectedChat, replyingTo, editingMessage, onEditText, onClearEdit, onClearReply, onSendText }: UseMessageInputOptions) {
const [newMessage, setNewMessage] = useState('')
const [mentions, setMentions] = useState<string[]>([])
@@ -23,13 +26,20 @@ export function useMessageInput({ selectedChat, replyingTo, onClearReply, onSend
e?.preventDefault()
if (!selectedChat || !newMessage.trim()) return
const text = newMessage
// Modo edição: salva a edição da mensagem em vez de enviar uma nova.
if (editingMessage) {
setNewMessage('')
onClearEdit?.()
await onEditText?.(String(editingMessage.id), text)
return
}
const mentionJids = mentions.length > 0 ? [...mentions] : undefined
const replyToMessageId = replyingTo?.message_id
setNewMessage('')
setMentions([])
onClearReply?.()
await onSendText(text, mentionJids, replyToMessageId)
}, [selectedChat, newMessage, mentions, replyingTo, onSendText, onClearReply])
}, [selectedChat, newMessage, mentions, replyingTo, editingMessage, onEditText, onClearEdit, onSendText, onClearReply])
return {
newMessage,
+30 -5
View File
@@ -9,14 +9,39 @@ export function nwToken(): string | null {
try { return localStorage.getItem(TOKEN_KEY); } catch { return null; }
}
// Clínica do workspace ativo — escopo por-requisição (modelo de canal).
function activeClinicaId(): string | null {
// Clínica do workspace ativo (agenda/secretária) — escopo por-requisição (modelo de canal).
function activeWorkspaceId(): string | null {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_ACTIVE_WORKSPACE') || '{}')?.id || null; } catch { return null; }
}
function authHeaders(extra?: Record<string, string>): Record<string, string> {
// Conta ativa da INBOX (seletor de número multi-conta). Quando o usuário escolhe um
// número no seletor, guardamos { clinicaId, instanceId }. clinicaId null = conta
// PRÓPRIA (sem x-nw-clinica → o proxy fala com o motor como o próprio usuário).
export const INBOX_ACCOUNT_KEY = 'nw:inbox-account';
function inboxAccountClinica(): { set: boolean; clinicaId: string | null } {
try {
const raw = localStorage.getItem(INBOX_ACCOUNT_KEY);
if (!raw) return { set: false, clinicaId: null };
const a = JSON.parse(raw);
return a && a.instanceId ? { set: true, clinicaId: a.clinicaId ?? null } : { set: false, clinicaId: null };
} catch { return { set: false, clinicaId: null }; }
}
// Resolve o x-nw-clinica da requisição. Rotas de inbox usam SEMPRE a CONTA ATIVA do
// seletor (nunca o workspace global da agenda) — assim a caixa própria não herda a
// clínica selecionada na agenda, o que faria o proxy reescrever para o dono errado
// e o motor devolver 404. clinicaId null (conta própria/não-setado) → sem header.
// As demais rotas (agenda/secretária) seguem o workspace global.
function clinicaForPath(path: string): string | null {
if (/^\/(inbox|conversations)(\/|$|\?)/.test(path)) {
return inboxAccountClinica().clinicaId;
}
return activeWorkspaceId();
}
function authHeaders(path: string, extra?: Record<string, string>): Record<string, string> {
const t = nwToken();
const c = activeClinicaId();
const c = clinicaForPath(path);
return {
...(t ? { Authorization: `Bearer ${t}` } : {}),
...(c ? { 'x-nw-clinica': c } : {}),
@@ -28,7 +53,7 @@ async function req(method: string, path: string, body?: any): Promise<any> {
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
const res = await fetch(`${API}/nw/v1${path}`, {
method,
headers: authHeaders(isForm ? undefined : (body != null ? { 'Content-Type': 'application/json' } : undefined)),
headers: authHeaders(path, isForm ? undefined : (body != null ? { 'Content-Type': 'application/json' } : undefined)),
body: body == null ? undefined : (isForm ? body : JSON.stringify(body)),
});
const text = await res.text();
@@ -21,6 +21,16 @@ class SocketShim {
private listeners = new Map<string, Set<Listener>>();
private retry: ReturnType<typeof setTimeout> | null = null;
private closedByUser = false;
private currentClinica: string | null = null;
// Clínica da conta ativa da inbox (nw:inbox-account). null = conta própria.
private readClinica(): string | null {
try {
const raw = localStorage.getItem('nw:inbox-account');
if (!raw) return null;
return JSON.parse(raw)?.clinicaId ?? null;
} catch { return null; }
}
connect(_token?: string) {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
@@ -33,6 +43,11 @@ class SocketShim {
try { u = new URL(`${abs}/nw/v1/stream`); } catch { return; }
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
u.searchParams.set('token', token);
// Stream por-tenant: p/ a caixa da clínica, o backend usa ?clinica= para reescrever
// a conta-alvo (dono) quando o usuário tem can_inbox. null = conta própria.
const clinica = this.readClinica();
this.currentClinica = clinica;
if (clinica) u.searchParams.set('clinica', clinica);
let ws: WebSocket;
try { ws = new WebSocket(u.toString()); } catch { return; }
@@ -73,6 +88,20 @@ class SocketShim {
this._emit('message:update', m);
break;
}
case 'message.reaction':
// Reação recebida numa mensagem: { messageId, emoji, sender }.
this._emit('message:reaction', { messageId: data?.messageId, emoji: data?.emoji, sender: data?.sender });
break;
case 'message.media_ready':
this._emit('message:media_ready', { messageId: data?.messageId, mediaUrl: data?.mediaUrl });
break;
case 'message.transcription':
this._emit('message:transcription', { messageId: data?.messageId, transcription: data?.transcription });
break;
case 'presence':
// Presença do CONTATO (composing/recording/paused/available) por jid.
this._emit('presence:update', { jid: data?.jid, state: data?.state });
break;
case 'conversation.handoff': this._emit('handoff', data); break;
case 'conversation.escalated': this._emit('escalated', data); break;
default: break;
@@ -88,6 +117,16 @@ class SocketShim {
this.ws = null;
}
// Reabre o stream se a conta ativa (clínica) mudou. O realtime é por-tenant, então
// alternar entre a caixa própria e a da clínica exige reconectar no tenant certo.
syncAccount() {
if (this.readClinica() === this.currentClinica && this.ws) return;
if (this.retry) clearTimeout(this.retry);
try { this.ws?.close(); } catch { /* ignore */ }
this.ws = null;
this.connect();
}
// Satélite não envia comandos pelo stream (é read-only); mantidos p/ compat.
emit(_event: string, ..._args: any[]) { /* no-op */ }
joinInstance(_instanceId: string) { /* no-op — stream é tenant-wide */ }
+75 -1
View File
@@ -8,6 +8,11 @@ import { immer } from 'zustand/middleware/immer'
import { socketService } from '../services/socketService'
import { chatApi, messageApi } from '../services/chatApiService'
// Registra os listeners do socket UMA vez por sessão. Sem isto, cada mount do bridge
// (inbox do plugin ou drawer da agenda) reempilha handlers no socketService (que só
// acumula), fazendo message:new rodar N vezes → unreadCount inflado e reprocessamento.
let _chatListenersReady = false
// ─── Mapper: resposta aninhada do backend → Chat plano do store ────────────────
function mapBackendChat(raw: any): Chat {
@@ -42,6 +47,7 @@ function mapBackendChat(raw: any): Chat {
lastMessageSender: raw.lastMessage?.pushName ?? raw.lastMessageSender ?? null,
protocolNumber: raw.protocol?.number ?? raw.protocolNumber ?? null,
botPaused: raw.botPaused ?? false,
labels: Array.isArray(raw.labels) ? raw.labels : [],
}
}
@@ -71,6 +77,7 @@ export interface Chat {
lastMessageSender: string | null // pushName do remetente (grupos: quem enviou a última msg)
protocolNumber: string | null
botPaused: boolean
labels: Array<{ id: string; name: string; color: string }>
}
export interface Message {
@@ -97,6 +104,7 @@ export interface Message {
timestamp: string
pushName?: string | null
senderJid?: string | null
reactions?: Record<string, string> | null // { remetente: emoji } — 'me' = minha reação
}
// ─── Estado ───────────────────────────────────────────────────────────────────
@@ -107,8 +115,14 @@ interface ChatState {
activeChatId: string | null
messages: Record<string, Message[]>
messagesLoading: boolean
// Presença do contato por jid (composing/recording/paused/available) + timestamp.
presenceByJid: Record<string, { state: string; ts: number }>
// Assinatura do operador ("Nome") — usada para exibir "*Nome:*" na msg otimista.
operatorSignature: string | null
// Sync
setPresence: (jid: string, state: string) => void
setOperatorSignature: (sig: string | null) => void
setChats: (chats: Chat[]) => void
setChatsLoading: (v: boolean) => void
upsertChat: (chat: Chat) => void
@@ -120,12 +134,14 @@ interface ChatState {
appendMessage: (msg: Message) => void
patchMessage: (msgId: string, patch: Partial<Message>) => void
updateMediaReady: (msgId: string, mediaUrl: string) => void
reactMessage: (chatId: string, msgId: string, emoji: string, sender?: string) => void
// Async
loadChats: (instanceId: string, opts?: { search?: string; archived?: boolean }) => Promise<void>
loadMessages: (chatId: string, opts?: { before?: string }) => Promise<{ hasMore: boolean }>
selectChat: (chatId: string) => Promise<void>
sendMessage: (instanceId: string, jid: string, text: string, replyToMessageId?: string, mentions?: string[]) => Promise<void>
deleteMessage: (chatId: string, messageId: string) => Promise<void>
// Socket
initSocketListeners: () => void
@@ -195,6 +211,15 @@ export const useChatStore = create<ChatState>()(
activeChatId: null,
messages: {},
messagesLoading: false,
presenceByJid: {},
operatorSignature: null,
// Presença do contato (composing/recording/…): guarda estado + timestamp por jid.
setPresence: (jid, state) =>
set((s) => { if (jid) s.presenceByJid[jid] = { state, ts: Date.now() } }),
setOperatorSignature: (sig) =>
set((s) => { s.operatorSignature = sig }),
// ── Chats ─────────────────────────────────────────────────────────────────
@@ -262,6 +287,17 @@ export const useChatStore = create<ChatState>()(
}
}),
// Reação (otimista + eco do WS): emoji vazio remove a reação do remetente.
reactMessage: (chatId, msgId, emoji, sender = 'me') =>
set((s) => {
const list = s.messages[chatId]
const msg = list?.find((m) => m.id === msgId || m.messageId === msgId)
if (!msg) return
const r = { ...(msg.reactions || {}) }
if (emoji) r[sender] = emoji; else delete r[sender]
msg.reactions = r
}),
// ── Async ─────────────────────────────────────────────────────────────────
loadChats: async (instanceId, opts = {}) => {
@@ -350,13 +386,19 @@ export const useChatStore = create<ChatState>()(
? get().messages[chatId]?.find((m) => m.messageId === replyToMessageId) ?? null
: null
// Otimista com a assinatura "*Nome:*\n" JÁ aplicada (mesma que o proxy adiciona no
// servidor) — evita o flicker de a msg aparecer sem "Nome:" e "puxar" depois do eco.
// IMPORTANTE: envia o texto CRU (o proxy adiciona a assinatura; não duplicar aqui).
const sig = get().operatorSignature
const optimisticBody = sig ? `*${sig}:*\n${text}` : text
const optimistic: Message = {
id: `opt-${Date.now()}`,
chatId,
messageId: `opt-${Date.now()}`,
fromMe: true,
type: 'TEXT',
body: text,
body: optimisticBody,
mediaUrl: null,
mimeType: null,
fileName: null,
@@ -387,9 +429,21 @@ export const useChatStore = create<ChatState>()(
}
},
// Apaga a mensagem para todos (revoke no WhatsApp). Só remove do estado se o
// backend confirmar — o erro sobe para a UI (ex.: não é sua / instância off).
deleteMessage: async (chatId, messageId) => {
await chatApi.deleteMessage(chatId, messageId)
set((s) => {
if (s.messages[chatId]) s.messages[chatId] = s.messages[chatId].filter((m) => m.id !== messageId)
})
},
// ── Socket ────────────────────────────────────────────────────────────────
initSocketListeners: () => {
// Idempotente: só registra uma vez (evita listeners duplicados entre mounts).
if (_chatListenersReady) return
_chatListenersReady = true
// IMPORTANTE: sempre usar get() dentro dos callbacks para ler estado atual.
// Capturar `const store = get()` fora criaria uma closure stale.
@@ -423,6 +477,26 @@ export const useChatStore = create<ChatState>()(
get().patchMessage(messageId, { status })
})
socketService.on('presence:update', ({ jid, state }: { jid?: string; state?: string }) => {
if (jid && state) get().setPresence(jid, state)
})
// Reação recebida: acha a mensagem pelo messageId (WA) em qualquer chat carregado.
socketService.on('message:reaction', ({ messageId, emoji, sender }: { messageId?: string; emoji?: string; sender?: string }) => {
if (!messageId) return
set((s) => {
for (const list of Object.values(s.messages)) {
const msg = list.find((m) => m.messageId === messageId)
if (msg) {
const r = { ...(msg.reactions || {}) }
if (emoji) r[sender || 'contato'] = emoji; else delete r[sender || 'contato']
msg.reactions = r
break
}
}
})
})
socketService.on('chat:upsert', (raw: any) => {
get().upsertChat(mapBackendChat(raw))
})
+16
View File
@@ -0,0 +1,16 @@
// Papéis de número (sec_numbers) — rótulo em PT + cor. Usado pelo seletor de número
// da inbox e pelo ícone (i) de legenda. Espelha o ROLE_META da Secretária, porém
// leve (sem ícones lucide), para badges e pontos coloridos.
export const ROLE_LABELS: Record<string, { label: string; color: string }> = {
secretary_virtual: { label: 'Secretária Virtual', color: '#8b5cf6' },
clinic: { label: 'Clínica / Recepção', color: '#3b82f6' },
doctor: { label: 'Dentista / Dr.', color: '#10b981' },
specialist: { label: 'Especialista', color: '#a855f7' },
manager: { label: 'Gerente', color: '#f97316' },
reserve: { label: 'Reserva / Sala', color: '#eab308' },
human_secretary: { label: 'Secretária', color: '#f43f5e' },
}
export function roleMeta(role?: string | null): { label: string; color: string } {
return (role && ROLE_LABELS[role]) || { label: 'Sem papel definido', color: '#9ca3af' }
}