ca2cfa1f35
- Duplicar horário para todos os dias (botão na grade). - Janela do dentista com DOIS limites: "IA agenda livre" [inicio,fim] e "aceito confirmar também" (mais cedo/mais tarde) — colunas hora_inicio_flex/hora_fim_flex. - Zona cinza: quando o cliente pede um horário fora da janela livre, a Secretária IA NÃO agenda — registra um pedido (POST /pedido) e diz "vou confirmar e retorno". - Fila da secretária humana: tabela agenda_pedidos + endpoints (listar/confirmar/ recusar); tela PedidosPendentes (confirmar → vira agendamento real, com escolha do dentista); botão com BADGE de contagem na Agenda (poll a cada 30 min) para staff. Testado em dev: dentista configura flex; IA registra pedido de 8h30 e responde "vou verificar com a secretária"; humana confirma → agendamento criado. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
116 lines
6.8 KiB
TypeScript
116 lines
6.8 KiB
TypeScript
// Fila da secretária humana: pedidos de horário da ZONA CINZA que a Secretária IA
|
||
// 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';
|
||
|
||
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;
|
||
}
|
||
|
||
const dm = (s: string) => s ? s.split('-').reverse().join('/') : s;
|
||
|
||
export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onChange?: () => void }> = ({ isOpen, onClose, clinicaId, onChange }) => {
|
||
const [pedidos, setPedidos] = useState<any[]>([]);
|
||
const [dentistas, setDentistas] = useState<any[]>([]);
|
||
const [sel, setSel] = useState<Record<string, string>>({}); // pedido_id → dentista_id escolhido
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [erro, setErro] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const carregar = useCallback(async () => {
|
||
if (!clinicaId) return;
|
||
setLoading(true); setErro(null);
|
||
try {
|
||
const p = await req(`/clinica/${clinicaId}/pedidos?status=pendente`);
|
||
setPedidos(p.pedidos || []);
|
||
const d = await req(`/clinica/${clinicaId}/dentistas`);
|
||
setDentistas(d.dentistas || []);
|
||
} catch (e: any) { setErro(e.message); } finally { setLoading(false); }
|
||
}, [clinicaId]);
|
||
|
||
useEffect(() => { if (isOpen) carregar(); }, [isOpen, carregar]);
|
||
|
||
const confirmar = async (p: any) => {
|
||
const dentistaId = p.dentista_id || sel[p.id];
|
||
if (!dentistaId) { setErro('Escolha o dentista para confirmar.'); return; }
|
||
setBusy(p.id); setErro(null);
|
||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/confirmar`, { method: 'POST', body: JSON.stringify({ dentista_id: dentistaId }) }); await carregar(); onChange?.(); }
|
||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||
};
|
||
const recusar = async (p: any) => {
|
||
setBusy(p.id); setErro(null);
|
||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/recusar`, { method: 'POST' }); await carregar(); onChange?.(); }
|
||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||
};
|
||
|
||
if (!isOpen) return null;
|
||
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-[90vh] 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-amber-500 rounded-2xl shadow-lg shadow-amber-200"><CalendarClock size={22} className="text-white" /></div>
|
||
<div>
|
||
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Pedidos aguardando confirmação</h2>
|
||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Horários fora do padrão que a Secretária IA encaminhou</p>
|
||
</div>
|
||
</div>
|
||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
|
||
</div>
|
||
|
||
{erro && <div className="px-8 py-2 text-xs font-bold bg-red-50 text-red-600">{erro}</div>}
|
||
|
||
<div className="flex-1 overflow-y-auto p-6 space-y-3">
|
||
{loading ? (
|
||
<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) => (
|
||
<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">
|
||
<div className="text-sm font-black text-gray-800">
|
||
{dm(p.data)} às {p.hora_inicio}{p.hora_fim ? `–${p.hora_fim}` : ''}
|
||
</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>
|
||
{p.procedimento && <div className="text-[11px] text-gray-500">{p.procedimento}</div>}
|
||
</div>
|
||
<div className="flex flex-col items-end gap-2">
|
||
{p.dentista_id ? (
|
||
<span className="text-[11px] font-bold text-teal-600">{p.dentista_nome}</span>
|
||
) : (
|
||
<select value={sel[p.id] || ''} onChange={(e) => setSel({ ...sel, [p.id]: e.target.value })}
|
||
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-xs font-bold text-gray-800 outline-none">
|
||
<option value="">Escolher dentista…</option>
|
||
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||
</select>
|
||
)}
|
||
<div className="flex gap-2">
|
||
<button onClick={() => confirmar(p)} disabled={busy === p.id}
|
||
className="bg-emerald-600 hover:bg-emerald-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} />} Confirmar
|
||
</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} /> Recusar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|