diff --git a/frontend/components/AgendaPresence.tsx b/frontend/components/AgendaPresence.tsx
index 62cf569..143277c 100644
--- a/frontend/components/AgendaPresence.tsx
+++ b/frontend/components/AgendaPresence.tsx
@@ -65,42 +65,41 @@ export const AgendaPresence: React.FC = () => {
+ {/* 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 && (
- <>
-
setOpen(false)} />
-
-
-
- Online agora ({online.length})
-
-
-
- {online.length === 0 && (
-
Só você por aqui.
- )}
- {online.map(o => (
-
-
- {initials(o.nome)}
-
-
- {o.nome}
-
- ))}
- {ultimasOffline.length > 0 && (
-
-
Últimas visitas
- {ultimasOffline.slice(0, 6).map(u => (
-
- {u.usuario_nome}
- {relTime(u.last_view_at)}
-
- ))}
-
- )}
-
+
+
+
+ Online agora ({online.length})
+
- >
+
+ {online.length === 0 && (
+
Só você por aqui.
+ )}
+ {online.map(o => (
+
+
+ {initials(o.nome)}
+
+
+ {o.nome}
+
+ ))}
+ {ultimasOffline.length > 0 && (
+
+
Últimas visitas
+ {ultimasOffline.slice(0, 6).map(u => (
+
+ {u.usuario_nome}
+ {relTime(u.last_view_at)}
+
+ ))}
+
+ )}
+
+
)}
);
diff --git a/frontend/components/ComunicacaoInternaModal.tsx b/frontend/components/ComunicacaoInternaModal.tsx
index 04eacb4..d00f589 100644
--- a/frontend/components/ComunicacaoInternaModal.tsx
+++ b/frontend/components/ComunicacaoInternaModal.tsx
@@ -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
)}
+ {/* Escape do gate forçado: sair para OUTRA clínica sem preencher esta. */}
+ {forced && outrasClinicas.length > 0 && (
+
+
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
+
+ {trocarOpen && (
+ <>
+
setTrocarOpen(false)} />
+
+
Ir para outra clínica
+ {outrasClinicas.map((w: any) => (
+
HybridBackend.switchWorkspace(w.id)}
+ className="w-full flex items-center gap-2.5 px-4 py-2 hover:bg-gray-50 text-left">
+
+ {w.nome}
+
+ ))}
+
+ >
+ )}
+
+ )}
{saving ? : completo ? : }
diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx
index 104c423..7426412 100644
--- a/frontend/components/Sidebar.tsx
+++ b/frontend/components/Sidebar.tsx
@@ -35,8 +35,9 @@ export const Sidebar: React.FC = ({ 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 = ({ 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
diff --git a/frontend/public/chat-bg.png b/frontend/public/chat-bg.png
new file mode 100644
index 0000000..12be842
Binary files /dev/null and b/frontend/public/chat-bg.png differ
diff --git a/frontend/utils/appTime.ts b/frontend/utils/appTime.ts
index 99b54fe..0ce7ff0 100644
--- a/frontend/utils/appTime.ts
+++ b/frontend/utils/appTime.ts
@@ -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');
+};
diff --git a/frontend/views/GestaoEquipeView.tsx b/frontend/views/GestaoEquipeView.tsx
index f70cf0b..3a7603e 100644
--- a/frontend/views/GestaoEquipeView.tsx
+++ b/frontend/views/GestaoEquipeView.tsx
@@ -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 && (
setEditing(null)}>
+
+
NOME
+
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" />
+
Usado na assinatura das mensagens do WhatsApp. Troque quando a pessoa desta conta mudar (ex.: nova secretária).
+
ACESSO NO SISTEMA
setEditing({ ...editing, role: e.target.value })}
diff --git a/frontend/views/newwhats/SessionsView.tsx b/frontend/views/newwhats/SessionsView.tsx
index 0c74098..b84a37a 100644
--- a/frontend/views/newwhats/SessionsView.tsx
+++ b/frontend/views/newwhats/SessionsView.tsx
@@ -435,11 +435,20 @@ const SessionCard = React.forwardRef Acesso
)}
-
- Desconectar
-
+ {/* Desconectar = gestão da conexão (o backend exige dono ou can_rescan). */}
+ {rescanLocked ? (
+
+ Desconectar
+
+ ) : (
+
+ Desconectar
+
+ )}
>
) : rescanLocked ? (
vo
// ── Ownership & permissões por sessão ──────────────────────────────────────
const [authzByInstance, setAuthzByInstance] = useState
>({});
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(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.
diff --git a/frontend/views/newwhats/WhatsChatDrawer.tsx b/frontend/views/newwhats/WhatsChatDrawer.tsx
index 72ad9a5..009c78b 100644
--- a/frontend/views/newwhats/WhatsChatDrawer.tsx
+++ b/frontend/views/newwhats/WhatsChatDrawer.tsx
@@ -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> = ({ onClose, pacienteId, pacienteNome, onNavigate }) => {
+const DrawerInner: React.FC> = ({ 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> = ({ 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([]);
const [selectedPhone, setSelectedPhone] = useState('');
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> = ({ 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> = ({ 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> = ({ 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 */}
-
+ {/* Barra superior: nome + dropdown do GRUPO FAMILIAR (troca a conversa) + fechar */}
+
-
numeros.length > 1 && setNumDropdownOpen((v) => !v)}
- className={`flex items-center gap-1.5 max-w-full ${numeros.length > 1 ? 'cursor-pointer' : 'cursor-default'}`}
- >
- {tituloContato}
- {selectedNumero && (
-
- {formatPhone(normalizeSendPhone(selectedNumero.phone))}
-
- )}
- {numeros.length > 1 && }
-
+ {/* Nome (truncado) + pill dropdown do grupo familiar — NA MESMA LINHA */}
+
+
{shortContactName(tituloContato)}
+
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"
+ >
+
+ {selectedNumero?.relacao || 'Paciente'}
+
+
+
- {numDropdownOpen && numeros.length > 1 && (
-
- Número / grupo familiar
- {numeros.map((n) => (
- { 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' : ''}`}
- >
-
-
-
-
-
{n.nome}
-
- {n.relacao} · {formatPhone(normalizeSendPhone(n.phone))}
-
-
-
- ))}
-
+ {numDropdownOpen && (
+ <>
+ setNumDropdownOpen(false)} />
+
+
+ Grupo familiar
+
+ {numeros.map((n) => (
+ { 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' : ''}`}
+ >
+
+
+
+
+
{n.nome}
+
+ {n.relacao} · {formatPhone(normalizeSendPhone(n.phone))}
+
+
+ {n.phone === selectedPhone && ativo }
+
+ ))}
+ {numeros.length <= 1 && (
+
+ Cadastre o grupo familiar no paciente (mãe, filho, cônjuge…) para trocar de conversa aqui e notificar sobre o paciente.
+
+ )}
+
+ >
)}
@@ -378,9 +440,21 @@ const DrawerInner: React.FC> = ({ onClose,
onSyncAvatar={handleSyncAvatar}
isChatMenuOpen={isChatMenuOpen}
isSyncingAvatar={isSyncingAvatar}
- onSearchClick={() => {}}
+ isOwner={isWaOwner}
+ onClearConversation={handleClearConversation}
+ onManageLabels={() => setLabelModalOpen(true)}
+ onSearchClick={() => setIsSearchOpen(true)}
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
/>
+ {isSearchOpen && (
+ setIsSearchOpen(false)}
+ onQueryChange={setSearchQuery}
+ hasOlder={hasMoreHistory}
+ loadOlder={async () => { const r = await handleLoadMoreHistory(); return !!r?.hasMore; }}
+ />
+ )}
> = ({ 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> = ({ 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> = ({ onClose,
)}
+
setLabelModalOpen(false)}
+ chat={selectedChat as any}
+ onChanged={() => { if (activeInstance?.instance) useChatStore.getState().loadChats(activeInstance.instance); }}
+ />
+
+ 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);
+ }}
+ />
>
);
};
diff --git a/frontend/views/newwhats/components/ConversationSearch.tsx b/frontend/views/newwhats/components/ConversationSearch.tsx
new file mode 100644
index 0000000..6ac9e21
--- /dev/null
+++ b/frontend/views/newwhats/components/ConversationSearch.tsx
@@ -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-").
+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; // 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 (
+
+
+ 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 && (
+
+ antigas…
+
+ )}
+ {counter && {counter} }
+ go(-1)} disabled={!matches.length} title="Anterior"
+ className="p-1 rounded-full hover:bg-black/10 disabled:opacity-40 text-[#54656f] transition-colors">
+ go(1)} disabled={!matches.length} title="Próximo"
+ className="p-1 rounded-full hover:bg-black/10 disabled:opacity-40 text-[#54656f] transition-colors">
+
+
+ );
+};
+
+export default ConversationSearch;
diff --git a/frontend/views/newwhats/components/ForwardModal.tsx b/frontend/views/newwhats/components/ForwardModal.tsx
new file mode 100644
index 0000000..945b72d
--- /dev/null
+++ b/frontend/views/newwhats/components/ForwardModal.tsx
@@ -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;
+}
+
+// Modal para escolher um ou mais chats de destino e encaminhar a mensagem selecionada.
+const ForwardModal: React.FC = ({ isOpen, onClose, chats, onForward }) => {
+ const [query, setQuery] = useState('');
+ const [selected, setSelected] = useState>(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(
+
+
e.stopPropagation()}>
+
+
Encaminhar para…
+
+
+
+
+
+
+ setQuery(e.target.value)}
+ placeholder="Pesquisar conversa"
+ className="w-full bg-transparent text-[14px] text-[#111b21] placeholder:text-[#667781] focus:outline-none"
+ />
+
+
+
+
+ {filtered.length === 0 && (
+
Nenhuma conversa encontrada.
+ )}
+ {filtered.map((c) => {
+ const id = String(c.id);
+ const isSel = selected.has(id);
+ return (
+
toggle(id)}
+ className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-[#f5f6f6] transition-colors text-left"
+ >
+
+ {isSel && }
+
+ {label(c)}
+
+ );
+ })}
+
+
+
+ {selected.size} selecionada(s)
+
+
+ {sending ? 'Encaminhando…' : 'Encaminhar'}
+
+
+
+
,
+ document.body,
+ );
+};
+
+export default ForwardModal;
diff --git a/frontend/views/newwhats/components/InputBar.tsx b/frontend/views/newwhats/components/InputBar.tsx
index fdec05a..dabf070 100644
--- a/frontend/views/newwhats/components/InputBar.tsx
+++ b/frontend/views/newwhats/components/InputBar.tsx
@@ -43,6 +43,8 @@ interface InputBarProps {
onSendAudio: (blob: Blob) => Promise;
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 = ({
onSendAudio,
replyingTo,
onCancelReply,
+ editingMessage,
+ onCancelEdit,
isMobile,
selectedChat,
instanceId,
@@ -205,10 +209,11 @@ const InputBar: React.FC = ({
}, []);
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 = ({
)}
+ {/* Edit banner */}
+
+ {editingMessage && (
+
+
+
Editando mensagem
+
+ {editingMessage.text || 'Mensagem'}
+
+
+
+
+
+
+ )}
+
+
{/* Reply Preview */}
{replyingTo && (
@@ -513,30 +544,36 @@ const InputBar: React.FC = ({
Mensagem Rica
+
+ {/* Emoji */}
+ { 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"
+ >
+
+
+ Emoji
+
+
+
+ {/* Figurinhas */}
+ {instanceId && (
+ { 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"
+ >
+
+
+ Figurinhas
+
+
+ )}
)}
-
- {/* Emoji — mantém no lugar */}
-
{ 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"
- >
-
-
-
- {/* Figurinhas */}
- {instanceId && (
-
{ 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"
- >
-
-
- )}
@@ -589,7 +626,7 @@ const InputBar: React.FC
= ({
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);
diff --git a/frontend/views/newwhats/components/LabelManagerModal.tsx b/frontend/views/newwhats/components/LabelManagerModal.tsx
new file mode 100644
index 0000000..75b54c5
--- /dev/null
+++ b/frontend/views/newwhats/components/LabelManagerModal.tsx
@@ -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 = ({ isOpen, onClose, chat, onChanged }) => {
+ const [labels, setLabels] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [newName, setNewName] = useState('');
+ const [newColor, setNewColor] = useState(PALETTE[0]);
+ const [assigned, setAssigned] = useState>(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(
+
+
e.stopPropagation()}>
+
+
Etiquetas
+
+
+
+
+ {/* Atribuição à conversa */}
+ {chat && (
+
+
Nesta conversa
+ {labels.length === 0 &&
Nenhuma etiqueta criada ainda.
}
+
+ {labels.map((l) => {
+ const on = assigned.has(l.id);
+ return (
+ 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 && }
+ {l.name}
+
+ );
+ })}
+
+
+ )}
+
+ {/* Lista/gestão */}
+
+
Todas as etiquetas
+ {loading &&
Carregando…
}
+
+ {labels.map((l) => (
+
+
+ {l.name}
+ remove(l.id)} className="p-1 text-gray-300 hover:text-red-500 transition-colors">
+
+ ))}
+
+
+
+ {/* Criar */}
+
+
Nova etiqueta
+
+
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" />
+
+ Criar
+
+
+
+ {PALETTE.map((c) => (
+ 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 }} />
+ ))}
+
+
+
+
+ {chat && (
+
+
+ {saving ? 'Salvando…' : 'Salvar nesta conversa'}
+
+
+ )}
+
+
,
+ document.body,
+ );
+};
+
+export default LabelManagerModal;
diff --git a/frontend/views/newwhats/components/LinkPreview.tsx b/frontend/views/newwhats/components/LinkPreview.tsx
index 57b3af7..c23bddd 100644
--- a/frontend/views/newwhats/components/LinkPreview.tsx
+++ b/frontend/views/newwhats/components/LinkPreview.tsx
@@ -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 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 {part.slice(1, -1)} ;
+ if (/^_\S(?:[^_\n]*\S)?_$/.test(part)) return {part.slice(1, -1)} ;
+ if (/^~\S(?:[^~\n]*\S)?~$/.test(part)) return {part.slice(1, -1)} ;
+ return {part} ;
+ });
+};
+
+// Renders text with URLs as 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[] => {
);
}
- return {part} ;
+ return {renderInline(part, String(i))} ;
});
};
diff --git a/frontend/views/newwhats/components/MessageBubble.tsx b/frontend/views/newwhats/components/MessageBubble.tsx
index 7fd3351..73e2c2d 100644
--- a/frontend/views/newwhats/components/MessageBubble.tsx
+++ b/frontend/views/newwhats/components/MessageBubble.tsx
@@ -104,7 +104,7 @@ const AudioBubble: React.FC = ({ msg, fullMediaUrl, isOut, Tim
onEnded={() => { setPlaying(false); setCurrentTime(0) }}
preload="metadata"
/>
-
+
{/* Play/pause */}
= ({
// o trecho casado vira . 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 = ({
{isRedownloading
?
:
}
-
+
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Áudio não disponível' : 'Toque para baixar áudio'}
@@ -898,7 +900,7 @@ const MessageBubble: React.FC
= ({
{/* Authentic WhatsApp Message Menu Button */}
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'}`}
>
diff --git a/frontend/views/newwhats/components/ProtocolPanel.tsx b/frontend/views/newwhats/components/ProtocolPanel.tsx
index 0138fcf..b373ed1 100644
--- a/frontend/views/newwhats/components/ProtocolPanel.tsx
+++ b/frontend/views/newwhats/components/ProtocolPanel.tsx
@@ -121,6 +121,8 @@ function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: Protoc
{showActions && (
+ <>
+ setShowActions(false)} />
+ >
)}
diff --git a/frontend/views/newwhats/components/SettingsPanel.tsx b/frontend/views/newwhats/components/SettingsPanel.tsx
index aaae429..3108932 100644
--- a/frontend/views/newwhats/components/SettingsPanel.tsx
+++ b/frontend/views/newwhats/components/SettingsPanel.tsx
@@ -42,7 +42,9 @@ export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
const [error, setError] = useState(null);
const [version, setVersion] = useState(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 => {
const t = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
return t ? { Authorization: `Bearer ${t}` } : {};
diff --git a/frontend/views/newwhats/components/StickerPanel.tsx b/frontend/views/newwhats/components/StickerPanel.tsx
index f97f1eb..50a2cc1 100644
--- a/frontend/views/newwhats/components/StickerPanel.tsx
+++ b/frontend/views/newwhats/components/StickerPanel.tsx
@@ -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
diff --git a/frontend/views/newwhats/hooks/inbox/useChatWindow.ts b/frontend/views/newwhats/hooks/inbox/useChatWindow.ts
index 7e07389..c87016f 100644
--- a/frontend/views/newwhats/hooks/inbox/useChatWindow.ts
+++ b/frontend/views/newwhats/hooks/inbox/useChatWindow.ts
@@ -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(null)
+ const [forwardingMessage, setForwardingMessage] = useState(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 | 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,
}
}
diff --git a/frontend/views/newwhats/hooks/inbox/useInstanceSelector.ts b/frontend/views/newwhats/hooks/inbox/useInstanceSelector.ts
index b9dcff9..cda0eac 100644
--- a/frontend/views/newwhats/hooks/inbox/useInstanceSelector.ts
+++ b/frontend/views/newwhats/hooks/inbox/useInstanceSelector.ts
@@ -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(() =>
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 ────────────────────────────────────────────────────────────────
diff --git a/frontend/views/newwhats/hooks/inbox/useMessageInput.ts b/frontend/views/newwhats/hooks/inbox/useMessageInput.ts
index 89e7135..687ecf7 100644
--- a/frontend/views/newwhats/hooks/inbox/useMessageInput.ts
+++ b/frontend/views/newwhats/hooks/inbox/useMessageInput.ts
@@ -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
+ onClearEdit?: () => void
onClearReply?: () => void
onSendText: (text: string, mentions?: string[], replyToMessageId?: string) => Promise
}
-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([])
@@ -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,
diff --git a/frontend/views/newwhats/services/nwClient.ts b/frontend/views/newwhats/services/nwClient.ts
index 35e1e79..30d96da 100644
--- a/frontend/views/newwhats/services/nwClient.ts
+++ b/frontend/views/newwhats/services/nwClient.ts
@@ -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): Record {
+// 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): Record {
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 {
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();
diff --git a/frontend/views/newwhats/services/socketService.ts b/frontend/views/newwhats/services/socketService.ts
index 6dd1c45..7466461 100644
--- a/frontend/views/newwhats/services/socketService.ts
+++ b/frontend/views/newwhats/services/socketService.ts
@@ -21,6 +21,16 @@ class SocketShim {
private listeners = new Map>();
private retry: ReturnType | 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 */ }
diff --git a/frontend/views/newwhats/store/chatStore.ts b/frontend/views/newwhats/store/chatStore.ts
index 4537dd3..e167f6b 100644
--- a/frontend/views/newwhats/store/chatStore.ts
+++ b/frontend/views/newwhats/store/chatStore.ts
@@ -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 | null // { remetente: emoji } — 'me' = minha reação
}
// ─── Estado ───────────────────────────────────────────────────────────────────
@@ -107,8 +115,14 @@ interface ChatState {
activeChatId: string | null
messages: Record
messagesLoading: boolean
+ // Presença do contato por jid (composing/recording/paused/available) + timestamp.
+ presenceByJid: Record
+ // 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) => 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
loadMessages: (chatId: string, opts?: { before?: string }) => Promise<{ hasMore: boolean }>
selectChat: (chatId: string) => Promise
sendMessage: (instanceId: string, jid: string, text: string, replyToMessageId?: string, mentions?: string[]) => Promise
+ deleteMessage: (chatId: string, messageId: string) => Promise
// Socket
initSocketListeners: () => void
@@ -195,6 +211,15 @@ export const useChatStore = create()(
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()(
}
}),
+ // 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()(
? 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()(
}
},
+ // 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()(
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))
})
diff --git a/frontend/views/newwhats/utils/roleMeta.ts b/frontend/views/newwhats/utils/roleMeta.ts
new file mode 100644
index 0000000..cc43c2c
--- /dev/null
+++ b/frontend/views/newwhats/utils/roleMeta.ts
@@ -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 = {
+ 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' }
+}