Files
scoreodonto.com/frontend/views/newwhats/WhatsChatDrawer.tsx
T
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

450 lines
21 KiB
TypeScript

'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 já 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);
// O GET /inbox?search=<dígitos> casa por `jid contains` no backend.
await store.loadChats(activeInstanceId, { search: send });
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;