From 89588c26740be92aabe2fe3be51807f29f9a295e Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Fri, 10 Jul 2026 06:07:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(wa-inbox):=20nome=20do=20paciente,=20fila,?= =?UTF-8?q?=20power=20por=20chat,=20agenda,=20secret=C3=A1ria?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nome do paciente (base) no lugar do número/pushname; multi-paciente → titular. - Aba Fila (pedidos da Secretária) + selo pendente; botão de pedidos e overlay da agenda no header do chat. - Power da Secretária: por sessão (fail-closed, só dono) e override por CHAT (ChatSecretariaPower); botão de pausa reflete o estado efetivo (AiPauseButton). - secretariaApi/chatApiService: chat-power + handoff com sec_enabled. Co-Authored-By: Claude Opus 4.8 --- frontend/views/newwhats/InboxView.tsx | 141 ++++++++++++++++-- frontend/views/newwhats/SecretariaView.tsx | 19 +++ .../newwhats/components/AiPauseButton.tsx | 125 ++++++++++++++++ .../newwhats/components/ChatListItem.tsx | 16 +- .../components/ChatSecretariaPower.tsx | 84 +++++++++++ .../newwhats/components/ContactProfile.tsx | 4 +- .../views/newwhats/components/InboxHeader.tsx | 76 +++++++++- .../views/newwhats/components/MessageArea.tsx | 8 +- .../components/SessaoSecretariaPower.tsx | 15 +- .../views/newwhats/components/ThinSidebar.tsx | 23 ++- .../views/newwhats/services/chatApiService.ts | 95 +++++++++++- .../views/newwhats/services/secretariaApi.ts | 15 ++ 12 files changed, 586 insertions(+), 35 deletions(-) create mode 100644 frontend/views/newwhats/components/AiPauseButton.tsx create mode 100644 frontend/views/newwhats/components/ChatSecretariaPower.tsx diff --git a/frontend/views/newwhats/InboxView.tsx b/frontend/views/newwhats/InboxView.tsx index 30a2c33..50fc02d 100644 --- a/frontend/views/newwhats/InboxView.tsx +++ b/frontend/views/newwhats/InboxView.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Search, MoreVertical, @@ -12,6 +12,7 @@ import { ArrowLeft, Info, Bot, + X, } from 'lucide-react'; import { roleMeta } from './utils/roleMeta'; import { motion, AnimatePresence } from 'framer-motion'; @@ -41,6 +42,8 @@ import NewConversationModal from './components/NewConversationModal'; import ContactProfile from './components/ContactProfile'; import ProtocolPanel from './components/ProtocolPanel'; import { HybridBackend } from '../../services/backend'; +import { PedidosPendentes } from '../../components/PedidosPendentes.tsx'; +import { AgendaView } from '../AgendaView.tsx'; type Chat = NewWhatsChat; @@ -163,17 +166,90 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void const [isMobile, setIsMobile] = useState(false) const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false) - // Permissão de apagar conversa/mensagem (dono ou liberado). Fail-open enquanto carrega. - const [canDeleteMsg, setCanDeleteMsg] = useState(true) + // Permissão de apagar conversa/mensagem (dono ou liberado). Fail-closed: só libera após confirmar. + const [canDeleteMsg, setCanDeleteMsg] = useState(false) // "Limpar conversa" é só do DONO do workspace. Fail-closed: só aparece se confirmado dono. const [isWaOwner, setIsWaOwner] = useState(false) + // Secretária IA: só o dono ou quem tem can_secretaria acessa a config. Fail-closed. + const [canSecretaria, setCanSecretaria] = 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(() => {}) + HybridBackend.getWaAccess().then(a => { if (alive) { setCanDeleteMsg(a.isOwner || a.delete_msg); setIsWaOwner(!!a.isOwner); setCanSecretaria(a.isOwner || a.secretaria); useChatStore.getState().setOperatorSignature(a.signature ?? null) } }).catch(() => {}) return () => { alive = false } }, []) + // ── Fila da Secretária ──────────────────────────────────────────────────── + // Pedidos que a Secretária IA encaminhou p/ a humana confirmar (zona cinza). + // Carregamos os telefones dos pendentes e cruzamos com a lista de conversas + // (últimos 8 dígitos, tolerante ao 9º dígito/DDI) → flag `pendenteSec` por chat. + // A aba "Fila" filtra a lista por essa flag; a lista normal só ganha o selo. + const [pendingPhones, setPendingPhones] = useState>(new Set()) + const [pedidosCount, setPedidosCount] = useState(0) // total de pedidos pendentes (badge do botão) + const [showPedidos, setShowPedidos] = useState(false) + const [showAgenda, setShowAgenda] = useState(false) // agenda em overlay, sem sair do inbox + const carregarPendentes = useCallback(async () => { + const cid = (HybridBackend.getActiveWorkspace() as any)?.id + if (!cid) return + try { + const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN') + const r = await fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }) + const j = r.ok ? await r.json() : { pedidos: [] } + const keys = new Set() + for (const p of (j.pedidos || [])) { + const k = String(p.paciente_telefone || '').replace(/\D/g, '').slice(-8) + if (k) keys.add(k) + } + setPendingPhones(keys) + setPedidosCount((j.pedidos || []).length) + } catch { /* silencioso: fila é auxiliar, não bloqueia o inbox */ } + }, []) + useEffect(() => { + carregarPendentes() + const t = setInterval(carregarPendentes, 30000) // mantém a fila fresca sem recarregar a página + return () => { clearInterval(t) } + }, [carregarPendentes]) + + // ── Nomes NOSSOS (base de pacientes) por telefone ───────────────────────── + // Mostra o nome que a clínica cadastrou no lugar do número/pushname do WhatsApp. + // Um número pode ter VÁRIOS pacientes (grupo familiar) → `principal` = "1º nome +N". + // Fallback (sem paciente): pushname → telefone. Casa pelos últimos 8 dígitos. + const [nomesNossos, setNomesNossos] = useState>({}) + const phonesKey = useMemo( + () => [...new Set(chatsByScope.map(c => String(c.phone || c.remote_jid || '').replace(/\D/g, '').slice(-8)).filter(k => k.length >= 8))].sort().join(','), + [chatsByScope], + ) + useEffect(() => { + const cid = (HybridBackend.getActiveWorkspace() as any)?.id + const telefones = phonesKey ? phonesKey.split(',') : [] + if (!cid || telefones.length === 0) return + let alive = true + const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN') + fetch(`/api/nw/agenda-config/clinica/${cid}/nomes-por-telefone`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ telefones }), + }).then(r => (r.ok ? r.json() : { mapa: {} })) + .then(j => { if (alive) setNomesNossos(j.mapa || {}) }) + .catch(() => { /* silencioso: sem nossos nomes, cai no pushname */ }) + return () => { alive = false } + }, [phonesKey]) + const nomeKey = useCallback((c: any) => String(c?.phone || c?.remote_jid || '').replace(/\D/g, '').slice(-8), []) + const selectedNomeNosso = selectedChat ? nomesNossos[nomeKey(selectedChat)]?.principal : undefined + + // Enriquecimento: nome NOSSO + flag `pendenteSec` (fila) por conversa. + const chatsEnriched = useMemo(() => { + const temNomes = Object.keys(nomesNossos).length > 0 + if (pendingPhones.size === 0 && !temNomes) return chatsByScope + return chatsByScope.map(c => { + const key = String(c.phone || c.remote_jid || '').replace(/\D/g, '').slice(-8) + const nomeNosso = key ? nomesNossos[key]?.principal : undefined + const pend = !!(key && pendingPhones.has(key)) + return (nomeNosso || pend) ? { ...c, ...(nomeNosso ? { nomeNosso } : {}), ...(pend ? { pendenteSec: true } : {}) } : c + }) + }, [chatsByScope, pendingPhones, nomesNossos]) + const filaChats = useMemo(() => chatsEnriched.filter((c: any) => c.pendenteSec), [chatsEnriched]) + // Preloader: aparece APENAS em hard reload (F5 / URL direta) — nunca em // navegação client-side (router.push). Detectado por: // 1. performance.getEntriesByType('navigation')[0].type === 'reload' @@ -230,6 +306,18 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void handleMenuAction(action, chat, toggleFavorite) }, [handleMenuAction, toggleFavorite]) + // "Abrir chat" a partir do modal de pedidos: seleciona a conversa correspondente + // na própria lista do inbox (casa pelos últimos 8 dígitos) e fecha o modal. + const handleOpenChatFromFila = useCallback((t: { pacienteNome?: string; phone?: string }) => { + const key = String(t.phone || '').replace(/\D/g, '').slice(-8) + const chat = key ? chatsStore.find(c => String(c.phone || c.remote_jid || '').replace(/\D/g, '').slice(-8) === key) : undefined + if (chat) { + handleSelectChat(chat as any) + setShowPedidos(false) + setActiveTab('chats') + } + }, [chatsStore, handleSelectChat]) + const handleFetchChats = useCallback(async () => { await refreshInstances() @@ -290,9 +378,9 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
- + - {activeTab === 'chats' && ( + {(activeTab === 'chats' || activeTab === 'fila') && (
{/* Header da sidebar */}
@@ -358,7 +446,9 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
- {/* Secretária IA — aba dentro do WhatsApp (saiu do menu lateral). */} + {/* Secretária IA — aba dentro do WhatsApp (saiu do menu lateral). + Só o dono ou quem tem can_secretaria acessa a CONFIG (fail-closed). */} + {canSecretaria && ( + )} {/* Ícone (i): legenda dos papéis de cada número acessível */}
@@ -452,9 +543,9 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
- {/* Lista de chats */} + {/* Lista de chats — na aba "Fila", só as conversas com pendência da Secretária */} void isProtocolOpen={isProtocolOpen} hasActiveProtocol={protocolPanel.hasActive} isOwner={isWaOwner} + nomeNosso={selectedNomeNosso} + activeInstanceId={activeInstance?.instance ?? null} + pedidosCount={pedidosCount} + onOpenPedidos={() => setShowPedidos(true)} + onOpenAgenda={() => setShowAgenda(true)} onClearConversation={handleClearConversation} onManageLabels={() => setLabelModalOpen(true)} onSearchClick={() => setIsSearchOpen(true)} @@ -563,6 +659,7 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void chat={selectedChat} messages={messages as any} onClose={() => setIsProfileOpen(false)} + nomeNosso={selectedNomeNosso} /> void setForwardingMessage(null) }} /> + + {/* Fila da Secretária — mesmo modal da /agenda (confirmar/recusar/ajustar horário) */} + setShowPedidos(false)} + clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id} + onOpenChat={handleOpenChatFromFila} + onChange={carregarPendentes} + /> + + {/* Agenda em overlay — reutiliza a AgendaView inteira p/ conferir horários vagos + em outro dia sem sair do inbox. Overlay full-screen: zero mudança estrutural. */} + {showAgenda && ( +
+
+ Agenda + +
+ {/* Mesmo respiro da página /agenda (App.tsx: p-4 md:p-8 + scroll) */} +
+ +
+
+ )}
) } diff --git a/frontend/views/newwhats/SecretariaView.tsx b/frontend/views/newwhats/SecretariaView.tsx index 03d09c0..fa0b828 100644 --- a/frontend/views/newwhats/SecretariaView.tsx +++ b/frontend/views/newwhats/SecretariaView.tsx @@ -1626,6 +1626,22 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } = const [powerData, setPowerData] = useState(null) const [powerErr, setPowerErr] = useState(null) + // Guard de acesso (fail-closed): a CONFIG da Secretária é só do dono ou de quem tem + // can_secretaria. Protege contra navegação por URL direta a /wa-secretaria — sem isto, + // qualquer operador do inbox abriria esta tela. Enquanto verifica, não renderiza nada. + const [accessChecked, setAccessChecked] = useState(false) + const [hasSecretariaAccess, setHasSecretariaAccess] = useState(false) + useEffect(() => { + let alive = true + HybridBackend.getWaAccess().then((a) => { + if (!alive) return + const ok = !!(a.isOwner || a.secretaria) + setHasSecretariaAccess(ok); setAccessChecked(true) + if (!ok) props.onNavigate?.('wa-inbox') + }).catch(() => { if (alive) { setAccessChecked(true); props.onNavigate?.('wa-inbox') } }) + return () => { alive = false } + }, []) + // Boot useEffect(() => { store.loadAgents() @@ -1718,6 +1734,9 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } = setActiveTab('conversations') } + // Fail-closed: enquanto verifica ou se negado, não renderiza a config (evita flash e vazamento). + if (!accessChecked || !hasSecretariaAccess) return null + return (
diff --git a/frontend/views/newwhats/components/AiPauseButton.tsx b/frontend/views/newwhats/components/AiPauseButton.tsx new file mode 100644 index 0000000..cd98341 --- /dev/null +++ b/frontend/views/newwhats/components/AiPauseButton.tsx @@ -0,0 +1,125 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Bot } from 'lucide-react'; +import { handoffApi, HANDOFF_MINUTES } from '../services/chatApiService'; +import { socketService } from '../services/socketService'; + +interface AiPauseButtonProps { + // jid do contato (o handoff é por conversa, chaveado pelo jid). + jid: string | null | undefined; + // instância atual — p/ saber o estado EFETIVO do liga/desliga (chat/sessão/global). + instanceId?: string | null; +} + +const WINDOW_MS = HANDOFF_MINUTES * 60 * 1000; + +// Botão do atendente (canto inferior direito) para PAUSAR/RETOMAR a Secretária IA. +// Ao pausar, um contador simples aparece no centro; passados 15 min o motor reativa a +// IA sozinho (e o evento de handoff volta o botão ao normal). +const AiPauseButton: React.FC = ({ jid, instanceId }) => { + const [mode, setMode] = useState<'ia' | 'humano'>('ia'); + const [humanAt, setHumanAt] = useState(null); + const [remaining, setRemaining] = useState(0); + const [busy, setBusy] = useState(false); + const [secEnabled, setSecEnabled] = useState(null); // liga/desliga efetivo + const tickRef = useRef | null>(null); + + useEffect(() => { + if (!jid) return; + let alive = true; + handoffApi.get(jid, instanceId).then((r) => { + if (!alive) return; + setMode(r.mode); + setHumanAt(r.humanAt ? new Date(r.humanAt).getTime() : null); + setSecEnabled(r.secEnabled); + }).catch(() => {}); + return () => { alive = false; }; + }, [jid, instanceId]); + + // Atualiza ao vivo se o motor mudar o modo (escalonamento / timeout de retorno). + useEffect(() => { + const onHandoff = (d: any) => { + if (d?.mode !== 'ia' && d?.mode !== 'humano') return; + setMode(d.mode); + setHumanAt(d.mode === 'humano' ? (d.handoffHumanAt ? new Date(d.handoffHumanAt).getTime() : Date.now()) : null); + }; + const onEscalated = () => { setMode('humano'); setHumanAt(Date.now()); }; + socketService.on('handoff', onHandoff); + socketService.on('escalated', onEscalated); + return () => { socketService.off?.('handoff', onHandoff); socketService.off?.('escalated', onEscalated); }; + }, []); + + // Contador: roda 1x/s enquanto pausado; ao zerar, otimisticamente volta p/ IA. + useEffect(() => { + if (tickRef.current) { clearInterval(tickRef.current); tickRef.current = null; } + if (mode !== 'humano' || !humanAt) { setRemaining(0); return; } + const compute = () => { + const left = WINDOW_MS - (Date.now() - humanAt); + if (left <= 0) { setRemaining(0); setMode('ia'); setHumanAt(null); } + else setRemaining(left); + }; + compute(); + tickRef.current = setInterval(compute, 1000); + return () => { if (tickRef.current) clearInterval(tickRef.current); }; + }, [mode, humanAt]); + + const toggle = useCallback(async () => { + if (!jid || busy) return; + const next = mode === 'ia' ? 'humano' : 'ia'; + const prevMode = mode, prevAt = humanAt; + setBusy(true); + setMode(next); setHumanAt(next === 'humano' ? Date.now() : null); // otimista + try { await handoffApi.set(jid, next); } + catch { setMode(prevMode); setHumanAt(prevAt); } + finally { setBusy(false); } + }, [jid, mode, humanAt, busy]); + + if (!jid) return null; + const paused = mode === 'humano'; + const mmss = () => { + const t = Math.max(0, Math.ceil(remaining / 1000)); + return `${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`; + }; + + // Secretária DESLIGADA p/ este chat (sessão/global/override): não faz sentido + // mostrar "IA ativa" (laranja) nem oferecer pausa — ela já não responde. Botão + // fica apagado/cinza e informa. Só o dono religa (cabeçalho do chat / power). + if (secEnabled === false) { + return ( + + ); + } + + return ( + <> + {/* Contador central (só quando pausado) */} + {paused && remaining > 0 && ( +
+ + IA pausada · {mmss()} +
+ )} + + {/* Botão robô: laranja = IA ativa; cinza = pausada */} + + + ); +}; + +export default AiPauseButton; diff --git a/frontend/views/newwhats/components/ChatListItem.tsx b/frontend/views/newwhats/components/ChatListItem.tsx index 23241e6..86464a8 100644 --- a/frontend/views/newwhats/components/ChatListItem.tsx +++ b/frontend/views/newwhats/components/ChatListItem.tsx @@ -193,13 +193,27 @@ export default function ChatListItem({

- {chat.displayName || chat.subject || chat.phone} + {(chat as any).nomeNosso || chat.displayName || chat.subject || chat.phone}

0 ? 'text-[#00a884] font-medium' : 'text-[#667781]'}`}> {relativeTime}
+ {((chat as any).pendenteSec || (Array.isArray((chat as any).labels) && (chat as any).labels.length > 0)) && ( +
+ {/* Selo da fila da Secretária: há pedido aguardando a humana confirmar */} + {(chat as any).pendenteSec && ( + + Pendente + + )} + {Array.isArray((chat as any).labels) && (chat as any).labels.slice(0, 3).map((l: any) => ( + {l.name} + ))} +
+ )} +
{(() => { diff --git a/frontend/views/newwhats/components/ChatSecretariaPower.tsx b/frontend/views/newwhats/components/ChatSecretariaPower.tsx new file mode 100644 index 0000000..89fc807 --- /dev/null +++ b/frontend/views/newwhats/components/ChatSecretariaPower.tsx @@ -0,0 +1,84 @@ +// Liga/desliga a Secretária IA SÓ NESTE CHAT (override do dono, precedência máxima: +// vence a sessão e o global). Botão no header do chat + popover com 3 estados. +// Só o dono vê/opera (canPower). Persiste até mudar; "Herdar" remove o override. +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Bot, Check, Loader2, Power, PowerOff, CornerUpLeft } from 'lucide-react'; +import { secChatPowerApi, ChatPower } from '../services/secretariaApi'; + +const byName = (() => { + try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}')?.nome || 'Dono'; } + catch { return 'Dono'; } +})(); + +export const ChatSecretariaPower: React.FC<{ instanceId: string | null; jid: string | null; canPower?: boolean }> = ({ instanceId, jid, canPower }) => { + const [cp, setCp] = useState(null); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const ref = useRef(null); + + const carregar = useCallback(async () => { + if (!instanceId || !jid) return; + try { setCp(await secChatPowerApi.get(instanceId, jid)); } catch { /* silencioso */ } + }, [instanceId, jid]); + + useEffect(() => { setCp(null); carregar(); }, [carregar]); + + // Fecha o popover ao clicar fora. + useEffect(() => { + if (!open) return; + const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener('mousedown', h); + return () => document.removeEventListener('mousedown', h); + }, [open]); + + if (!instanceId || !jid || !canPower) return null; + + const overridden = !!cp?.overridden; + const enabled = cp?.enabled; + // Cor do ícone: verde = ligada só aqui; vermelho = desligada só aqui; neutro = herda. + const tone = !overridden ? 'text-[#54656f]' : enabled ? 'text-emerald-600' : 'text-red-600'; + + const aplicar = async (val: boolean | null) => { + if (!instanceId || !jid) return; + setBusy(true); + try { await secChatPowerApi.set(instanceId, jid, val, byName); await carregar(); setOpen(false); } + catch { /* mantém aberto */ } finally { setBusy(false); } + }; + + const Opt = ({ icon, label, active, danger, onClick }: { icon: React.ReactNode; label: string; active: boolean; danger?: boolean; onClick: () => void }) => ( + + ); + + return ( +
+ + + {open && ( +
+

Secretária neste chat

+ {busy &&
aplicando…
} + } label="Ligar só neste chat" active={overridden && enabled === true} onClick={() => aplicar(true)} /> + } label="Desligar só neste chat" active={overridden && enabled === false} danger onClick={() => aplicar(false)} /> + {overridden && ( + <> +
+ } label="Herdar padrão (global/sessão)" active={false} onClick={() => aplicar(null)} /> + + )} +
+ )} +
+ ); +}; diff --git a/frontend/views/newwhats/components/ContactProfile.tsx b/frontend/views/newwhats/components/ContactProfile.tsx index c6db653..4535b16 100644 --- a/frontend/views/newwhats/components/ContactProfile.tsx +++ b/frontend/views/newwhats/components/ContactProfile.tsx @@ -28,6 +28,7 @@ interface ContactProfileProps { chat: any; messages: Message[]; onClose: () => void; + nomeNosso?: string; // nome da NOSSA base (paciente) — precede o pushname do WhatsApp } const ContactProfile: React.FC = ({ @@ -35,6 +36,7 @@ const ContactProfile: React.FC = ({ chat, messages, onClose, + nomeNosso, }) => { // Filter media messages const mediaMessages = useMemo(() => { @@ -75,7 +77,7 @@ const ContactProfile: React.FC = ({ {/* Profile Photo */}

- {chat.lead_name || chat.subject || formatPhone(chat.phone, chat.remote_jid)} + {nomeNosso || chat.lead_name || chat.subject || formatPhone(chat.phone, chat.remote_jid)}

{formatPhone(chat.phone, chat.remote_jid)} diff --git a/frontend/views/newwhats/components/InboxHeader.tsx b/frontend/views/newwhats/components/InboxHeader.tsx index 5e8a1ed..b757ee8 100644 --- a/frontend/views/newwhats/components/InboxHeader.tsx +++ b/frontend/views/newwhats/components/InboxHeader.tsx @@ -9,12 +9,15 @@ import { Tag, X, ClipboardList, + CalendarClock, + CalendarDays, } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { formatPhone, } from '../utils/inboxUtils'; import Avatar from './ui/Avatar'; +import { ChatSecretariaPower } from './ChatSecretariaPower'; interface InboxHeaderProps { selectedChat: any; @@ -24,6 +27,14 @@ interface InboxHeaderProps { isSyncingAvatar: boolean; isProtocolOpen?: boolean; hasActiveProtocol?: boolean; + isOwner?: boolean; // só o dono do workspace vê "Limpar conversa" (ação destrutiva). Fail-closed. + nomeNosso?: string; // nome da NOSSA base (paciente) — precede o pushname do WhatsApp + activeInstanceId?: string | null; // instância atual (p/ override da Secretária por chat) + pedidosCount?: number; // badge do botão de fila (0 = sem badge) + onOpenPedidos?: () => void; // abre o modal "Pedidos aguardando confirmação" + onOpenAgenda?: () => void; // abre a agenda (overlay) sem sair do inbox + onClearConversation?: () => void; + onManageLabels?: () => void; onBack: () => void; onSearchClick: () => void; onToggleMenu: () => void; @@ -40,6 +51,14 @@ const InboxHeader: React.FC = ({ isSyncingAvatar, isProtocolOpen, hasActiveProtocol, + isOwner, + nomeNosso, + activeInstanceId, + pedidosCount = 0, + onOpenPedidos, + onOpenAgenda, + onClearConversation, + onManageLabels, onBack, onSearchClick, onToggleMenu, @@ -69,7 +88,7 @@ const InboxHeader: React.FC = ({

- {selectedChat.lead_name || selectedChat.subject || formatPhone(selectedChat.phone, selectedChat.remote_jid)} + {nomeNosso || selectedChat.lead_name || selectedChat.subject || formatPhone(selectedChat.phone, selectedChat.remote_jid)}

{isTyping ? ( @@ -91,6 +110,33 @@ const InboxHeader: React.FC = ({
+ {/* Secretária IA só neste chat (override do dono, precedência máxima) */} + + {/* Agenda — abre a agenda (overlay) p/ conferir dias/horários vagos sem sair do chat */} + {onOpenAgenda && ( + + )} + {/* Fila da Secretária — modal "Pedidos aguardando confirmação" (mesmo da agenda) */} + {onOpenPedidos && ( + + )} {onProtocolClick && ( - -
- + {/* Ação destrutiva — só o dono do workspace (dentista/protético/ + biomédico dono da clínica/consultório). Secretária/agente não vê. */} + {isOwner && ( + <> +
+ + + )} + )}
diff --git a/frontend/views/newwhats/components/MessageArea.tsx b/frontend/views/newwhats/components/MessageArea.tsx index b21b042..d9bc163 100644 --- a/frontend/views/newwhats/components/MessageArea.tsx +++ b/frontend/views/newwhats/components/MessageArea.tsx @@ -11,6 +11,7 @@ import { normalizeJid, parseSafeDate } from '../utils/inboxUtils'; import { Message } from '../types/inboxTypes'; import { mediaApi } from '../services/chatApiService'; import { useChatStore } from '../store/chatStore'; +import AiPauseButton from './AiPauseButton'; // Message interface removed and moved to types.ts @@ -33,6 +34,7 @@ interface MessageAreaProps { quickReactions: string[]; getGroupSenderLabel: (msg: Message) => string; canDelete?: boolean; + highlightTerm?: string; } const MessageArea: React.FC = ({ @@ -54,6 +56,7 @@ const MessageArea: React.FC = ({ quickReactions, getGroupSenderLabel, canDelete = true, + highlightTerm, }) => { const [showScrollBottom, setShowScrollBottom] = useState(false); const lastScrollTop = useRef(0); @@ -144,6 +147,8 @@ const MessageArea: React.FC = ({ return ( <>
+ {/* Botão do atendente: pausar/retomar a Secretária IA nesta conversa. */} +
= ({ selectedChat={selectedChat} onOpenMedia={(m) => setViewerMsgId(m.message_id)} onRedownloadMedia={handleRedownloadMedia} + highlightTerm={highlightTerm} /> ); @@ -237,7 +243,7 @@ const MessageArea: React.FC = ({ animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.8, y: 10 }} onClick={() => scrollToBottom()} - className="absolute bottom-6 right-6 w-11 h-11 bg-white text-[#54656f] rounded-full shadow-lg flex items-center justify-center hover:bg-[#f0f2f5] transition-all z-30 border border-[#d1d7db] active:scale-90" + className="absolute bottom-[80px] right-6 w-11 h-11 bg-white text-[#54656f] rounded-full shadow-lg flex items-center justify-center hover:bg-[#f0f2f5] transition-all z-30 border border-[#d1d7db] active:scale-90" > {selectedChat?.unread_count > 0 && ( diff --git a/frontend/views/newwhats/components/SessaoSecretariaPower.tsx b/frontend/views/newwhats/components/SessaoSecretariaPower.tsx index 91ae9fa..49da57a 100644 --- a/frontend/views/newwhats/components/SessaoSecretariaPower.tsx +++ b/frontend/views/newwhats/components/SessaoSecretariaPower.tsx @@ -16,7 +16,7 @@ const fmt = (iso?: string | null) => { catch { return ''; } }; -export const SessaoSecretariaPower: React.FC<{ instanceId: string | null }> = ({ instanceId }) => { +export const SessaoSecretariaPower: React.FC<{ instanceId: string | null; canPower?: boolean }> = ({ instanceId, canPower }) => { const [sp, setSp] = useState(null); const [open, setOpen] = useState(false); const [reason, setReason] = useState(''); @@ -30,8 +30,11 @@ export const SessaoSecretariaPower: React.FC<{ instanceId: string | null }> = ({ useEffect(() => { carregar(); }, [carregar]); - if (!instanceId) return null; - const enabled = sp ? sp.enabled : true; + // Fail-closed: só o dono controla o liga/desliga da Secretária. Para os demais o + // botão nem aparece (antes, o GET dava 403 e o estado caía no fail-open "ligada"). + if (!instanceId || !canPower) return null; + const known = sp !== null; // estado real carregado? + const enabled = known ? sp!.enabled : false; // desconhecido NUNCA vira "ligada" const toggle = async () => { if (!sp?.can_toggle) return; @@ -51,10 +54,12 @@ export const SessaoSecretariaPower: React.FC<{ instanceId: string | null }> = ({ return ( <>