feat(superadmin+wa-secretaria): motivos do botão "desativar para todos os chats"

Botão global no /wa-secretaria agora abre modal com textarea de motivo (obrigatório
p/ desativar) + histórico, em vez de um confirm. Página super admin "Secretária IA"
passa a listar os motivos por ESCOPO: "Todos os chats" (global) e "Uma sessão",
mesclando os dois logs do motor (/secretaria/power/log + /session-power/log).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-06 22:49:34 +02:00
parent d8d8617a6c
commit 65244c983f
4 changed files with 104 additions and 20 deletions
+16 -6
View File
@@ -9450,12 +9450,22 @@ app.get('/api/superadmin/secretaria-desligamentos', superadminGuard, async (req,
const email = rows[0]?.email;
if (!email) return res.json({ configurado: true, log: [] });
const limit = Math.min(500, Math.max(1, parseInt(String(req.query.limit || '150'), 10)));
const r = await fetch(`${cfg.motorUrl}/api/ext/v1/secretaria/session-power/log?limit=${limit}`, {
headers: { 'x-nw-key': cfg.integrationKey, 'x-nw-email': email },
});
if (!r.ok) return res.status(502).json({ error: 'Motor indisponível', status: r.status });
const log = await r.json();
res.json({ configurado: true, log: Array.isArray(log) ? log : [] });
const headers = { 'x-nw-key': cfg.integrationKey, 'x-nw-email': email };
const pull = async (path, escopo) => {
try {
const r = await fetch(`${cfg.motorUrl}/api/ext/v1${path}?limit=${limit}`, { headers });
if (!r.ok) return [];
const j = await r.json();
return (Array.isArray(j) ? j : []).map((e) => ({ ...e, escopo }));
} catch { return []; }
};
// "global" = botão que desativa a Secretária para TODOS os chats; "sessao" = por número.
const [glob, sess] = await Promise.all([
pull('/secretaria/power/log', 'global'),
pull('/secretaria/session-power/log', 'sessao'),
]);
const log = [...glob, ...sess].sort((a, b) => new Date(b.at) - new Date(a.at));
res.json({ configurado: true, log });
} catch (err) { res.status(500).json({ error: err.message }); }
});
+8 -2
View File
@@ -113,8 +113,8 @@ const TabSecretaria: React.FC = () => {
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tight flex items-center gap-2"><Power size={18} className="text-red-600" /> Secretária IA motivos de desligamento</h2>
<p className="text-xs text-gray-400 font-medium">Contexto GERAL para identificar bugs/estratégias no atendimento da IA e corrigir. Sem dados das clínicas (nem número, nem quem). {totalOff > 0 && `${totalOff} desligamento(s).`}</p>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tight flex items-center gap-2"><Power size={18} className="text-red-600" /> Secretária IA motivos de desativação</h2>
<p className="text-xs text-gray-400 font-medium">Motivos de desativar a Secretária para TODOS os chats (global) ou por sessão. Contexto GERAL p/ identificar bugs/estratégias no atendimento e corrigir. Sem dados das clínicas. {totalOff > 0 && `${totalOff} desativação(ões).`}</p>
</div>
<button onClick={load} className="p-2 rounded-xl border border-gray-200 text-gray-400 hover:text-gray-700"><RefreshCw size={16} /></button>
</div>
@@ -130,6 +130,7 @@ const TabSecretaria: React.FC = () => {
<thead className="bg-gray-50 text-[10px] font-black text-gray-400 uppercase tracking-widest">
<tr>
<th className="text-left px-4 py-3">Ação</th>
<th className="text-left px-4 py-3">Escopo</th>
<th className="text-left px-4 py-3">Quando</th>
<th className="text-left px-4 py-3">Motivo</th>
</tr>
@@ -142,6 +143,11 @@ const TabSecretaria: React.FC = () => {
? <span className="inline-flex items-center gap-1 text-emerald-700 text-[11px] font-black uppercase"><ToggleRight size={14} /> Ligou</span>
: <span className="inline-flex items-center gap-1 text-red-600 text-[11px] font-black uppercase"><ToggleLeft size={14} /> Desligou</span>}
</td>
<td className="px-4 py-3">
{l.escopo === 'global'
? <span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-black uppercase bg-red-50 text-red-700 border border-red-200">Todos os chats</span>
: <span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-black uppercase bg-gray-100 text-gray-500 border border-gray-200">Uma sessão</span>}
</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{fmt(l.at)}</td>
<td className="px-4 py-3 text-gray-600">{l.reason || <span className="text-gray-300"></span>}</td>
</tr>
+78 -10
View File
@@ -1617,27 +1617,41 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
const [showFila, setShowFila] = useState(false)
const [pendCount, setPendCount] = useState(0)
// Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) — todos, sem timer
// Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) — todos os chats.
// Ao desligar exige MOTIVO (contexto geral p/ o super admin) e guarda histórico.
const [secOn, setSecOn] = useState(true)
const [powerBusy, setPowerBusy] = useState(false)
const [powerModal, setPowerModal] = useState(false)
const [powerReason, setPowerReason] = useState('')
const [powerData, setPowerData] = useState<any>(null)
const [powerErr, setPowerErr] = useState<string | null>(null)
// Boot
useEffect(() => {
store.loadAgents()
store.loadCalendar()
store.loadNumbers()
secPowerApi.get().then((r) => setSecOn(r.enabled)).catch(() => {})
secPowerApi.get().then((r) => { setSecOn(r.enabled); setPowerData(r) }).catch(() => {})
}, [])
const handleTogglePower = useCallback(async () => {
const openPowerModal = useCallback(() => {
setPowerErr(null); setPowerReason('')
secPowerApi.get().then((r) => { setSecOn(r.enabled); setPowerData(r) }).catch(() => {})
setPowerModal(true)
}, [])
const confirmTogglePower = useCallback(async () => {
if (powerBusy) return
const proximo = !secOn
// Ao DESLIGAR (para todos os chats, sem retorno automático), confirma.
if (!proximo && !window.confirm('Desligar a Secretária para TODOS os chats? Ela para de responder e só volta quando você ligar de novo.')) return
setPowerBusy(true)
try { const r = await secPowerApi.set(proximo); setSecOn(r.enabled) }
catch { /* mantém estado atual */ } finally { setPowerBusy(false) }
}, [secOn, powerBusy])
if (!proximo && !powerReason.trim()) { setPowerErr('Escreva o motivo para desativar.'); return }
setPowerBusy(true); setPowerErr(null)
try {
const userName = (() => { try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}')?.nome || 'Usuário' } catch { return 'Usuário' } })()
const r = await secPowerApi.set(proximo, powerReason.trim(), userName)
setSecOn(r.enabled); setPowerReason('')
const fresh = await secPowerApi.get().catch(() => null); if (fresh) setPowerData(fresh)
} catch (e: any) { setPowerErr(e?.message || 'Não foi possível alterar.') } finally { setPowerBusy(false) }
}, [secOn, powerBusy, powerReason])
// Contagem de pendências para o badge (dono) — atualiza ao abrir e a cada 5 min.
useEffect(() => {
@@ -1710,7 +1724,61 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
{/* Thin Nav */}
<ThinNav active={activeTab} onChange={handleTabChange} onHelp={() => setShowHelp(true)} onBack={() => props.onNavigate?.('dashboard')}
onPendencias={ehDono ? () => setShowPendencias(true) : undefined} pendenciasBadge={pendCount}
secOn={secOn} onTogglePower={handleTogglePower} />
secOn={secOn} onTogglePower={openPowerModal} />
{powerModal && (
<div className="fixed inset-0 bg-black/60 z-[80] flex items-center justify-center backdrop-blur-md p-4" onClick={() => setPowerModal(false)}>
<div className="bg-white w-full max-w-md rounded-[1.5rem] shadow-2xl border border-gray-100 flex flex-col max-h-[90vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="px-6 py-5 border-b border-gray-50 flex items-center justify-between bg-gradient-to-br from-gray-50 to-white">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-xl ${secOn ? 'bg-emerald-500' : 'bg-red-600'} shadow`}><Power size={18} className="text-white" /></div>
<div>
<h2 className="text-base font-black text-gray-900 uppercase tracking-tighter leading-none">Secretária todos os chats</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-1">{secOn ? 'Ativa' : 'Desativada'} · vale para TODAS as sessões</p>
</div>
</div>
<button onClick={() => setPowerModal(false)} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={20} /></button>
</div>
{powerErr && <div className="px-6 py-2 text-xs font-bold bg-red-50 text-red-600">{powerErr}</div>}
<div className="p-6 space-y-4 overflow-y-auto">
{!secOn && powerData?.reason && (
<div className="text-xs bg-red-50 text-red-700 rounded-xl p-3"><span className="font-black">Desativada.</span> Motivo: {powerData.reason}</div>
)}
{secOn && (
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Motivo para desativar (todos os chats)</label>
<textarea value={powerReason} onChange={(e) => setPowerReason(e.target.value)} rows={3}
placeholder="Ex.: bug na forma de agendar; estratégia a ajustar no atendimento da IA…"
className="w-full bg-gray-50 border-2 border-transparent focus:border-red-500 rounded-xl px-3 py-2 text-sm outline-none resize-y" />
<p className="text-[10px] text-gray-400 mt-1">Desativa a Secretária para TODAS as sessões até alguém reativar. O motivo aparece no super admin.</p>
</div>
)}
<button onClick={confirmTogglePower} disabled={powerBusy}
className={`w-full py-2.5 rounded-xl text-sm font-black uppercase tracking-wide flex items-center justify-center gap-2 disabled:opacity-40 ${secOn ? 'bg-red-600 hover:bg-red-700 text-white' : 'bg-emerald-600 hover:bg-emerald-700 text-white'}`}>
{powerBusy ? <Loader2 size={16} className="animate-spin" /> : <Power size={16} />}
{secOn ? 'Desativar (todos os chats)' : 'Ativar (todos os chats)'}
</button>
{!!powerData?.history?.length && (
<div>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2">Histórico</p>
<div className="space-y-1.5 max-h-40 overflow-y-auto">
{powerData.history.map((h: any, i: number) => (
<div key={i} className="text-[11px] flex items-start gap-2">
<span className={`mt-1 w-1.5 h-1.5 rounded-full shrink-0 ${h.enabled ? 'bg-emerald-500' : 'bg-red-500'}`} />
<div className="min-w-0">
<span className="font-bold text-gray-700">{h.enabled ? 'Ativou' : 'Desativou'}</span>
<span className="text-gray-400"> · {h.by || '—'} · {h.at ? new Date(h.at).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}</span>
{!h.enabled && h.reason && <div className="text-gray-500 leading-snug">{h.reason}</div>}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
{ehDono && (
<>
@@ -86,8 +86,8 @@ export interface CalendarSlot {
// ─── Power (liga/desliga global — kill-switch auto_reply) ──────────────────────
export const secPowerApi = {
get: () => api.get<{ enabled: boolean }>(`${BASE}/power`).then((r) => r.data),
set: (enabled: boolean) => api.post<{ enabled: boolean }>(`${BASE}/power`, { enabled }).then((r) => r.data),
get: () => api.get<SessionPower>(`${BASE}/power`).then((r) => r.data),
set: (enabled: boolean, reason = '', by = '') => api.post<{ enabled: boolean }>(`${BASE}/power`, { enabled, reason, by }).then((r) => r.data),
}
// Liga/desliga a Secretária POR SESSÃO (instância atual do /wa-inbox).