feat(newwhats): agenda inteligente — especialidades, situações, dúvidas, checklist do dono e pendências
Satélite da Secretária IA: - /contexto (dentistas+especialidades+situações+cautelas) e /duvida (encaminhar ao humano sem agendar); agenda_pedidos ganha tipo=duvida + resumo. - Situações da clínica: tabela agenda_situacoes + config (dono) + aba na UI. - Checklist do dono (checklist-defs.js + agenda_checklist): perguntas Sim/Não conferidas contra o banco (dentistas/pacientes) → pendências de config + cautelas p/ a IA não chutar. - Área de PENDÊNCIAS DO DONO (PendenciasDono.tsx): engloba config + fila operacional (horários/dúvidas); botão+badge no /wa-secretaria (só o dono). - Fila humana (PedidosPendentes): cards de dúvida + resolver; /resolver. - Botão DESLIGAR global da Secretária no ThinNav (secPowerApi, visível a todos). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
// horários dos dentistas. Consome /api/nw/agenda-config (o backend garante o
|
||||
// ownership: horário/feriado da clínica = dono; horário do dentista = ele ou dono).
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy } from 'lucide-react';
|
||||
import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
@@ -88,8 +88,8 @@ const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void;
|
||||
);
|
||||
};
|
||||
|
||||
export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => {
|
||||
const [aba, setAba] = useState<'clinica' | 'dentistas'>(initialTab || 'clinica');
|
||||
export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas' | 'situacoes'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => {
|
||||
const [aba, setAba] = useState<'clinica' | 'dentistas' | 'situacoes'>(initialTab || 'clinica');
|
||||
useEffect(() => { if (isOpen && initialTab) setAba(initialTab); }, [isOpen, initialTab]);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
@@ -107,6 +107,9 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
|
||||
const [folgas, setFolgas] = useState<any[]>([]);
|
||||
const [novaFolga, setNovaFolga] = useState({ data_inicio: '', data_fim: '', motivo: '' });
|
||||
|
||||
// Situações (regras que a Secretária IA segue)
|
||||
const [situacoes, setSituacoes] = useState<string[]>([]);
|
||||
|
||||
const flash = (setter: (v: string | null) => void, msg: string) => { setter(msg); setTimeout(() => setter(null), 3000); };
|
||||
|
||||
const carregarClinica = useCallback(async () => {
|
||||
@@ -131,7 +134,13 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
|
||||
} catch (e: any) { flash(setErro, e.message); }
|
||||
}, [clinicaId, soDentista]);
|
||||
|
||||
useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); } }, [isOpen, carregarClinica, carregarDentistas]);
|
||||
const carregarSituacoes = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
try { const s = await req(`/clinica/${clinicaId}/situacoes`); setSituacoes((s.situacoes || []).map((r: any) => r.texto)); }
|
||||
catch (e: any) { flash(setErro, e.message); }
|
||||
}, [clinicaId]);
|
||||
|
||||
useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); if (!soDentista) carregarSituacoes(); } }, [isOpen, carregarClinica, carregarDentistas, carregarSituacoes, soDentista]);
|
||||
const carregarFolgas = useCallback((id: string) => {
|
||||
req(`/dentista/${id}/folgas`).then((f) => setFolgas(f.folgas || [])).catch(() => setFolgas([]));
|
||||
}, []);
|
||||
@@ -171,6 +180,11 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
|
||||
try { await req(`/clinica/${clinicaId}/feriados/${id}`, { method: 'DELETE' }); carregarClinica(); }
|
||||
catch (e: any) { flash(setErro, e.message); }
|
||||
};
|
||||
const salvarSituacoes = async () => {
|
||||
setSaving(true); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/situacoes`, { method: 'PUT', body: JSON.stringify({ situacoes: situacoes.map((s) => s.trim()).filter(Boolean) }) }); flash(setOk, 'Situações salvas!'); }
|
||||
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -191,10 +205,10 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-8 pt-4 border-b border-gray-50">
|
||||
{(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas'] as const)).map((t) => (
|
||||
{(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas', 'situacoes'] as const)).map((t) => (
|
||||
<button key={t} onClick={() => setAba(t)}
|
||||
className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide rounded-t-xl transition-colors ${aba === t ? 'bg-teal-600 text-white' : 'text-gray-400 hover:text-gray-600'}`}>
|
||||
{t === 'clinica' ? 'Clínica' : 'Dentistas'}
|
||||
{t === 'clinica' ? 'Clínica' : t === 'dentistas' ? 'Dentistas' : 'Situações'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -244,7 +258,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
) : aba === 'dentistas' ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -287,6 +301,30 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2"><AlertTriangle size={16} className="text-teal-600" /> Situações e regras da Secretária IA</h3>
|
||||
<button onClick={salvarSituacoes} disabled={saving} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed bg-amber-50 rounded-2xl p-4">
|
||||
Escreva regras que a Secretária IA deve seguir ao atender — uma por caixa. Ex.: <span className="italic">"Pelo plano X não temos profissional para canal em dente posterior; não agende direto — ofereça uma avaliação para o dentista examinar melhor o dente."</span> Quando uma situação exigir decisão humana, a IA encaminha o caso e pede para o paciente aguardar (aparece em Pedidos pendentes).
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{situacoes.map((s, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<textarea value={s} rows={2} onChange={(e) => setSituacoes(situacoes.map((x, j) => j === i ? e.target.value : x))}
|
||||
placeholder="Descreva a situação e o que a IA deve fazer…"
|
||||
className="flex-1 bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-medium text-gray-800 outline-none resize-y" />
|
||||
<button onClick={() => setSituacoes(situacoes.filter((_, j) => j !== i))} className="mt-2 text-gray-300 hover:text-red-600"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => setSituacoes([...situacoes, ''])} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Nova situação</button>
|
||||
</div>
|
||||
{situacoes.length === 0 && <p className="text-[11px] text-gray-400 italic">Nenhuma situação cadastrada. A IA seguirá apenas o roteiro padrão.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// registrou ("vou confirmar e retorno"). A humana confirma (vira agendamento) ou
|
||||
// recusa. Consome /api/nw/agenda-config/clinica/:id/pedidos*.
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, CalendarClock, Check, Ban, Loader2, Phone, User } from 'lucide-react';
|
||||
import { X, CalendarClock, Check, Ban, Loader2, Phone, User, HelpCircle } from 'lucide-react';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
@@ -50,6 +50,11 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
|
||||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/recusar`, { method: 'POST' }); await carregar(); onChange?.(); }
|
||||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||||
};
|
||||
const resolver = async (p: any) => {
|
||||
setBusy(p.id); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/resolver`, { method: 'POST' }); await carregar(); onChange?.(); }
|
||||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
@@ -73,7 +78,28 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
|
||||
<div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div>
|
||||
) : pedidos.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-16">Nenhum pedido pendente. 🎉</p>
|
||||
) : pedidos.map((p) => (
|
||||
) : pedidos.map((p) => p.tipo === 'duvida' ? (
|
||||
<div key={p.id} className="border-2 border-indigo-100 rounded-2xl p-4 bg-indigo-50/40">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1.5 max-w-[75%]">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-black text-indigo-600 uppercase tracking-widest"><HelpCircle size={13} /> Dúvida p/ decidir</div>
|
||||
<div className="text-sm font-bold text-gray-800 leading-snug whitespace-pre-wrap">{p.resumo}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 font-bold"><User size={13} className="text-gray-400" /> {p.paciente_nome || 'Cliente'}
|
||||
{p.paciente_telefone && <span className="flex items-center gap-1 text-gray-400"><Phone size={12} /> {p.paciente_telefone}</span>}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => resolver(p)} disabled={busy === p.id}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 disabled:opacity-50">
|
||||
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Resolvida
|
||||
</button>
|
||||
<button onClick={() => recusar(p)} disabled={busy === p.id}
|
||||
className="bg-gray-100 hover:bg-red-50 text-gray-500 hover:text-red-600 text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
|
||||
<Ban size={13} /> Descartar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={p.id} className="border border-gray-200 rounded-2xl p-4 bg-white">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Área de PENDÊNCIAS DO DONO do workspace (só o dono vê). Engloba tudo:
|
||||
// 1) Checklist da clínica — perguntas Sim/Não que o dono responde;
|
||||
// 2) Pendências de configuração — quando a resposta não bate com o banco
|
||||
// (ex.: marcou "mais de um dentista" mas só há 1 cadastrado);
|
||||
// 3) Fila operacional — resumo dos pedidos/dúvidas aguardando a secretária humana.
|
||||
// Consome /api/nw/agenda-config/clinica/:id/{checklist,pendencias}.
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, ClipboardCheck, AlertTriangle, Check, Ban, Loader2, Save, CalendarClock, HelpCircle, ChevronRight } from 'lucide-react';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
|
||||
async function req(path: string, opts: RequestInit = {}) {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
const res = await fetch(`${BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) } });
|
||||
const t = await res.text(); const d = t ? JSON.parse(t) : {};
|
||||
if (!res.ok) throw new Error(d.error || `Erro ${res.status}`);
|
||||
return d;
|
||||
}
|
||||
|
||||
type Questao = { chave: string; pergunta: string; ajuda?: string; resposta: number | null };
|
||||
type PendConfig = { chave: string; severidade: string; titulo: string; detalhe: string };
|
||||
|
||||
export const PendenciasDono: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onOpenFila?: () => void }> = ({ isOpen, onClose, clinicaId, onOpenFila }) => {
|
||||
const [questoes, setQuestoes] = useState<Questao[]>([]);
|
||||
const [config, setConfig] = useState<PendConfig[]>([]);
|
||||
const [oper, setOper] = useState<{ horarios: number; duvidas: number; total: number }>({ horarios: 0, duvidas: 0, total: 0 });
|
||||
const [contagens, setContagens] = useState<{ dentistas: number; pacientes: number }>({ dentistas: 0, pacientes: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
|
||||
const flash = (setter: (v: string | null) => void, m: string) => { setter(m); setTimeout(() => setter(null), 3000); };
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
setLoading(true); setErro(null);
|
||||
try {
|
||||
const c = await req(`/clinica/${clinicaId}/checklist`);
|
||||
setQuestoes(c.questoes || []);
|
||||
const p = await req(`/clinica/${clinicaId}/pendencias`);
|
||||
setConfig(p.config || []);
|
||||
setOper(p.operacionais || { horarios: 0, duvidas: 0, total: 0 });
|
||||
setContagens(p.contagens || { dentistas: 0, pacientes: 0 });
|
||||
} catch (e: any) { setErro(e.message); } finally { setLoading(false); }
|
||||
}, [clinicaId]);
|
||||
|
||||
useEffect(() => { if (isOpen) carregar(); }, [isOpen, carregar]);
|
||||
|
||||
const setResposta = (chave: string, resposta: number | null) =>
|
||||
setQuestoes((qs) => qs.map((q) => q.chave === chave ? { ...q, resposta } : q));
|
||||
|
||||
const salvar = async () => {
|
||||
setSaving(true); setErro(null);
|
||||
try {
|
||||
const respostas: Record<string, number | null> = {};
|
||||
questoes.forEach((q) => { respostas[q.chave] = q.resposta; });
|
||||
await req(`/clinica/${clinicaId}/checklist`, { method: 'PUT', body: JSON.stringify({ respostas }) });
|
||||
flash(setOk, 'Checklist salvo!');
|
||||
await carregar();
|
||||
} catch (e: any) { setErro(e.message); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
const sevColor = (s: string) => s === 'alta' ? 'border-red-200 bg-red-50' : 'border-amber-200 bg-amber-50';
|
||||
const sevText = (s: string) => s === 'alta' ? 'text-red-700' : 'text-amber-700';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
|
||||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-3xl md:h-[92vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
|
||||
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-indigo-600 rounded-2xl shadow-lg shadow-indigo-200"><ClipboardCheck size={22} className="text-white" /></div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Pendências do workspace</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Config da clínica + o que a Secretária precisa que você resolva</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
|
||||
</div>
|
||||
|
||||
{(erro || ok) && <div className={`px-8 py-2 text-xs font-bold ${erro ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>{erro || ok}</div>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div>
|
||||
) : (
|
||||
<>
|
||||
{/* Fila operacional */}
|
||||
<section>
|
||||
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3">Aguardando a secretária humana</h3>
|
||||
<button onClick={() => onOpenFila?.()} disabled={!onOpenFila}
|
||||
className="w-full flex items-center justify-between gap-3 border-2 border-amber-100 bg-amber-50/50 rounded-2xl px-4 py-3.5 text-left hover:bg-amber-50 disabled:cursor-default disabled:hover:bg-amber-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarClock size={20} className="text-amber-600" />
|
||||
<div>
|
||||
<div className="text-sm font-black text-gray-800">{oper.total} pedido(s) na fila</div>
|
||||
<div className="text-[11px] text-gray-500 font-bold">{oper.horarios} horário(s) · {oper.duvidas} dúvida(s)</div>
|
||||
</div>
|
||||
</div>
|
||||
{onOpenFila && <ChevronRight size={18} className="text-amber-400" />}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Pendências de configuração */}
|
||||
<section>
|
||||
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3">Configuração a resolver</h3>
|
||||
{config.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 flex items-center gap-2 py-2"><Check size={16} className="text-emerald-500" /> Nenhuma pendência de configuração. 🎉</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{config.map((p, i) => (
|
||||
<div key={i} className={`border-2 rounded-2xl p-4 ${sevColor(p.severidade)}`}>
|
||||
<div className={`flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest ${sevText(p.severidade)}`}><AlertTriangle size={13} /> {p.severidade === 'alta' ? 'Importante' : 'Atenção'}</div>
|
||||
<div className="text-sm font-black text-gray-800 mt-1">{p.titulo}</div>
|
||||
<div className="text-[12px] text-gray-600 mt-0.5 leading-snug">{p.detalhe}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-400 mt-2">No sistema: {contagens.dentistas} dentista(s) · {contagens.pacientes} paciente(s) cadastrado(s).</p>
|
||||
</section>
|
||||
|
||||
{/* Checklist */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest">Checklist da clínica</h3>
|
||||
<button onClick={salvar} disabled={saving} className="bg-indigo-600 hover:bg-indigo-700 text-white text-[11px] font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={13} className="animate-spin" /> : <Save size={13} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 bg-gray-50 rounded-2xl p-3 mb-3 leading-relaxed">
|
||||
Responda sobre a clínica. A Secretária usa isto para saber quando pode agendar sozinha — quando algo não bate com o cadastro, ela evita agendar e encaminha ao humano, e aparece aqui em cima como pendência.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{questoes.map((q) => (
|
||||
<div key={q.chave} className="border border-gray-200 rounded-2xl p-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="text-sm font-black text-gray-800 flex items-center gap-1.5"><HelpCircle size={14} className="text-gray-300" /> {q.pergunta}</div>
|
||||
{q.ajuda && <div className="text-[11px] text-gray-400 mt-1 leading-snug">{q.ajuda}</div>}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => setResposta(q.chave, q.resposta === 1 ? null : 1)}
|
||||
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 transition-colors ${q.resposta === 1 ? 'bg-emerald-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-emerald-50'}`}>
|
||||
<Check size={13} /> Sim
|
||||
</button>
|
||||
<button onClick={() => setResposta(q.chave, q.resposta === 0 ? null : 0)}
|
||||
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 transition-colors ${q.resposta === 0 ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-red-50'}`}>
|
||||
<Ban size={13} /> Não
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
Sparkles, BookOpen, ShieldCheck, Calendar, AlertTriangle,
|
||||
CheckCircle, Clock, User, RefreshCw, MoreVertical, Power, Cpu,
|
||||
Phone, Shield, Star, Repeat, UserCheck, Building2, Stethoscope,
|
||||
Maximize2, Save, HelpCircle, ArrowLeft,
|
||||
Maximize2, Save, HelpCircle, ArrowLeft, ClipboardCheck,
|
||||
} from 'lucide-react'
|
||||
import { useSecretariaStore } from './store/secretariaStore'
|
||||
import type { SecAgent, BrainNode, SecConversation, BrainNodeType, CalendarSlot, SecNumber, NumberRole } from './services/secretariaApi'
|
||||
import { HybridBackend } from '../../services/backend.ts'
|
||||
import { PendenciasDono } from '../../components/PendenciasDono.tsx'
|
||||
import { PedidosPendentes } from '../../components/PedidosPendentes.tsx'
|
||||
import { secPowerApi } from './services/secretariaApi'
|
||||
|
||||
// ─── Thin Nav ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -25,7 +29,7 @@ const NAV_ITEMS: { id: NavTab; icon: React.ElementType; label: string }[] = [
|
||||
{ id: 'numbers', icon: Phone, label: 'Números' },
|
||||
]
|
||||
|
||||
function ThinNav({ active, onChange, onHelp, onBack }: { active: NavTab; onChange: (t: NavTab) => void; onHelp: () => void; onBack?: () => void }) {
|
||||
function ThinNav({ active, onChange, onHelp, onBack, onPendencias, pendenciasBadge, secOn, onTogglePower }: { active: NavTab; onChange: (t: NavTab) => void; onHelp: () => void; onBack?: () => void; onPendencias?: () => void; pendenciasBadge?: number; secOn?: boolean; onTogglePower?: () => void }) {
|
||||
return (
|
||||
<div className="w-[60px] h-full bg-white border-r border-gray-100 flex flex-col items-center py-4 shrink-0 z-30 gap-1">
|
||||
{onBack && (
|
||||
@@ -54,6 +58,34 @@ function ThinNav({ active, onChange, onHelp, onBack }: { active: NavTab; onChang
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Desligar/ligar a Secretária — GLOBAL, todos os chats, sem timer de retorno.
|
||||
Botão de segurança enquanto a IA amadurece; visível a todos. */}
|
||||
{onTogglePower && (
|
||||
<button
|
||||
title={secOn ? 'Desligar a Secretária (todos os chats)' : 'Secretária DESLIGADA — clique para ligar'}
|
||||
onClick={onTogglePower}
|
||||
className={`relative w-10 h-10 rounded-xl flex items-center justify-center transition-all mb-1 ${
|
||||
secOn ? 'text-emerald-600 hover:bg-emerald-50' : 'text-white bg-red-600 hover:bg-red-700 animate-pulse'
|
||||
}`}
|
||||
>
|
||||
<Power size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Pendências do dono — checklist + config + fila (só o dono do workspace) */}
|
||||
{onPendencias && (
|
||||
<button
|
||||
title="Pendências do workspace"
|
||||
onClick={onPendencias}
|
||||
className="relative w-10 h-10 rounded-xl flex items-center justify-center text-gray-500 hover:text-indigo-600 hover:bg-gray-50 transition-all mb-1"
|
||||
>
|
||||
<ClipboardCheck size={20} />
|
||||
{!!pendenciasBadge && pendenciasBadge > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 bg-indigo-600 text-white text-[9px] font-black rounded-full flex items-center justify-center">{pendenciasBadge > 99 ? '99+' : pendenciasBadge}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Ajuda — modelos de como usar e configurar a secretária */}
|
||||
<button
|
||||
title="Ajuda — como usar"
|
||||
@@ -1578,13 +1610,52 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
|
||||
const [openNode, setOpenNode] = useState<BrainNode | null>(null)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
// Pendências do dono (checklist + config + fila) — só o dono do workspace
|
||||
const clinicaId = (HybridBackend.getActiveWorkspace() as any)?.id as string | undefined
|
||||
const ehDono = ['admin', 'donoclinica', 'donoconsultorio'].includes(HybridBackend.getCurrentRole?.() || '')
|
||||
const [showPendencias, setShowPendencias] = useState(false)
|
||||
const [showFila, setShowFila] = useState(false)
|
||||
const [pendCount, setPendCount] = useState(0)
|
||||
|
||||
// Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) — todos, sem timer
|
||||
const [secOn, setSecOn] = useState(true)
|
||||
const [powerBusy, setPowerBusy] = useState(false)
|
||||
|
||||
// Boot
|
||||
useEffect(() => {
|
||||
store.loadAgents()
|
||||
store.loadCalendar()
|
||||
store.loadNumbers()
|
||||
secPowerApi.get().then((r) => setSecOn(r.enabled)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleTogglePower = 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])
|
||||
|
||||
// Contagem de pendências para o badge (dono) — atualiza ao abrir e a cada 5 min.
|
||||
useEffect(() => {
|
||||
if (!ehDono || !clinicaId) return
|
||||
let vivo = true
|
||||
const carregar = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN')
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api'
|
||||
const r = await fetch(`${API}/nw/agenda-config/clinica/${clinicaId}/pendencias`, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
||||
if (r.ok && vivo) { const d = await r.json(); setPendCount(d.total || 0) }
|
||||
} catch { /* silencioso */ }
|
||||
}
|
||||
carregar()
|
||||
const t = setInterval(carregar, 5 * 60 * 1000)
|
||||
return () => { vivo = false; clearInterval(t) }
|
||||
}, [ehDono, clinicaId, showPendencias, showFila])
|
||||
|
||||
// Sincroniza openNode quando o store atualiza o nó (ex: toggle active, node_model)
|
||||
useEffect(() => {
|
||||
if (!openNode) return
|
||||
@@ -1637,7 +1708,17 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
|
||||
<div className="flex h-full w-full bg-gray-50 overflow-hidden font-sans">
|
||||
|
||||
{/* Thin Nav */}
|
||||
<ThinNav active={activeTab} onChange={handleTabChange} onHelp={() => setShowHelp(true)} onBack={() => props.onNavigate?.('dashboard')} />
|
||||
<ThinNav active={activeTab} onChange={handleTabChange} onHelp={() => setShowHelp(true)} onBack={() => props.onNavigate?.('dashboard')}
|
||||
onPendencias={ehDono ? () => setShowPendencias(true) : undefined} pendenciasBadge={pendCount}
|
||||
secOn={secOn} onTogglePower={handleTogglePower} />
|
||||
|
||||
{ehDono && (
|
||||
<>
|
||||
<PendenciasDono isOpen={showPendencias} onClose={() => setShowPendencias(false)} clinicaId={clinicaId}
|
||||
onOpenFila={() => { setShowPendencias(false); setShowFila(true) }} />
|
||||
<PedidosPendentes isOpen={showFila} onClose={() => setShowFila(false)} clinicaId={clinicaId} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{showHelp && (
|
||||
<HelpModal
|
||||
|
||||
@@ -83,6 +83,13 @@ export interface CalendarSlot {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// ─── 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),
|
||||
}
|
||||
|
||||
// ─── Agents ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const secAgentApi = {
|
||||
|
||||
Reference in New Issue
Block a user