c2a1f8f701
- WhatsChatDrawer: busca o chat pelos últimos 8 dígitos (o WhatsApp grava o jid BR sem o 9º dígito, ex. 556799591687), evitando falso "sem conversa" - AgendaView: slot usa pacienteNome || pacientenome (backend retorna minúsculo), corrigindo o card que não exibia o nome do paciente Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
453 lines
22 KiB
TypeScript
453 lines
22 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);
|
|
// Busca pelos ÚLTIMOS 8 dígitos (estáveis): o WhatsApp grava o jid do BR sem o
|
|
// 9º dígito (ex.: 556799591687), então buscar o número completo (13 dígitos)
|
|
// não casaria no `jid contains` do backend. O samePhone (últimos 8) filtra.
|
|
const tail8 = send.replace(/\D/g, '').slice(-8);
|
|
await store.loadChats(activeInstanceId, { search: tail8 });
|
|
const chats = useChatStore.getState().chats;
|
|
const hit = chats.find((c) =>
|
|
samePhone(c.contactPhone || c.jid.split('@')[0], send)) ?? null;
|
|
if (hit) {
|
|
await store.selectChat(hit.id);
|
|
onChatWindowSelect();
|
|
setNotFound(false);
|
|
} else {
|
|
setNotFound(true);
|
|
}
|
|
} catch {
|
|
setNotFound(true);
|
|
} finally {
|
|
setResolving(false);
|
|
}
|
|
}, [activeInstanceId, onChatWindowSelect]);
|
|
|
|
// (Re)resolve quando muda o número selecionado ou a instância fica pronta.
|
|
useEffect(() => {
|
|
if (selectedPhone && activeInstanceId) resolveChat(selectedPhone);
|
|
}, [selectedPhone, activeInstanceId, resolveChat]);
|
|
|
|
// Inicia conversa (paciente sem chat): mesmo fluxo do NewConversationModal.
|
|
const handleIniciarConversa = useCallback(async () => {
|
|
if (!activeInstanceId || !selectedPhone) return;
|
|
if (!initText.trim()) { setInitError('Escreva uma mensagem para iniciar a conversa.'); return; }
|
|
setInitSending(true);
|
|
setInitError('');
|
|
try {
|
|
const phone = normalizeSendPhone(selectedPhone);
|
|
await nw.post('/conversations', { sessionId: activeInstanceId, phone, text: initText.trim() });
|
|
setInitText('');
|
|
// Dá um respiro para o motor registrar o chat e então re-resolve.
|
|
await new Promise((r) => setTimeout(r, 900));
|
|
await resolveChat(selectedPhone);
|
|
} catch (e: any) {
|
|
setInitError(e?.message || 'Erro ao enviar. Verifique o número e tente novamente.');
|
|
} finally {
|
|
setInitSending(false);
|
|
}
|
|
}, [activeInstanceId, selectedPhone, initText, resolveChat]);
|
|
|
|
const selectedNumero = useMemo(
|
|
() => numeros.find((n) => n.phone === selectedPhone) ?? null,
|
|
[numeros, selectedPhone]
|
|
);
|
|
|
|
const tituloContato = selectedNumero?.nome || pacienteNome || 'Paciente';
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
<motion.div
|
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
onClick={onClose}
|
|
className="fixed inset-0 z-[190] bg-black/40 backdrop-blur-[2px]"
|
|
/>
|
|
{/* Painel */}
|
|
<motion.aside
|
|
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
|
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
|
className="fixed inset-y-0 right-0 z-[191] w-full sm:w-[440px] bg-[#f0f2f5] shadow-2xl flex flex-col overflow-hidden"
|
|
>
|
|
{/* Barra superior do drawer: contato + dropdown de números + fechar */}
|
|
<div className="shrink-0 bg-[#008069] text-white px-3 py-2.5 flex items-center gap-2">
|
|
<MessageCircle className="w-5 h-5 shrink-0" />
|
|
<div className="min-w-0 flex-1 relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => numeros.length > 1 && setNumDropdownOpen((v) => !v)}
|
|
className={`flex items-center gap-1.5 max-w-full ${numeros.length > 1 ? 'cursor-pointer' : 'cursor-default'}`}
|
|
>
|
|
<span className="font-bold text-sm truncate">{tituloContato}</span>
|
|
{selectedNumero && (
|
|
<span className="text-[11px] text-white/70 truncate">
|
|
{formatPhone(normalizeSendPhone(selectedNumero.phone))}
|
|
</span>
|
|
)}
|
|
{numeros.length > 1 && <ChevronDown className={`w-4 h-4 shrink-0 transition-transform ${numDropdownOpen ? 'rotate-180' : ''}`} />}
|
|
</button>
|
|
<AnimatePresence>
|
|
{numDropdownOpen && numeros.length > 1 && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}
|
|
className="absolute left-0 top-full mt-1 w-72 bg-white text-gray-800 rounded-xl shadow-2xl border border-gray-100 py-1.5 z-10"
|
|
>
|
|
<p className="px-3 py-1 text-[9px] font-black uppercase tracking-widest text-gray-400">Número / grupo familiar</p>
|
|
{numeros.map((n) => (
|
|
<button
|
|
key={n.phone}
|
|
onClick={() => { setSelectedPhone(n.phone); setNumDropdownOpen(false); }}
|
|
className={`w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2.5 ${n.phone === selectedPhone ? 'bg-teal-50' : ''}`}
|
|
>
|
|
<div className="w-8 h-8 rounded-full bg-teal-50 flex items-center justify-center shrink-0">
|
|
<User size={15} className="text-teal-600" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-black uppercase truncate">{n.nome}</p>
|
|
<p className="text-[10px] text-gray-400 font-bold">
|
|
<span className="text-teal-500">{n.relacao}</span> · {formatPhone(normalizeSendPhone(n.phone))}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
<button onClick={onClose} className="p-1.5 hover:bg-white/20 rounded-full shrink-0" title="Fechar">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Corpo */}
|
|
<div
|
|
className="flex-1 flex flex-col overflow-hidden relative"
|
|
style={{ backgroundImage: "url('/chat-bg.png')", backgroundRepeat: 'repeat', backgroundColor: '#b5bdb5' }}
|
|
>
|
|
<div className="absolute inset-0 bg-white/75 pointer-events-none z-0" />
|
|
|
|
{(loadingInstances && instances.length === 0) ? (
|
|
// ── Carregando estado das sessões ──────────────────────────────
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
|
|
<Loader2 className="w-8 h-8 animate-spin text-[#008069]" />
|
|
<span className="text-sm">Verificando conexão do WhatsApp…</span>
|
|
</div>
|
|
) : !hasConnected ? (
|
|
// ── Nenhuma sessão de WhatsApp conectada ────────────────────────
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-5 relative z-[1] p-8 text-center">
|
|
<div className="relative">
|
|
<div className="w-20 h-20 rounded-full bg-[#008069]/10 flex items-center justify-center">
|
|
<WifiOff className="w-9 h-9 text-[#008069]" />
|
|
</div>
|
|
<span className="absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-white shadow-md flex items-center justify-center">
|
|
<MessageCircle className="w-4 h-4 text-[#008069]" />
|
|
</span>
|
|
</div>
|
|
<div className="max-w-xs">
|
|
<h3 className="text-lg font-bold text-[#111b21]">
|
|
{instances.length === 0 ? 'WhatsApp não conectado' : 'Sessão desconectada'}
|
|
</h3>
|
|
<p className="text-[13px] text-[#667781] leading-relaxed mt-1.5">
|
|
{instances.length === 0
|
|
? 'Nenhuma conta de WhatsApp está vinculada a esta clínica. Conecte um número para conversar com os pacientes direto da agenda.'
|
|
: 'A sessão do WhatsApp está fora do ar no momento. Reconecte o número lendo o QR Code para retomar as conversas.'}
|
|
</p>
|
|
</div>
|
|
{onNavigate && (
|
|
<button
|
|
onClick={() => { onClose(); onNavigate('wa-sessions'); }}
|
|
className="flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold text-white bg-[#008069] hover:bg-[#017259] transition-all active:scale-[0.98] shadow-sm"
|
|
>
|
|
<QrCode className="w-4 h-4" />
|
|
{instances.length === 0 ? 'Conectar WhatsApp' : 'Reconectar sessão'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : resolving ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
|
|
<Loader2 className="w-8 h-8 animate-spin text-[#008069]" />
|
|
<span className="text-sm">Abrindo conversa…</span>
|
|
</div>
|
|
) : !selectedPhone ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] p-8 text-center">
|
|
<Phone className="w-12 h-12 opacity-10" />
|
|
<p className="text-sm text-[#667781]">Este paciente não tem telefone no cadastro.</p>
|
|
</div>
|
|
) : notFound ? (
|
|
// ── Sem conversa: iniciar uma nova ──────────────────────────────
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-4 relative z-[1] p-6">
|
|
<div className="w-14 h-14 rounded-full bg-[#008069]/10 flex items-center justify-center">
|
|
<MessageCircle className="w-7 h-7 text-[#008069]" />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-sm font-bold text-[#111b21]">Sem conversa com {tituloContato}</p>
|
|
<p className="text-[12px] text-[#667781] mt-0.5">{formatPhone(normalizeSendPhone(selectedPhone))}</p>
|
|
</div>
|
|
<div className="w-full max-w-sm space-y-2">
|
|
<textarea
|
|
rows={3}
|
|
value={initText}
|
|
onChange={(e) => { setInitText(e.target.value); setInitError(''); }}
|
|
placeholder="Olá! Tudo bem? Passando para confirmar seu horário…"
|
|
disabled={initSending}
|
|
className="w-full bg-white rounded-xl px-4 py-3 text-sm text-[#111b21] placeholder:text-[#8696a0] focus:outline-none focus:ring-2 focus:ring-[#008069]/40 resize-none disabled:opacity-60 border border-gray-100"
|
|
/>
|
|
{initError && <p className="text-[12px] text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{initError}</p>}
|
|
<button
|
|
onClick={handleIniciarConversa}
|
|
disabled={initSending || !initText.trim()}
|
|
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-sm font-semibold text-white bg-[#008069] hover:bg-[#017259] transition-all active:scale-[0.98] disabled:opacity-50"
|
|
>
|
|
{initSending
|
|
? <><Loader2 className="w-4 h-4 animate-spin" /> Enviando…</>
|
|
: <><Send className="w-4 h-4" /> Iniciar conversa</>}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : selectedChat ? (
|
|
// ── Conversa ativa ──────────────────────────────────────────────
|
|
<div className="flex-1 flex h-full relative z-[1] overflow-hidden">
|
|
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
|
<InboxHeader
|
|
selectedChat={selectedChat}
|
|
isMobile={true}
|
|
isTyping={isTyping}
|
|
onAvatarClick={() => setIsProfileOpen(!isProfileOpen)}
|
|
onBack={onClose}
|
|
onSyncAvatar={handleSyncAvatar}
|
|
isChatMenuOpen={isChatMenuOpen}
|
|
isSyncingAvatar={isSyncingAvatar}
|
|
onSearchClick={() => {}}
|
|
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
|
|
/>
|
|
<MessageArea
|
|
messages={messages as any}
|
|
selectedChat={selectedChat}
|
|
scrollRef={scrollRef}
|
|
msgLoading={loadingMessages}
|
|
onScroll={handleScroll}
|
|
historyLoadingMore={loadingMore}
|
|
hasMoreHistory={hasMoreHistory}
|
|
reactionPickerFor={reactionPickerFor}
|
|
onReply={setReplyingTo}
|
|
onToggleReactionPicker={toggleReactionPicker}
|
|
onReact={handleReactToMessage}
|
|
onForward={() => {}}
|
|
onDelete={() => {}}
|
|
onEdit={() => {}}
|
|
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
|
|
getGroupSenderLabel={(msg: any) => {
|
|
if (msg.direction === 'out') return 'Você';
|
|
if (msg.participant) return msg.participant;
|
|
if (msg.senderJid) {
|
|
if (msg.senderJid.endsWith('@lid')) return msg.participant || '';
|
|
const phone = msg.senderJid.split('@')[0].split(':')[0];
|
|
return formatPhone(phone, msg.senderJid);
|
|
}
|
|
return '';
|
|
}}
|
|
/>
|
|
<InputBar
|
|
newMessage={newMessage}
|
|
onNewMessageChange={setNewMessage}
|
|
onSend={handleSendMessage}
|
|
onPresence={handleTyping}
|
|
onCancelReply={() => setReplyingTo(null)}
|
|
replyingTo={replyingTo}
|
|
selectedChat={selectedChat}
|
|
isMobile={true}
|
|
onSendMedia={handleSendMedia}
|
|
onSendAudio={handleSendAudio}
|
|
instanceId={activeInstance?.instance ?? null}
|
|
mentions={mentions}
|
|
onMentionsChange={setMentions}
|
|
/>
|
|
</div>
|
|
<ContactProfile
|
|
isOpen={isProfileOpen}
|
|
chat={selectedChat}
|
|
messages={messages as any}
|
|
onClose={() => setIsProfileOpen(false)}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
|
|
<Loader2 className="w-6 h-6 animate-spin" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.aside>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// ─── Wrapper: só monta o conteúdo (e os hooks) quando aberto ──────────────────
|
|
export const WhatsChatDrawer: React.FC<WhatsChatDrawerProps> = ({ isOpen, ...rest }) => (
|
|
<AnimatePresence>
|
|
{isOpen && <DrawerInner {...rest} />}
|
|
</AnimatePresence>
|
|
);
|
|
|
|
export default WhatsChatDrawer;
|