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