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 && ( +
+ + {trocarOpen && ( + <> +
setTrocarOpen(false)} /> +
+

Ir para outra clínica

+ {outrasClinicas.map((w: any) => ( + + ))} +
+ + )} +
+ )} + + +
+ ); +}; + +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 ( + + ); + })} +
+ +
+ {selected.size} selecionada(s) + +
+
+
, + 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 */} + + + {/* Figurinhas */} + {instanceId && ( + + )} )}
- - {/* Emoji — mantém no lugar */} - - - {/* Figurinhas */} - {instanceId && ( - - )}
@@ -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 ( + + ); + })} +
+
+ )} + + {/* Lista/gestão */} +
+
Todas as etiquetas
+ {loading &&
Carregando…
} +
+ {labels.map((l) => ( +
+ + {l.name} + +
+ ))} +
+
+ + {/* 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" /> + +
+
+ {PALETTE.map((c) => ( +
+
+
+ + {chat && ( +
+ +
+ )} +
+
, + 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 */} 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' } +}