feat(agenda): pedidos (dia/horário), check-in com confirmação de dados
- PedidosPendentes: ajuste de DIA (mini-calendário) além do horário; abrir chat. - ConfirmarDadosPaciente: modal no check-in (Atender) p/ pacientes cadastrados pela Secretária — confere nome/nascimento/CPF/telefone (número próprio) + grupo familiar, valida e marca confirmado. - AgendaView: dispara o modal no Atender; backend.ts getPacienteById/confirmar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// Feature C — modal de confirmação de dados no CHECK-IN. Abre quando a recepção
|
||||
// clica "Atender" e o paciente veio da Secretária (dados_confirmados=false). A recepção
|
||||
// confere/ajusta Nome, Data de nascimento, CPF e Telefone (o número PRÓPRIO deste
|
||||
// paciente — um dependente pode ter ganhado WhatsApp próprio) e vê o grupo familiar.
|
||||
// Salva na base de Pacientes e marca dados_confirmados=true (o modal para de abrir).
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader2, Check, Users, ShieldCheck } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
// Normaliza p/ YYYY-MM-DD (aceita DD/MM/AAAA); senão devolve vazio p/ o <input type=date>.
|
||||
const paraInputDate = (s?: string | null): string => {
|
||||
const v = String(s || '').trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
|
||||
const m = v.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/);
|
||||
if (m) return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`;
|
||||
return '';
|
||||
};
|
||||
|
||||
interface Props {
|
||||
pacienteId: string | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirmed: () => void; // segue o fluxo (ex.: prosseguir com o Atender)
|
||||
}
|
||||
|
||||
export const ConfirmarDadosPaciente: React.FC<Props> = ({ pacienteId, isOpen, onClose, onConfirmed }) => {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [nome, setNome] = useState('');
|
||||
const [nasc, setNasc] = useState('');
|
||||
const [cpf, setCpf] = useState('');
|
||||
const [tel, setTel] = useState('');
|
||||
const [familia, setFamilia] = useState<{ grupo: any; membros: any[] }>({ grupo: null, membros: [] });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !pacienteId) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
HybridBackend.getPacienteById(pacienteId),
|
||||
HybridBackend.getFamiliaPaciente(pacienteId).catch(() => ({ grupo: null, membros: [] })),
|
||||
]).then(([p, fam]) => {
|
||||
if (!alive) return;
|
||||
setNome(p?.nome || ''); setNasc(paraInputDate(p?.datanascimento)); setCpf(p?.cpf || ''); setTel(p?.telefone || '');
|
||||
setFamilia(fam as any);
|
||||
}).catch(() => { if (alive) toast.error('Não foi possível carregar o paciente.'); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [isOpen, pacienteId]);
|
||||
|
||||
if (!isOpen || !pacienteId) return null;
|
||||
|
||||
const salvar = async () => {
|
||||
if (!nome.trim()) { toast.error('Informe o nome.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const r = await HybridBackend.confirmarDadosPaciente(pacienteId, {
|
||||
nome: nome.trim(), cpf: cpf.trim() || undefined, telefone: tel.trim() || undefined, data_nascimento: nasc || undefined,
|
||||
});
|
||||
if (!r.ok) { toast.error(r.mensagem || 'Dados inválidos.'); setSaving(false); return; }
|
||||
toast.success('Dados confirmados!');
|
||||
onConfirmed(); onClose();
|
||||
} catch { toast.error('Erro ao confirmar.'); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] bg-black/60 backdrop-blur-md flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white w-full max-w-md rounded-[1.75rem] shadow-2xl border border-gray-100 flex flex-col max-h-[92vh] 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-teal-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-teal-500 rounded-2xl shadow-lg shadow-teal-200"><ShieldCheck size={20} className="text-white" /></div>
|
||||
<div>
|
||||
<h2 className="text-base font-black text-gray-900 uppercase tracking-tighter leading-none">Confirme os dados</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-1">Cadastro veio da Secretária IA · confira no check-in</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin text-gray-400" /></div>
|
||||
) : (
|
||||
<div className="p-6 space-y-4 overflow-y-auto">
|
||||
<Campo label="Nome completo"><input value={nome} onChange={e => setNome(e.target.value)} className={inpCls} /></Campo>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Data de nascimento"><input type="date" value={nasc} onChange={e => setNasc(e.target.value)} className={inpCls} /></Campo>
|
||||
<Campo label="CPF"><input value={cpf} onChange={e => setCpf(e.target.value)} placeholder="000.000.000-00" inputMode="numeric" className={inpCls} /></Campo>
|
||||
</div>
|
||||
<Campo label="Telefone deste paciente">
|
||||
<input value={tel} onChange={e => setTel(e.target.value)} placeholder="(67) 90000-0000" inputMode="tel" className={inpCls} />
|
||||
<p className="text-[10px] text-gray-400 mt-1">Se for dependente com WhatsApp próprio, ajuste o número dele aqui.</p>
|
||||
</Campo>
|
||||
|
||||
{/* Grupo familiar (contexto) */}
|
||||
{(familia.grupo || familia.membros.length > 0) && (
|
||||
<div className="rounded-2xl bg-gray-50 p-3">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5 mb-2">
|
||||
<Users size={12} /> Grupo familiar{familia.grupo?.nome ? ` · ${familia.grupo.nome}` : ''}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{familia.membros.map((m: any) => (
|
||||
<div key={m.id} className="flex items-center gap-2 text-xs">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${String(m.relacao || m.grupofamiliarrelacao || '').toLowerCase() === 'titular' ? 'bg-teal-500' : 'bg-gray-300'}`} />
|
||||
<span className="font-bold text-gray-700 truncate">{m.nome}</span>
|
||||
{String(m.relacao || m.grupofamiliarrelacao || '').toLowerCase() === 'titular' && <span className="text-[9px] font-black uppercase text-teal-600">titular</span>}
|
||||
</div>
|
||||
))}
|
||||
{familia.membros.length === 0 && <p className="text-[11px] text-gray-400">Sem outros membros vinculados.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-50 flex gap-2">
|
||||
<button onClick={onClose} className="flex-1 py-2.5 rounded-xl text-sm font-black uppercase tracking-wide text-gray-500 hover:bg-gray-100">Depois</button>
|
||||
<button onClick={salvar} disabled={saving || loading}
|
||||
className="flex-[2] py-2.5 rounded-xl text-sm font-black uppercase tracking-wide bg-teal-600 hover:bg-teal-700 text-white flex items-center justify-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />} Confirmar e continuar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const inpCls = 'w-full bg-gray-50 border-2 border-transparent focus:border-teal-500 rounded-xl px-3 py-2 text-sm outline-none';
|
||||
const Campo: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{label}</label>
|
||||
{children}
|
||||
</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, HelpCircle } from 'lucide-react';
|
||||
import { X, CalendarClock, Check, Ban, Loader2, Phone, User, HelpCircle, MessageCircle, ChevronDown, ChevronLeft, ChevronRight, Clock, Sparkles, CalendarDays } from 'lucide-react';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
@@ -17,10 +17,85 @@ async function req(path: string, opts: RequestInit = {}) {
|
||||
|
||||
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 }) => {
|
||||
// Grade de horários (07:00–20:00, passo 30min) para AJUSTAR o horário na confirmação.
|
||||
// Garante que o horário sugerido pela IA esteja sempre presente (mesmo fora do passo).
|
||||
function gerarSlots(sugerido?: string): string[] {
|
||||
const set = new Set<string>();
|
||||
for (let m = 7 * 60; m <= 20 * 60; m += 30) {
|
||||
set.add(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`);
|
||||
}
|
||||
const s = (sugerido || '').slice(0, 5);
|
||||
if (/^\d{2}:\d{2}$/.test(s)) set.add(s);
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
// ── Mini-calendário (troca de DIA na confirmação) ────────────────────────────
|
||||
const WD = ['D', 'S', 'T', 'Q', 'Q', 'S', 'S'];
|
||||
const MESES = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'];
|
||||
const toISO = (y: number, m: number, d: number) => `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
const hojeISO = () => { const n = new Date(); return toISO(n.getFullYear(), n.getMonth(), n.getDate()); };
|
||||
|
||||
// Calendário mensal compacto, sem libs. Dia sugerido pela IA marcado (bolinha âmbar),
|
||||
// selecionado em verde, hoje com anel, passado desabilitado. Navega por mês.
|
||||
const MiniCalendar: React.FC<{ value: string; suggested: string; onSelect: (iso: string) => void }> = ({ value, suggested, onSelect }) => {
|
||||
const base = (value || suggested || hojeISO()).slice(0, 10);
|
||||
const [view, setView] = useState<{ y: number; m: number }>({ y: Number(base.slice(0, 4)), m: Number(base.slice(5, 7)) - 1 });
|
||||
const hoje = hojeISO();
|
||||
const firstDow = new Date(view.y, view.m, 1).getDay();
|
||||
const nDias = new Date(view.y, view.m + 1, 0).getDate();
|
||||
const cells: (number | null)[] = [...Array(firstDow).fill(null), ...Array.from({ length: nDias }, (_, i) => i + 1)];
|
||||
const passo = (delta: number) => setView(v => { const m = v.m + delta; return m < 0 ? { y: v.y - 1, m: 11 } : m > 11 ? { y: v.y + 1, m: 0 } : { y: v.y, m }; });
|
||||
return (
|
||||
<div className="w-64 max-w-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button type="button" onClick={() => passo(-1)} className="p-1 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"><ChevronLeft size={16} /></button>
|
||||
<span className="text-xs font-black uppercase tracking-wide text-gray-700">{MESES[view.m]} {view.y}</span>
|
||||
<button type="button" onClick={() => passo(1)} className="p-1 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"><ChevronRight size={16} /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 text-center mb-1">
|
||||
{WD.map((w, i) => <span key={i} className="text-[10px] font-black text-gray-300 uppercase">{w}</span>)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{cells.map((d, i) => {
|
||||
if (d === null) return <span key={i} />;
|
||||
const iso = toISO(view.y, view.m, d);
|
||||
const past = iso < hoje;
|
||||
const on = iso === value;
|
||||
const ia = iso === suggested;
|
||||
const isHoje = iso === hoje;
|
||||
return (
|
||||
<button key={i} type="button" disabled={past} onClick={() => onSelect(iso)}
|
||||
title={ia ? 'Sugerido pela IA' : undefined}
|
||||
className={`relative h-8 rounded-lg text-xs font-bold tabular-nums transition-all ${
|
||||
past ? 'text-gray-300 cursor-not-allowed'
|
||||
: on ? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'text-gray-700 hover:bg-emerald-50 hover:text-emerald-700'} ${isHoje && !on ? 'ring-1 ring-inset ring-teal-300' : ''}`}>
|
||||
{d}
|
||||
{ia && !on && <span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-amber-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PedidosPendentes: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
clinicaId?: string;
|
||||
onChange?: () => void;
|
||||
// Abre o chat do WhatsApp do solicitante (drawer da agenda) — telefone direto,
|
||||
// funciona mesmo para lead sem cadastro.
|
||||
onOpenChat?: (t: { pacienteId?: string; pacienteNome?: string; phone?: string }) => void;
|
||||
}> = ({ isOpen, onClose, clinicaId, onChange, onOpenChat }) => {
|
||||
const [pedidos, setPedidos] = useState<any[]>([]);
|
||||
const [dentistas, setDentistas] = useState<any[]>([]);
|
||||
const [sel, setSel] = useState<Record<string, string>>({}); // pedido_id → dentista_id escolhido
|
||||
const [horaSel, setHoraSel] = useState<Record<string, string>>({}); // pedido_id → HH:MM ajustado
|
||||
const [horaOpen, setHoraOpen] = useState<Record<string, boolean>>({}); // pedido_id → grade aberta
|
||||
const [dataSel, setDataSel] = useState<Record<string, string>>({}); // pedido_id → YYYY-MM-DD ajustado
|
||||
const [dataOpen, setDataOpen] = useState<Record<string, boolean>>({}); // pedido_id → calendário aberto
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -41,8 +116,11 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
|
||||
const confirmar = async (p: any) => {
|
||||
const dentistaId = p.dentista_id || sel[p.id];
|
||||
if (!dentistaId) { setErro('Escolha o dentista para confirmar.'); return; }
|
||||
// Horário e DIA: os ajustados pela humana (se mexeu) ou os que a IA fixou.
|
||||
const hora = horaSel[p.id] || String(p.hora_inicio || '').slice(0, 5);
|
||||
const data = dataSel[p.id] || String(p.data || '').slice(0, 10);
|
||||
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?.(); }
|
||||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/confirmar`, { method: 'POST', body: JSON.stringify({ dentista_id: dentistaId, hora_inicio: hora, data }) }); await carregar(); onChange?.(); }
|
||||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||||
};
|
||||
const recusar = async (p: any) => {
|
||||
@@ -88,6 +166,12 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
|
||||
{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">
|
||||
{onOpenChat && p.paciente_telefone && (
|
||||
<button onClick={() => onOpenChat({ pacienteNome: p.paciente_nome, phone: p.paciente_telefone })}
|
||||
className="bg-[#25D366]/10 hover:bg-[#25D366]/20 text-[#128C7E] text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
|
||||
<MessageCircle size={13} /> Abrir chat
|
||||
</button>
|
||||
)}
|
||||
<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
|
||||
@@ -103,9 +187,46 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
|
||||
<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>
|
||||
{/* Dia AJUSTÁVEL: a IA fixou p.data; a humana troca se o paciente quiser outro dia. */}
|
||||
{(() => {
|
||||
const sugerido = String(p.data || '').slice(0, 10);
|
||||
const atual = dataSel[p.id] || sugerido;
|
||||
const mudou = atual !== sugerido;
|
||||
return (
|
||||
<button type="button" onClick={() => setDataOpen({ ...dataOpen, [p.id]: !dataOpen[p.id] })}
|
||||
title="Trocar o dia combinado com o paciente"
|
||||
className="flex items-center gap-1.5 text-sm font-black text-gray-800 hover:text-teal-700 transition-colors">
|
||||
<CalendarDays size={14} className="text-teal-600" />
|
||||
<span>{dm(atual)}</span>
|
||||
{mudou ? (
|
||||
<span className="text-[9px] font-black uppercase text-teal-700 bg-teal-50 px-1.5 py-0.5 rounded-md">trocado</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-black uppercase text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-md flex items-center gap-0.5"><Sparkles size={9} /> IA</span>
|
||||
)}
|
||||
<ChevronDown size={14} className={`text-gray-400 transition-transform ${dataOpen[p.id] ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{/* Horário AJUSTÁVEL: a IA fixou p.hora_inicio; a humana troca pelo combinado com o paciente. */}
|
||||
{(() => {
|
||||
const sugerida = String(p.hora_inicio).slice(0, 5);
|
||||
const atual = horaSel[p.id] || sugerida;
|
||||
const mudou = atual !== sugerida;
|
||||
return (
|
||||
<button type="button" onClick={() => setHoraOpen({ ...horaOpen, [p.id]: !horaOpen[p.id] })}
|
||||
title="Ajustar o horário combinado com o paciente"
|
||||
className="flex items-center gap-1.5 text-sm font-black text-gray-800 hover:text-teal-700 transition-colors">
|
||||
<Clock size={14} className="text-teal-600" />
|
||||
<span>{atual}{p.hora_fim && !mudou ? `–${String(p.hora_fim).slice(0, 5)}` : ''}</span>
|
||||
{mudou ? (
|
||||
<span className="text-[9px] font-black uppercase text-teal-700 bg-teal-50 px-1.5 py-0.5 rounded-md">ajustado</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-black uppercase text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-md flex items-center gap-0.5"><Sparkles size={9} /> IA</span>
|
||||
)}
|
||||
<ChevronDown size={14} className={`text-gray-400 transition-transform ${horaOpen[p.id] ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<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>}
|
||||
@@ -114,15 +235,26 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
|
||||
{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 flex-col items-end gap-0.5">
|
||||
<select value={sel[p.id] || ''} onChange={(e) => setSel({ ...sel, [p.id]: e.target.value })}
|
||||
className={`bg-gray-50 border-2 rounded-xl px-2 py-1.5 text-xs font-bold text-gray-800 outline-none transition-colors ${
|
||||
sel[p.id] ? 'border-transparent focus:border-teal-600' : 'border-amber-400 ring-2 ring-amber-100'}`}>
|
||||
<option value="">Escolher dentista…</option>
|
||||
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
{!sel[p.id] && <span className="text-[9px] font-black uppercase tracking-wide text-amber-600">Escolha o dentista</span>}
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
{onOpenChat && p.paciente_telefone && (
|
||||
<button onClick={() => onOpenChat({ pacienteNome: p.paciente_nome, phone: p.paciente_telefone })}
|
||||
className="bg-[#25D366]/10 hover:bg-[#25D366]/20 text-[#128C7E] text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
|
||||
<MessageCircle size={13} /> Abrir chat
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => confirmar(p)} disabled={busy === p.id || (!p.dentista_id && !sel[p.id])}
|
||||
title={!p.dentista_id && !sel[p.id] ? 'Escolha o dentista antes de confirmar' : undefined}
|
||||
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 disabled:cursor-not-allowed">
|
||||
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Confirmar
|
||||
</button>
|
||||
<button onClick={() => recusar(p)} disabled={busy === p.id}
|
||||
@@ -132,6 +264,62 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendário — escondido por padrão, abre pelo chevron do dia. Troca o dia
|
||||
combinado com o paciente; o sugerido pela IA vem marcado (bolinha âmbar). */}
|
||||
{dataOpen[p.id] && (() => {
|
||||
const sugerido = String(p.data || '').slice(0, 10);
|
||||
const atual = dataSel[p.id] || sugerido;
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<div className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1.5">
|
||||
<CalendarDays size={12} /> Dia combinado com o paciente
|
||||
</div>
|
||||
<MiniCalendar value={atual} suggested={sugerido} onSelect={(iso) => setDataSel({ ...dataSel, [p.id]: iso })} />
|
||||
{dataSel[p.id] && dataSel[p.id] !== sugerido && (
|
||||
<button type="button" onClick={() => setDataSel({ ...dataSel, [p.id]: sugerido })}
|
||||
className="mt-2 text-[10px] font-bold text-gray-400 hover:text-gray-600 uppercase tracking-wide">
|
||||
↺ Voltar ao sugerido ({dm(sugerido)})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Grade de horários — escondida por padrão, abre pelo chevron. Escolha rápida
|
||||
do horário decidido com o paciente; o sugerido pela IA vem marcado. */}
|
||||
{horaOpen[p.id] && (() => {
|
||||
const sugerida = String(p.hora_inicio).slice(0, 5);
|
||||
const atual = horaSel[p.id] || sugerida;
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<div className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1.5">
|
||||
<Clock size={12} /> Horário combinado com o paciente
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{gerarSlots(sugerida).map((h) => {
|
||||
const on = h === atual;
|
||||
const ia = h === sugerida;
|
||||
return (
|
||||
<button key={h} type="button" onClick={() => setHoraSel({ ...horaSel, [p.id]: h })}
|
||||
className={`relative px-2.5 py-1.5 rounded-lg text-xs font-black tabular-nums transition-all border ${
|
||||
on ? 'bg-emerald-600 text-white border-emerald-600 shadow-sm'
|
||||
: 'bg-gray-50 text-gray-600 border-transparent hover:border-emerald-300 hover:bg-emerald-50'}`}>
|
||||
{h}
|
||||
{ia && !on && <span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-amber-400" title="Sugerido pela IA" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{horaSel[p.id] && horaSel[p.id] !== sugerida && (
|
||||
<button type="button" onClick={() => setHoraSel({ ...horaSel, [p.id]: sugerida })}
|
||||
className="mt-2 text-[10px] font-bold text-gray-400 hover:text-gray-600 uppercase tracking-wide">
|
||||
↺ Voltar ao sugerido ({sugerida})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1496,6 +1496,18 @@ export const HybridBackend = {
|
||||
return { grupo: j.grupo || null, membros: j.membros || [] };
|
||||
},
|
||||
|
||||
// Paciente único (modal de confirmação no check-in — Feature C).
|
||||
getPacienteById: async (id: string): Promise<any | null> => {
|
||||
const res = await apiFetch(`${API_URL}/pacientes/${id}`, {});
|
||||
const j = await res.json().catch(() => ({} as any));
|
||||
return j.paciente || null;
|
||||
},
|
||||
// Confirma os dados no check-in: valida server-side; { ok:false, mensagem } se inválido.
|
||||
confirmarDadosPaciente: async (id: string, payload: { nome?: string; cpf?: string; telefone?: string; data_nascimento?: string }): Promise<{ ok: boolean; mensagem?: string; campo?: string }> => {
|
||||
const res = await apiFetch(`${API_URL}/pacientes/${id}/confirmar-dados`, { method: 'POST', body: JSON.stringify(payload) });
|
||||
return await res.json().catch(() => ({ ok: false, mensagem: 'Falha ao confirmar.' }));
|
||||
},
|
||||
|
||||
// ── NewWhats: ownership & autorizações de sessão de WhatsApp ──────────────
|
||||
// Dono do workspace tem poder total; delega re-scan/exclusão por sessão a outras contas.
|
||||
getWaSessionAuthz: async (instanceId: string): Promise<{
|
||||
@@ -1523,13 +1535,15 @@ export const HybridBackend = {
|
||||
// Fail-open: se o endpoint estiver indisponível, não esconde nada (backend é a autoridade).
|
||||
getWaAccess: async (): Promise<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean; signature?: string | null }> => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const open = { isOwner: false, inbox: true, secretaria: true, delete_msg: true, signature: null };
|
||||
if (!clinicaId) return open;
|
||||
// Fail-CLOSED: sem clínica resolvida ou em erro, NEGA acesso — nunca vaza as áreas
|
||||
// do WhatsApp (Inbox/Secretária) por falha transitória. Só libera com resposta OK.
|
||||
const closed = { isOwner: false, inbox: false, secretaria: false, delete_msg: false, signature: null };
|
||||
if (!clinicaId) return closed;
|
||||
try {
|
||||
const res = await apiFetch(`${API_URL}/nw/access?clinicaId=${encodeURIComponent(clinicaId)}`, {});
|
||||
if (!res.ok) return open;
|
||||
if (!res.ok) return closed;
|
||||
return await res.json();
|
||||
} catch { return open; }
|
||||
} catch { return closed; }
|
||||
},
|
||||
|
||||
setWaSessionAuthz: async (instanceId: string, usuarioId: string, perms: WaPerms): Promise<{ ok: boolean; error?: string }> => {
|
||||
|
||||
@@ -3,12 +3,13 @@ import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { fmtDataHora } from '../utils/appTime.ts';
|
||||
import { fmtDataHora, toCalendarNaive } from '../utils/appTime.ts';
|
||||
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
|
||||
import { HorariosConfig } from '../components/HorariosConfig.tsx';
|
||||
import { PedidosPendentes } from '../components/PedidosPendentes.tsx';
|
||||
import { AgendaDetailModal, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx';
|
||||
import { ConfirmarDadosPaciente } from '../components/ConfirmarDadosPaciente.tsx';
|
||||
import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx';
|
||||
import { AgendaPresence } from '../components/AgendaPresence.tsx';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
@@ -376,7 +377,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [whatsTarget, setWhatsTarget] = useState<{ pacienteId: string; pacienteNome: string } | null>(null);
|
||||
const [whatsTarget, setWhatsTarget] = useState<{ pacienteId?: string; pacienteNome?: string; phone?: string } | null>(null);
|
||||
const [confirmarDadosId, setConfirmarDadosId] = useState<string | null>(null); // Feature C: paciente a confirmar no check-in
|
||||
const [pendingAtender, setPendingAtender] = useState(false); // segue o Atender após confirmar
|
||||
const [interesses, setInteresses] = useState<any[]>([]);
|
||||
const [showInteresses, setShowInteresses] = useState(false);
|
||||
const [pendencias, setPendencias] = useState<any[]>([]);
|
||||
@@ -485,8 +488,11 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
return {
|
||||
id: ag.id,
|
||||
title: `${nomePac} - ${ag.procedimento}`,
|
||||
start: ag.start,
|
||||
end: ag.end,
|
||||
// Wall-clock naive no fuso canônico: o FullCalendar (sem plugin luxon)
|
||||
// ignora o prop timeZone nomeado e usaria o fuso do navegador — passando
|
||||
// naive, exibe o horário literal (mesmo que o card) em qualquer navegador.
|
||||
start: toCalendarNaive(ag.start),
|
||||
end: toCalendarNaive(ag.end),
|
||||
backgroundColor: cor,
|
||||
borderColor: cor,
|
||||
extendedProps: { ...ag, pacienteNome: nomePac, isGoogle: false, dentistaNome: dentist?.nome }
|
||||
@@ -598,7 +604,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
const currentUserEmail = HybridBackend.getCurrentUser();
|
||||
const gtoStore = useGTOStore();
|
||||
|
||||
const handleAtender = async () => {
|
||||
// Prossegue o atendimento (Lançar GTO). Separado p/ ser chamado após a confirmação
|
||||
// de dados (Feature C), quando ela é necessária.
|
||||
const prosseguirAtender = async () => {
|
||||
if (!selectedAppointment || !onNavigate) return;
|
||||
try {
|
||||
const result = await HybridBackend.getPacientes(selectedAppointment.pacienteNome);
|
||||
@@ -615,6 +623,24 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
onNavigate('lancar-gto');
|
||||
};
|
||||
|
||||
// Feature C: ao Atender (check-in), se o paciente veio da Secretária e ainda NÃO
|
||||
// teve os dados confirmados, abre o modal de confirmação ANTES de prosseguir.
|
||||
const handleAtender = async () => {
|
||||
if (!selectedAppointment || !onNavigate) return;
|
||||
const pid = (selectedAppointment as any).pacienteid || (selectedAppointment as any).pacienteId;
|
||||
if (pid) {
|
||||
try {
|
||||
const pac = await HybridBackend.getPacienteById(pid);
|
||||
if (pac && pac.dados_confirmados === false) {
|
||||
setConfirmarDadosId(pid);
|
||||
setPendingAtender(true); // após confirmar, segue o atender
|
||||
return;
|
||||
}
|
||||
} catch { /* falha ao checar → segue normal */ }
|
||||
}
|
||||
await prosseguirAtender();
|
||||
};
|
||||
|
||||
const filteredDentists = dentists?.filter(d => {
|
||||
if (currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') return true;
|
||||
if (currentRole === 'dentista') return d.email === currentUserEmail;
|
||||
@@ -750,7 +776,11 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
<FullCalendar
|
||||
key={isMobile ? 'mobile' : 'desktop'}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
timeZone="America/Sao_Paulo"
|
||||
/* Sem o plugin luxon, um timeZone NOMEADO ("America/Sao_Paulo") é ignorado
|
||||
e o FullCalendar usa o fuso do navegador. Por isso os eventos entram como
|
||||
wall-clock naive (toCalendarNaive) e aqui deixamos 'local': o horário é
|
||||
exibido literal (mesmo do card), sem depender do fuso do navegador. */
|
||||
timeZone="local"
|
||||
initialView={isMobile ? 'timeGridDay' : 'timeGridWeek'}
|
||||
headerToolbar={isMobile
|
||||
? { left: 'prev,next', center: 'title', right: 'today' }
|
||||
@@ -815,9 +845,22 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
onClose={() => setWhatsTarget(null)}
|
||||
pacienteId={whatsTarget?.pacienteId}
|
||||
pacienteNome={whatsTarget?.pacienteNome}
|
||||
phone={whatsTarget?.phone}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
|
||||
{/* Feature C: confirmação de dados no check-in (paciente cadastrado pela Secretária) */}
|
||||
<ConfirmarDadosPaciente
|
||||
isOpen={!!confirmarDadosId}
|
||||
pacienteId={confirmarDadosId}
|
||||
onClose={() => { setConfirmarDadosId(null); setPendingAtender(false); }}
|
||||
onConfirmed={() => {
|
||||
setConfirmarDadosId(null);
|
||||
if (pendingAtender) { setPendingAtender(false); prosseguirAtender(); }
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Lista "A REAGENDAR" — pendências de falta/cancelamento/remarcação */}
|
||||
{showPendencias && (
|
||||
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowPendencias(false)}>
|
||||
@@ -926,6 +969,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
<AgendaSettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} dentists={dentists} />
|
||||
<HorariosConfig isOpen={showMeusHorarios} onClose={() => setShowMeusHorarios(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id} initialTab="dentistas" soDentista />
|
||||
<PedidosPendentes isOpen={showPedidos} onClose={() => setShowPedidos(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id}
|
||||
onOpenChat={(t) => setWhatsTarget(t)}
|
||||
onChange={() => { const cid = (HybridBackend.getActiveWorkspace() as any)?.id; const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); if (cid) fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }).then(r => r.ok ? r.json() : { total: 0 }).then(j => setPedidosCount(j.total || 0)).catch(() => {}); }} />
|
||||
|
||||
{showInteresses && (
|
||||
|
||||
Reference in New Issue
Block a user