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:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user