feat(gto+agenda): busca de paciente por modal + botão ATENDER na agenda

- LancarGTO: substitui <select> de beneficiário por botão que abre modal de busca
  com filtro em tempo real por nome/CPF; avatar com inicial do paciente
- LancarGTO store: adiciona pendingSearch para receber paciente pré-selecionado
  de outra view; auto-seleciona por correspondência exata ou abre modal com
  o nome pré-preenchido
- AgendaView: adiciona botão "ATENDER PACIENTE" no painel de detalhes do
  agendamento (visível para todos os roles exceto paciente); ao clicar define
  o pendingSearch e navega para /lancar-gto com o paciente do horário
- App.tsx: passa onNavigate para AgendaView; adiciona lancar-gto às permissões
  do role dentista

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-05-14 20:53:48 +02:00
parent a47f225bf9
commit 36c4435787
3 changed files with 133 additions and 16 deletions
+2 -2
View File
@@ -130,7 +130,7 @@ const App: React.FC = () => {
const permissions: Record<string, ViewKey[]> = {
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
dentista: ['dashboard', 'pacientes', 'agenda', 'ortodontia', 'tratamentos', 'notificacoes', 'clinicas', 'configuracoes', 'contratos'],
dentista: ['dashboard', 'pacientes', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'contratos'],
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'contratos'],
};
@@ -218,7 +218,7 @@ const App: React.FC = () => {
case 'leads': return <LeadsView />;
case 'public': return <PublicContactForm />;
case 'pacientes': return <PatientsView />;
case 'agenda': return <AgendaView />;
case 'agenda': return <AgendaView onNavigate={handleNavigate} />;
case 'ortodontia': return <OrthoView />;
case 'financeiro': return <FinanceiroView />;
case 'dentistas': return <DentistasView />;
+19 -2
View File
@@ -8,6 +8,7 @@ import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import { HybridBackend } from '../services/backend.ts';
import { Dentista, Agendamento, Paciente, DentistaHorario } from '../types.ts';
import { useGTOStore } from './LancarGTO.tsx';
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
@@ -263,7 +264,7 @@ const AppointmentModal: React.FC<{
);
};
export const AgendaView: React.FC = () => {
export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({ onNavigate }) => {
const { data: agendamentos, refresh } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
const { data: dentists, refresh: refreshDentists } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend<any[]>(HybridBackend.getGoogleEvents);
@@ -373,6 +374,14 @@ export const AgendaView: React.FC = () => {
const currentRole = HybridBackend.getCurrentRole();
const currentUserEmail = HybridBackend.getCurrentUser();
const gtoStore = useGTOStore();
const handleAtender = () => {
if (!selectedAppointment || !onNavigate) return;
gtoStore.setPendingSearch(selectedAppointment.pacienteNome);
setShowDetails(false);
onNavigate('lancar-gto');
};
const filteredDentists = dentists?.filter(d => {
if (currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') return true;
@@ -537,8 +546,16 @@ export const AgendaView: React.FC = () => {
</div>
</div>
{/* Footer: Reagendar */}
{/* Footer: Ações */}
<div className="px-10 py-8 bg-gray-50/50 border-t border-gray-100 flex gap-4">
{onNavigate && currentRole !== 'paciente' && (
<button
onClick={handleAtender}
className="flex-1 py-4 bg-emerald-600 text-white rounded-2xl text-xs font-black uppercase tracking-widest hover:bg-emerald-700 hover:shadow-xl hover:shadow-emerald-200 transition-all shadow-lg shadow-emerald-100"
>
ATENDER PACIENTE
</button>
)}
<button
onClick={() => { setShowDetails(false); setIsModalOpen(true); }}
className="flex-1 py-4 bg-blue-600 text-white rounded-2xl text-xs font-black uppercase tracking-widest hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-200 transition-all shadow-lg shadow-blue-100"
+112 -12
View File
@@ -8,7 +8,9 @@ import {
PlusCircle,
Camera,
Stethoscope,
ChevronRight
ChevronRight,
Search,
X
} from 'lucide-react';
import { PageHeader } from '../components/PageHeader.tsx';
import { HybridBackend } from '../services/backend.ts';
@@ -37,12 +39,14 @@ interface GTOStore {
dentista: Dentista | null;
credenciada: string;
items: GTOItem[];
pendingSearch: string;
setPaciente: (p: Paciente | null) => void;
setDentista: (d: Dentista | null) => void;
setCredenciada: (c: string) => void;
addItem: (item: GTOItem) => void;
removeItem: (id: string) => void;
clear: () => void;
setPendingSearch: (s: string) => void;
}
const useGTOStore = create<GTOStore>((set) => ({
@@ -50,6 +54,7 @@ const useGTOStore = create<GTOStore>((set) => ({
dentista: null,
credenciada: 'CASSEMS - SEDE CENTRAL',
items: [],
pendingSearch: '',
setPaciente: (paciente) => set({ paciente }),
setDentista: (dentista) => set({ dentista }),
setCredenciada: (credenciada) => set({ credenciada }),
@@ -60,6 +65,7 @@ const useGTOStore = create<GTOStore>((set) => ({
items: state.items.filter(i => i.id !== id)
})),
clear: () => set({ paciente: null, dentista: null, items: [] }),
setPendingSearch: (pendingSearch) => set({ pendingSearch }),
}));
export { useGTOStore };
@@ -98,6 +104,8 @@ export const LancarGTO: React.FC = () => {
const [selectedArco, setSelectedArco] = useState<'AI' | 'AS' | null>(null);
const [face, setFace] = useState('');
const [obs, setObs] = useState('');
const [patientSearchOpen, setPatientSearchOpen] = useState(false);
const [patientSearchTerm, setPatientSearchTerm] = useState('');
useEffect(() => {
const load = async () => {
@@ -118,6 +126,30 @@ export const LancarGTO: React.FC = () => {
return procedimentos.filter(p => p.especialidadeId === selectedEsp);
}, [selectedEsp, procedimentos]);
const filteredPatients = useMemo(() => {
if (!patientSearchTerm) return pacientes.slice(0, 30);
const term = patientSearchTerm.toLowerCase();
return pacientes.filter(p =>
p.nome.toLowerCase().includes(term) ||
(p.cpf && p.cpf.includes(term))
).slice(0, 30);
}, [pacientes, patientSearchTerm]);
// Auto-seleciona paciente quando vem da agenda (pendingSearch)
useEffect(() => {
const { pendingSearch } = store;
if (!pendingSearch || pacientes.length === 0) return;
const term = pendingSearch.toLowerCase();
const match = pacientes.find(p => p.nome.toLowerCase() === term);
if (match) {
store.setPaciente(match);
} else {
setPatientSearchTerm(pendingSearch);
setPatientSearchOpen(true);
}
store.setPendingSearch('');
}, [pacientes, store.pendingSearch]); // eslint-disable-line react-hooks/exhaustive-deps
const toggleDente = (num: string) => {
if (selectedProc?.tipo_regiao !== 'DENTE') {
toast.error("ESTE PROCEDIMENTO NÃO PERMITE SELEÇÃO DE DENTE INDIVIDUAL.");
@@ -390,17 +422,29 @@ export const LancarGTO: React.FC = () => {
<div className="flex items-center gap-3 text-blue-600 font-black text-base uppercase tracking-wider">
<Users size={20} strokeWidth={3} /> 01. Paciente
</div>
<select
value={store.paciente?.id || ''}
onChange={(e) => {
const p = pacientes.find(p => p.id === e.target.value);
store.setPaciente(p || null);
}}
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-blue-500 outline-none transition-all uppercase"
>
<option value="">SELECIONE O BENEFICIÁRIO</option>
{pacientes.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
</select>
{store.paciente ? (
<div className="flex items-center justify-between bg-blue-50 border-2 border-blue-200 rounded-2xl px-4 py-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-black text-xs flex-shrink-0">
{store.paciente.nome.charAt(0)}
</div>
<span className="font-black text-blue-800 text-sm uppercase truncate">{store.paciente.nome}</span>
</div>
<button
onClick={() => store.setPaciente(null)}
className="text-xs font-bold text-blue-500 hover:text-red-500 transition-colors flex-shrink-0 ml-3 uppercase"
>
ALTERAR
</button>
</div>
) : (
<button
onClick={() => setPatientSearchOpen(true)}
className="w-full p-4 bg-gray-50 border-2 border-dashed border-blue-200 rounded-2xl font-black text-blue-400 text-sm hover:bg-blue-50 hover:border-blue-400 transition-all uppercase flex items-center justify-center gap-2"
>
<Search size={16} /> SELECIONAR BENEFICIÁRIO
</button>
)}
</div>
{/* STEP 2: ESPECIALIDADE & PROCEDIMENTO */}
@@ -529,6 +573,62 @@ export const LancarGTO: React.FC = () => {
</div>
</div>
{/* MODAL DE BUSCA DE PACIENTE */}
{patientSearchOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-4">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md flex flex-col overflow-hidden animate-in fade-in zoom-in-50 duration-200">
<div className="px-6 py-5 border-b border-gray-100 flex justify-between items-center">
<h3 className="font-black text-gray-900 uppercase tracking-wide text-sm flex items-center gap-2">
<Search size={16} className="text-blue-600" /> BUSCAR PACIENTE
</h3>
<button
onClick={() => { setPatientSearchOpen(false); setPatientSearchTerm(''); }}
className="text-gray-400 hover:text-gray-600 p-1 hover:bg-gray-100 rounded-full transition-colors"
>
<X size={20} />
</button>
</div>
<div className="p-5 space-y-3">
<input
autoFocus
type="text"
value={patientSearchTerm}
onChange={e => setPatientSearchTerm(e.target.value.toUpperCase())}
placeholder="NOME OU CPF..."
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-blue-500 outline-none transition-all uppercase"
/>
<div className="max-h-72 overflow-y-auto space-y-1">
{filteredPatients.length === 0 ? (
<div className="py-10 text-center text-gray-300 font-bold uppercase text-xs">
{patientSearchTerm ? 'NENHUM PACIENTE ENCONTRADO' : 'CARREGANDO...'}
</div>
) : (
filteredPatients.map(p => (
<button
key={p.id}
onClick={() => {
store.setPaciente(p);
setPatientSearchOpen(false);
setPatientSearchTerm('');
}}
className="w-full text-left px-4 py-3 hover:bg-blue-50 rounded-xl transition-all flex items-center gap-3 group"
>
<div className="w-9 h-9 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-black text-sm flex-shrink-0 group-hover:bg-blue-600 group-hover:text-white transition-colors">
{p.nome.charAt(0)}
</div>
<div className="min-w-0">
<div className="font-black text-sm text-gray-800 uppercase truncate">{p.nome}</div>
{p.cpf && <div className="text-[10px] text-gray-400 font-bold">{p.cpf}</div>}
</div>
</button>
))
)}
</div>
</div>
</div>
</div>
)}
</div>
);
};