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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user