feat(wa-inbox): nome do paciente, fila, power por chat, agenda, secretária

- 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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-10 06:07:13 +02:00
parent 56443765d4
commit 89588c2674
12 changed files with 586 additions and 35 deletions
+132 -9
View File
@@ -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<Set<string>>(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<string>()
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<Record<string, { principal: string; count: number }>>({})
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
</AnimatePresence>
<div className="flex flex-1 overflow-hidden h-full">
<ThinSidebar activeTab={activeTab} setActiveTab={setActiveTab} activeInstance={activeInstance} onNavigate={onNavigate} />
<ThinSidebar activeTab={activeTab} setActiveTab={setActiveTab} activeInstance={activeInstance} onNavigate={onNavigate} filaCount={filaChats.length} canPower={isWaOwner} />
{activeTab === 'chats' && (
{(activeTab === 'chats' || activeTab === 'fila') && (
<div className={`${isMobile ? (selectedChat ? 'hidden' : 'w-full') : 'w-[400px]'} flex flex-col bg-white border-r border-[#d1d7db] shrink-0 z-20`}>
{/* Header da sidebar */}
<div className="h-[60px] px-4 flex items-center justify-between bg-[#f0f2f5] border-b border-[#d1d7db] shrink-0">
@@ -358,7 +446,9 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
</AnimatePresence>
</div>
{/* 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 && (
<button
onClick={() => onNavigate?.('wa-secretaria')}
className="p-1.5 rounded-full text-[#8696a0] hover:bg-black/5 hover:text-[#2563eb] transition-colors"
@@ -366,6 +456,7 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
>
<Bot className="w-4 h-4" />
</button>
)}
{/* Ícone (i): legenda dos papéis de cada número acessível */}
<div className="relative" ref={roleInfoRef}>
@@ -452,9 +543,9 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
</button>
</div>
{/* Lista de chats */}
{/* Lista de chats — na aba "Fila", só as conversas com pendência da Secretária */}
<ChatSidebar
chats={chatsByScope}
chats={activeTab === 'fila' ? filaChats : chatsEnriched}
loading={loading}
selectedChat={selectedChat}
searchTerm={searchTerm}
@@ -496,6 +587,11 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => 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}
/>
<ProtocolPanel
isOpen={isProtocolOpen && !isMobile}
@@ -616,6 +713,32 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
setForwardingMessage(null)
}}
/>
{/* Fila da Secretária — mesmo modal da /agenda (confirmar/recusar/ajustar horário) */}
<PedidosPendentes
isOpen={showPedidos}
onClose={() => 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 && (
<div className="fixed inset-0 z-[80] bg-white flex flex-col">
<div className="h-12 px-4 flex items-center justify-between border-b border-gray-100 bg-[#f0f2f5] shrink-0">
<span className="text-sm font-black uppercase tracking-tight text-gray-700">Agenda</span>
<button onClick={() => setShowAgenda(false)} className="p-2 hover:bg-black/5 rounded-full text-[#54656f]" title="Fechar agenda (voltar ao chat)">
<X className="w-5 h-5" />
</button>
</div>
{/* Mesmo respiro da página /agenda (App.tsx: p-4 md:p-8 + scroll) */}
<div className="flex-1 min-h-0 overflow-y-auto scroll-smooth p-4 md:p-8">
<AgendaView onNavigate={onNavigate} />
</div>
</div>
)}
</div>
)
}
@@ -1626,6 +1626,22 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
const [powerData, setPowerData] = useState<any>(null)
const [powerErr, setPowerErr] = useState<string | null>(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 (
<div className="flex h-full w-full bg-gray-50 overflow-hidden font-sans">
@@ -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<AiPauseButtonProps> = ({ jid, instanceId }) => {
const [mode, setMode] = useState<'ia' | 'humano'>('ia');
const [humanAt, setHumanAt] = useState<number | null>(null);
const [remaining, setRemaining] = useState<number>(0);
const [busy, setBusy] = useState(false);
const [secEnabled, setSecEnabled] = useState<boolean | null>(null); // liga/desliga efetivo
const tickRef = useRef<ReturnType<typeof setInterval> | 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 (
<button
disabled
title="Secretária IA desligada neste chat — ligue no cabeçalho do chat ou no botão de energia"
className="absolute bottom-6 right-6 z-30 w-11 h-11 rounded-full shadow-lg flex items-center justify-center border bg-white text-gray-300 border-[#d1d7db] cursor-not-allowed opacity-80"
>
<Bot className="w-5 h-5" />
</button>
);
}
return (
<>
{/* Contador central (só quando pausado) */}
{paused && remaining > 0 && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 bg-[#f59e0b]/95 text-white px-3.5 py-1.5 rounded-full shadow-lg text-[13px] font-bold pointer-events-none">
<Bot className="w-4 h-4" />
IA pausada · {mmss()}
</div>
)}
{/* Botão robô: laranja = IA ativa; cinza = pausada */}
<button
onClick={toggle}
disabled={busy}
title={paused ? `Secretária IA pausada (${mmss()}) — clique para retomar` : 'Pausar a Secretária IA por 15 min (assumir a conversa)'}
className={`absolute bottom-6 right-6 z-30 w-11 h-11 rounded-full shadow-lg flex items-center justify-center border transition-all active:scale-90 disabled:opacity-60 ${
paused
? 'bg-white text-[#8696a0] border-[#d1d7db] hover:text-[#54656f] hover:bg-[#f0f2f5]'
: 'bg-[#f59e0b] text-white border-[#f59e0b] hover:bg-[#d97706]'
}`}
>
<Bot className="w-5 h-5" />
</button>
</>
);
};
export default AiPauseButton;
@@ -193,13 +193,27 @@ export default function ChatListItem({
<div className="flex-1 min-w-0 flex flex-col justify-center py-0.5 pr-1 text-left">
<div className="flex items-center justify-between">
<h3 className="text-[16px] leading-[21px] font-normal text-[#111b21] truncate">
{chat.displayName || chat.subject || chat.phone}
{(chat as any).nomeNosso || chat.displayName || chat.subject || chat.phone}
</h3>
<span className={`text-[12px] leading-[14px] flex-shrink-0 ml-2 ${chat.unread_count > 0 ? 'text-[#00a884] font-medium' : 'text-[#667781]'}`}>
{relativeTime}
</span>
</div>
{((chat as any).pendenteSec || (Array.isArray((chat as any).labels) && (chat as any).labels.length > 0)) && (
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
{/* Selo da fila da Secretária: há pedido aguardando a humana confirmar */}
{(chat as any).pendenteSec && (
<span className="text-[10px] font-black uppercase tracking-wide px-1.5 py-[1px] rounded-full bg-amber-500 text-white flex items-center gap-0.5">
<Clock className="w-2.5 h-2.5" /> Pendente
</span>
)}
{Array.isArray((chat as any).labels) && (chat as any).labels.slice(0, 3).map((l: any) => (
<span key={l.id} className="text-[10px] font-medium px-1.5 py-[1px] rounded-full text-white truncate max-w-[100px]" style={{ backgroundColor: l.color }}>{l.name}</span>
))}
</div>
)}
<div className="flex items-center justify-between mt-1 h-5">
<div className="flex items-center gap-1 flex-1 min-w-0">
{(() => {
@@ -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<ChatPower | null>(null);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const ref = useRef<HTMLDivElement>(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 }) => (
<button onClick={onClick} disabled={busy}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-left hover:bg-[#f5f6f6] transition-colors disabled:opacity-50 ${danger ? 'text-red-600' : 'text-[#111b21]'}`}>
<span className="shrink-0">{icon}</span>
<span className="flex-1">{label}</span>
{active && <Check size={14} className="text-emerald-600 shrink-0" />}
</button>
);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(o => !o)}
title="Secretária IA neste chat"
className={`relative p-2 rounded-full transition-all hover:bg-black/5 ${open ? 'bg-black/10' : ''} ${tone}`}
>
<Bot className="w-5 h-5" />
{overridden && <span className={`absolute top-1.5 right-1.5 w-2 h-2 rounded-full border border-white ${enabled ? 'bg-emerald-500' : 'bg-red-500'}`} />}
</button>
{open && (
<div className="absolute right-0 top-full mt-1 bg-white shadow-2xl rounded-xl border border-gray-100 w-60 z-50 py-1.5 overflow-hidden">
<p className="px-3 pt-1 pb-1.5 text-[10px] font-black uppercase tracking-widest text-gray-400">Secretária neste chat</p>
{busy && <div className="px-3 py-2 flex items-center gap-2 text-xs text-gray-400"><Loader2 size={13} className="animate-spin" /> aplicando</div>}
<Opt icon={<Power size={15} className="text-emerald-600" />} label="Ligar só neste chat" active={overridden && enabled === true} onClick={() => aplicar(true)} />
<Opt icon={<PowerOff size={15} className="text-red-600" />} label="Desligar só neste chat" active={overridden && enabled === false} danger onClick={() => aplicar(false)} />
{overridden && (
<>
<div className="my-1 border-t border-gray-100" />
<Opt icon={<CornerUpLeft size={15} className="text-gray-400" />} label="Herdar padrão (global/sessão)" active={false} onClick={() => aplicar(null)} />
</>
)}
</div>
)}
</div>
);
};
@@ -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<ContactProfileProps> = ({
@@ -35,6 +36,7 @@ const ContactProfile: React.FC<ContactProfileProps> = ({
chat,
messages,
onClose,
nomeNosso,
}) => {
// Filter media messages
const mediaMessages = useMemo(() => {
@@ -75,7 +77,7 @@ const ContactProfile: React.FC<ContactProfileProps> = ({
{/* Profile Photo */}
<Avatar chat={chat} size={200} className="mb-5 shadow-lg" />
<h1 className="text-[24px] font-normal text-[#111b21] mb-1 text-center">
{chat.lead_name || chat.subject || formatPhone(chat.phone, chat.remote_jid)}
{nomeNosso || chat.lead_name || chat.subject || formatPhone(chat.phone, chat.remote_jid)}
</h1>
<p className="text-[16px] text-[#667781] font-normal mb-1">
{formatPhone(chat.phone, chat.remote_jid)}
@@ -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<InboxHeaderProps> = ({
isSyncingAvatar,
isProtocolOpen,
hasActiveProtocol,
isOwner,
nomeNosso,
activeInstanceId,
pedidosCount = 0,
onOpenPedidos,
onOpenAgenda,
onClearConversation,
onManageLabels,
onBack,
onSearchClick,
onToggleMenu,
@@ -69,7 +88,7 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
<div className="flex flex-col min-w-0">
<h3 className="text-[16px] leading-[21px] font-bold text-[#111b21] flex items-center gap-2 truncate group-hover/header:text-[#00a884] transition-colors">
{selectedChat.lead_name || selectedChat.subject || formatPhone(selectedChat.phone, selectedChat.remote_jid)}
{nomeNosso || selectedChat.lead_name || selectedChat.subject || formatPhone(selectedChat.phone, selectedChat.remote_jid)}
</h3>
<div className="flex items-center gap-1 min-w-0">
{isTyping ? (
@@ -91,6 +110,33 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
</div>
<div className="flex items-center gap-1">
{/* Secretária IA só neste chat (override do dono, precedência máxima) */}
<ChatSecretariaPower instanceId={activeInstanceId ?? null} jid={selectedChat?.remote_jid ?? null} canPower={isOwner} />
{/* Agenda — abre a agenda (overlay) p/ conferir dias/horários vagos sem sair do chat */}
{onOpenAgenda && (
<button
onClick={onOpenAgenda}
className="p-2 rounded-full transition-all text-[#54656f] hover:bg-black/5 hover:text-[#128C7E]"
title="Abrir agenda"
>
<CalendarDays className="w-5 h-5" />
</button>
)}
{/* Fila da Secretária — modal "Pedidos aguardando confirmação" (mesmo da agenda) */}
{onOpenPedidos && (
<button
onClick={onOpenPedidos}
className="relative p-2 rounded-full transition-all text-[#54656f] hover:bg-black/5 hover:text-amber-600"
title="Pedidos aguardando confirmação"
>
<CalendarClock className="w-5 h-5" />
{pedidosCount > 0 && (
<span className="absolute top-1 right-1 min-w-[15px] h-[15px] px-1 rounded-full bg-amber-500 text-white text-[9px] font-black flex items-center justify-center pointer-events-none">
{pedidosCount > 99 ? '99+' : pedidosCount}
</span>
)}
</button>
)}
{onProtocolClick && (
<button
onClick={onProtocolClick}
@@ -121,6 +167,9 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
<AnimatePresence>
{isChatMenuOpen && (
<>
{/* Backdrop: fecha o menu ao clicar fora */}
<div className="fixed inset-0 z-40" onClick={onToggleMenu} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
@@ -138,16 +187,29 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
<RefreshCw className={`w-4 h-4 ${isSyncingAvatar ? 'animate-spin' : ''}`} />
Sincronizar Foto
</button>
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors">
<button
onClick={() => { onToggleMenu(); onManageLabels?.(); }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors"
>
<Tag className="w-4 h-4" />
Gerenciar etiquetas
</button>
<div className="border-t border-gray-100 my-1"></div>
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] font-bold text-red-600 hover:bg-red-50 transition-colors">
<X className="w-4 h-4" />
Limpar conversa
</button>
{/* 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 && (
<>
<div className="border-t border-gray-100 my-1"></div>
<button
onClick={() => { onToggleMenu(); onClearConversation?.(); }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] font-bold text-red-600 hover:bg-red-50 transition-colors"
>
<X className="w-4 h-4" />
Limpar conversa
</button>
</>
)}
</motion.div>
</>
)}
</AnimatePresence>
</div>
@@ -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<MessageAreaProps> = ({
@@ -54,6 +56,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
quickReactions,
getGroupSenderLabel,
canDelete = true,
highlightTerm,
}) => {
const [showScrollBottom, setShowScrollBottom] = useState(false);
const lastScrollTop = useRef(0);
@@ -144,6 +147,8 @@ const MessageArea: React.FC<MessageAreaProps> = ({
return (
<>
<div className="flex-1 relative flex flex-col min-h-0 bg-transparent overflow-hidden">
{/* Botão do atendente: pausar/retomar a Secretária IA nesta conversa. */}
<AiPauseButton jid={selectedChat?.remote_jid} instanceId={selectedChat?.instance_name ?? selectedChat?.instanceId} />
<div
className="flex-1 overflow-y-auto px-4 md:px-14 py-6 flex flex-col custom-scrollbar relative z-10"
@@ -219,6 +224,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
selectedChat={selectedChat}
onOpenMedia={(m) => setViewerMsgId(m.message_id)}
onRedownloadMedia={handleRedownloadMedia}
highlightTerm={highlightTerm}
/>
</React.Fragment>
);
@@ -237,7 +243,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
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"
>
<ArrowDown className="w-5 h-5" />
{selectedChat?.unread_count > 0 && (
@@ -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<SessionPower | null>(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 (
<>
<button
title={enabled ? 'Secretária IA ligada nesta sessão — clique para gerenciar' : 'Secretária IA DESLIGADA nesta sessão'}
title={!known ? 'Secretária IA — carregando estado…' : enabled ? 'Secretária IA ligada nesta sessão — clique para gerenciar' : 'Secretária IA DESLIGADA nesta sessão'}
onClick={() => { setErro(null); setOpen(true); }}
className={`w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors ${
enabled ? 'text-emerald-600 hover:bg-[#d9dbdf]/60' : 'text-white bg-red-600 hover:bg-red-700 animate-pulse'
!known ? 'text-gray-300 hover:bg-[#d9dbdf]/60'
: enabled ? 'text-emerald-600 hover:bg-[#d9dbdf]/60'
: 'text-white bg-red-600 hover:bg-red-700 animate-pulse'
}`}
>
<Power className="w-5 h-5" />
@@ -5,20 +5,25 @@ import {
Users,
Settings,
BrainCircuit,
CalendarClock,
} from 'lucide-react';
import { getAvatarProxyUrl } from '../utils/inboxUtils';
import { SessaoSecretariaPower } from './SessaoSecretariaPower';
export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings';
export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings' | 'fila';
interface ThinSidebarProps {
activeTab: TabType;
setActiveTab: (tab: TabType) => void;
activeInstance: any; // We'll pass the active instance to show the avatar
onNavigate?: (view: string) => void; // satélite: navegação entre as views do plugin
// Fila da Secretária: nº de conversas com pedido pendente (badge da aba). 0 = sem badge.
filaCount?: number;
// Só o dono controla o liga/desliga da Secretária (botão de power). Fail-closed.
canPower?: boolean;
}
export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, onNavigate }: ThinSidebarProps) {
export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, onNavigate, filaCount = 0, canPower = false }: ThinSidebarProps) {
const isConnected = activeInstance?.status === 'connected' || activeInstance?.status === 'open';
const renderIcon = (id: TabType, IconComponent: any, title: string) => {
@@ -51,6 +56,18 @@ export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, o
{/* Top Icons */}
<div className="flex-1 w-full flex flex-col items-center">
{renderIcon('chats', MessageCircle, 'Conversas')}
{/* Fila da Secretária: conversas com pedido aguardando confirmação da humana */}
<div className="relative">
{renderIcon('fila', CalendarClock, 'Fila da Secretária')}
{filaCount > 0 && (
<span
className="absolute -top-0.5 right-0 min-w-[18px] h-[18px] px-1 rounded-full bg-amber-500 text-white text-[10px] font-black flex items-center justify-center pointer-events-none shadow"
title={`${filaCount} pendente(s)`}
>
{filaCount > 99 ? '99+' : filaCount}
</span>
)}
</div>
{renderIcon('broadcasts', Megaphone, 'Transmissões')}
{renderIcon('groups', Users, 'Sessões')}
{/* Secretária IA — abre a tela de configuração da secretária virtual */}
@@ -66,7 +83,7 @@ export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, o
{/* Bottom Icons */}
<div className="w-full flex flex-col items-center pb-2">
{/* Liga/desliga a Secretária IA APENAS desta sessão (número atual) */}
<SessaoSecretariaPower instanceId={activeInstance?.instance ?? null} />
<SessaoSecretariaPower instanceId={activeInstance?.instance ?? null} canPower={canPower} />
{/* Configurações — abre o painel na área central (aba 'settings') */}
{renderIcon('settings', Settings, 'Configurações')}
@@ -1,7 +1,7 @@
// Shims dos services do motor sobre a ext API do satélite (/api/nw/v1/*).
// Mantém a MESMA interface que o chatApiService.ts do motor expõe, normalizando
// os shapes da ext API para o formato que os stores/hooks portados esperam.
import { nw } from './nwClient';
import { nw, nwToken } from './nwClient';
// ─── Instâncias ── (ext /sessions) ──────────────────────────────────────────
export const instanceApi = {
@@ -13,6 +13,29 @@ export const instanceApi = {
delete: (id: string) => nw.delete(`/sessions/${id}`),
};
// ─── Contas da inbox ── (/api/nw/accounts, fora do proxy /nw/v1) ─────────────
// Números que o usuário logado pode operar: os PRÓPRIOS + os LIBERADOS pelo dono
// (can_inbox). Cada item traz o papel (sec_numbers) p/ o seletor e o ícone (i).
export interface InboxAccount {
instanceId: string; phone: string | null; name: string | null;
status: string | null; avatar: string | null;
role: string | null; label: string | null; area: string | null; notes: string | null;
clinicaId: string | null; ownerEmail: string | null; ownerNome: string | null;
own: boolean; canInbox: boolean;
}
export const accountsApi = {
list: async (): Promise<InboxAccount[]> => {
try {
const res = await fetch(`${nw.base()}/nw/accounts`, {
headers: { Authorization: `Bearer ${nwToken() ?? ''}` },
});
if (!res.ok) return [];
const j = await res.json();
return Array.isArray(j) ? j : [];
} catch { return []; }
},
};
// ─── Avatar self-heal ── (ext /contacts/:jid/avatar/refresh) ─────────────────
// A URL do CDN do WhatsApp expira e passa a 404; aqui pedimos ao motor para
// re-buscar a foto atual pela conexão viva. Retorna a URL nova ou null.
@@ -25,6 +48,33 @@ export const avatarApi = {
},
};
// ─── Handoff IA/humano ── (ext /sec/handoff/:jid — pausar/retomar a Secretária) ──
export const handoffApi = {
// secEnabled = estado efetivo do liga/desliga (chat/sessão/global) p/ o botão de
// pausa não mostrar "IA ativa" quando a Secretária está desligada. null = desconhecido.
get: async (jid: string, instanceId?: string | null): Promise<{ mode: 'ia' | 'humano'; humanAt: string | null; secEnabled: boolean | null }> => {
try {
const q = instanceId ? `?instance_id=${encodeURIComponent(instanceId)}` : '';
const r = await nw.get(`/sec/handoff/${encodeURIComponent(jid)}${q}`);
return { mode: r?.mode === 'humano' ? 'humano' : 'ia', humanAt: r?.handoffHumanAt ?? null, secEnabled: r?.sec_enabled ?? null };
}
catch { return { mode: 'ia', humanAt: null, secEnabled: null }; }
},
set: (jid: string, mode: 'ia' | 'humano') => nw.patch(`/sec/handoff/${encodeURIComponent(jid)}`, { mode }),
};
// Janela de pausa antes do retorno automático da IA (igual ao motor: HANDOFF_TIMEOUT_MS).
export const HANDOFF_MINUTES = 15;
// ─── Etiquetas ── (ext /labels + /inbox/:chatId/labels) ─────────────────────
export interface Label { id: string; name: string; color: string }
export const labelApi = {
list: (): Promise<Label[]> => nw.get('/labels'),
create: (name: string, color: string): Promise<Label> => nw.post('/labels', { name, color }),
update: (id: string, patch: { name?: string; color?: string }) => nw.patch(`/labels/${id}`, patch),
delete: (id: string) => nw.delete(`/labels/${id}`),
setForChat: (chatId: string, labelIds: string[]) => nw.put(`/inbox/${chatId}/labels`, { labelIds }),
};
// ─── Chats ── (ext /inbox → shape achatado; normaliza p/ mapBackendChat) ────
export const chatApi = {
list: async (instanceId: string, opts: { search?: string; archived?: boolean; limit?: number } = {}) => {
@@ -55,6 +105,25 @@ export const chatApi = {
archive: (chatId: string, archived: boolean) => nw.patch(`/inbox/${chatId}/archive`, { archived }),
pin: (chatId: string, pinned: boolean) => nw.patch(`/inbox/${chatId}/pin`, { pinned }),
delete: (chatId: string) => nw.delete(`/inbox/${chatId}`),
// Apaga UMA mensagem para todos (revoke no WhatsApp). messageId = id interno (uuid).
deleteMessage: (chatId: string, messageId: string) => nw.delete(`/inbox/${chatId}/messages/${messageId}`),
// "Limpar conversa": remove TODAS as mensagens do chat no motor (mantém o chat). Local
// (não revoga no WhatsApp). Gate de dono/can_delete_msg validado no proxy nw.
clearConversation: (chatId: string) => nw.delete(`/inbox/${chatId}/messages`),
// Assina a presença do contato (composing/recording) — chamar ao abrir o chat.
subscribePresence: (chatId: string) => nw.post(`/inbox/${chatId}/presence`),
// Reage a uma mensagem (emoji ''= remove). messageId = id interno (uuid).
react: (chatId: string, messageId: string, emoji: string) =>
nw.post(`/inbox/${chatId}/messages/${messageId}/react`, { emoji }),
// Edita uma mensagem de texto enviada por você.
editMessage: (chatId: string, messageId: string, text: string) =>
nw.patch(`/inbox/${chatId}/messages/${messageId}`, { text }),
// Encaminha uma mensagem para outros chats (ids internos).
forward: (chatId: string, messageId: string, toChatIds: string[]) =>
nw.post(`/inbox/${chatId}/messages/${messageId}/forward`, { toChatIds }),
// Envia minha presença ao contato (composing/paused).
typing: (chatId: string, state: 'composing' | 'paused' | 'recording') =>
nw.post(`/inbox/${chatId}/typing`, { state }),
};
// ─── Mensagens ── (ext devolve array; envolve em {messages, hasMore}) ───────
@@ -83,24 +152,34 @@ export const messageApi = {
nw.post(`/inbox/${chatId}/send`, { text, ...(replyToMessageId ? { replyToMessageId } : {}) }),
};
// Lê um Blob/File como base64 puro (sem o prefixo data:...;base64,).
const toBase64 = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const r = new FileReader();
r.onloadend = () => resolve(String(r.result).split(',')[1] || '');
r.onerror = () => reject(new Error('Falha ao ler o arquivo'));
r.readAsDataURL(blob);
});
// ─── Mídia ── (ext /inbox/:chatId/send-media | send-audio) ──────────────────
// Imagem/vídeo/documento/figurinha vão via MULTIPART (o proxy encaminha o stream ao
// motor — sem limite de base64). Áudio (nota de voz) vai em base64/JSON: é pequeno e
// o motor converte p/ OGG/Opus via ffmpeg.
export const mediaApi = {
sendMedia: (chatId: string, file: File, caption?: string, replyToMessageId?: string) => {
sendMedia: (chatId: string, file: File, caption?: string) => {
const fd = new FormData();
fd.append('file', file);
if (caption) fd.append('caption', caption);
if (replyToMessageId) fd.append('replyToMessageId', replyToMessageId);
return nw.post(`/inbox/${chatId}/send-media`, fd);
},
sendAudio: (chatId: string, blob: Blob) => {
const fd = new FormData();
fd.append('audio', blob, 'audio.ogg');
return nw.post(`/inbox/${chatId}/send-audio`, fd);
sendAudio: async (chatId: string, blob: Blob) => {
const audio = await toBase64(blob);
return nw.post(`/inbox/${chatId}/send-audio`, { audio, mimetype: blob.type || 'audio/ogg' });
},
// Sticker: reaproveita o endpoint de mídia (a ext detecta image/webp → sticker).
sendSticker: (chatId: string, blob: Blob) => {
const fd = new FormData();
fd.append('file', new File([blob], 'sticker.webp', { type: 'image/webp' }));
fd.append('sticker', 'true');
return nw.post(`/inbox/${chatId}/send-media`, fd);
},
// Re-download sob demanda: ~95% das mídias vêm com mediaUrl NULL (lazy). O motor
@@ -106,6 +106,21 @@ export const secSessionPowerApi = {
api.post<{ ok: boolean; enabled: boolean }>(`${BASE}/session-power`, { instance_id: instanceId, enabled, reason, by }).then((r) => r.data),
}
// Override POR CHAT (decisão do dono, precedência máxima). overridden=false → herda
// a cascata global/sessão; enabled null nesse caso. set(enabled=null) remove o override.
export interface ChatPower {
overridden: boolean
enabled: boolean | null
changed_by: string | null
changed_at: string | null
}
export const secChatPowerApi = {
get: (instanceId: string, jid: string) =>
api.get<ChatPower>(`${BASE}/chat-power`, { params: { instance_id: instanceId, jid } }).then((r) => r.data),
set: (instanceId: string, jid: string, enabled: boolean | null, by: string) =>
api.post<{ ok: boolean; overridden: boolean; enabled: boolean | null }>(`${BASE}/chat-power`, { instance_id: instanceId, jid, enabled, by }).then((r) => r.data),
}
// ─── Agents ───────────────────────────────────────────────────────────────────
export const secAgentApi = {