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[]> = {
|
const permissions: Record<string, ViewKey[]> = {
|
||||||
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
|
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'],
|
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 'leads': return <LeadsView />;
|
||||||
case 'public': return <PublicContactForm />;
|
case 'public': return <PublicContactForm />;
|
||||||
case 'pacientes': return <PatientsView />;
|
case 'pacientes': return <PatientsView />;
|
||||||
case 'agenda': return <AgendaView />;
|
case 'agenda': return <AgendaView onNavigate={handleNavigate} />;
|
||||||
case 'ortodontia': return <OrthoView />;
|
case 'ortodontia': return <OrthoView />;
|
||||||
case 'financeiro': return <FinanceiroView />;
|
case 'financeiro': return <FinanceiroView />;
|
||||||
case 'dentistas': return <DentistasView />;
|
case 'dentistas': return <DentistasView />;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
|
|||||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||||
import { HybridBackend } from '../services/backend.ts';
|
import { HybridBackend } from '../services/backend.ts';
|
||||||
import { Dentista, Agendamento, Paciente, DentistaHorario } from '../types.ts';
|
import { Dentista, Agendamento, Paciente, DentistaHorario } from '../types.ts';
|
||||||
|
import { useGTOStore } from './LancarGTO.tsx';
|
||||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||||
import { useToast } from '../contexts/ToastContext.tsx';
|
import { useToast } from '../contexts/ToastContext.tsx';
|
||||||
import { PageHeader } from '../components/PageHeader.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: agendamentos, refresh } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
||||||
const { data: dentists, refresh: refreshDentists } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
const { data: dentists, refresh: refreshDentists } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
||||||
const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend<any[]>(HybridBackend.getGoogleEvents);
|
const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend<any[]>(HybridBackend.getGoogleEvents);
|
||||||
@@ -373,6 +374,14 @@ export const AgendaView: React.FC = () => {
|
|||||||
|
|
||||||
const currentRole = HybridBackend.getCurrentRole();
|
const currentRole = HybridBackend.getCurrentRole();
|
||||||
const currentUserEmail = HybridBackend.getCurrentUser();
|
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 => {
|
const filteredDentists = dentists?.filter(d => {
|
||||||
if (currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') return true;
|
if (currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') return true;
|
||||||
@@ -537,8 +546,16 @@ export const AgendaView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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
|
<button
|
||||||
onClick={() => { setShowDetails(false); setIsModalOpen(true); }}
|
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"
|
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,
|
PlusCircle,
|
||||||
Camera,
|
Camera,
|
||||||
Stethoscope,
|
Stethoscope,
|
||||||
ChevronRight
|
ChevronRight,
|
||||||
|
Search,
|
||||||
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader } from '../components/PageHeader.tsx';
|
import { PageHeader } from '../components/PageHeader.tsx';
|
||||||
import { HybridBackend } from '../services/backend.ts';
|
import { HybridBackend } from '../services/backend.ts';
|
||||||
@@ -37,12 +39,14 @@ interface GTOStore {
|
|||||||
dentista: Dentista | null;
|
dentista: Dentista | null;
|
||||||
credenciada: string;
|
credenciada: string;
|
||||||
items: GTOItem[];
|
items: GTOItem[];
|
||||||
|
pendingSearch: string;
|
||||||
setPaciente: (p: Paciente | null) => void;
|
setPaciente: (p: Paciente | null) => void;
|
||||||
setDentista: (d: Dentista | null) => void;
|
setDentista: (d: Dentista | null) => void;
|
||||||
setCredenciada: (c: string) => void;
|
setCredenciada: (c: string) => void;
|
||||||
addItem: (item: GTOItem) => void;
|
addItem: (item: GTOItem) => void;
|
||||||
removeItem: (id: string) => void;
|
removeItem: (id: string) => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
|
setPendingSearch: (s: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useGTOStore = create<GTOStore>((set) => ({
|
const useGTOStore = create<GTOStore>((set) => ({
|
||||||
@@ -50,6 +54,7 @@ const useGTOStore = create<GTOStore>((set) => ({
|
|||||||
dentista: null,
|
dentista: null,
|
||||||
credenciada: 'CASSEMS - SEDE CENTRAL',
|
credenciada: 'CASSEMS - SEDE CENTRAL',
|
||||||
items: [],
|
items: [],
|
||||||
|
pendingSearch: '',
|
||||||
setPaciente: (paciente) => set({ paciente }),
|
setPaciente: (paciente) => set({ paciente }),
|
||||||
setDentista: (dentista) => set({ dentista }),
|
setDentista: (dentista) => set({ dentista }),
|
||||||
setCredenciada: (credenciada) => set({ credenciada }),
|
setCredenciada: (credenciada) => set({ credenciada }),
|
||||||
@@ -60,6 +65,7 @@ const useGTOStore = create<GTOStore>((set) => ({
|
|||||||
items: state.items.filter(i => i.id !== id)
|
items: state.items.filter(i => i.id !== id)
|
||||||
})),
|
})),
|
||||||
clear: () => set({ paciente: null, dentista: null, items: [] }),
|
clear: () => set({ paciente: null, dentista: null, items: [] }),
|
||||||
|
setPendingSearch: (pendingSearch) => set({ pendingSearch }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export { useGTOStore };
|
export { useGTOStore };
|
||||||
@@ -98,6 +104,8 @@ export const LancarGTO: React.FC = () => {
|
|||||||
const [selectedArco, setSelectedArco] = useState<'AI' | 'AS' | null>(null);
|
const [selectedArco, setSelectedArco] = useState<'AI' | 'AS' | null>(null);
|
||||||
const [face, setFace] = useState('');
|
const [face, setFace] = useState('');
|
||||||
const [obs, setObs] = useState('');
|
const [obs, setObs] = useState('');
|
||||||
|
const [patientSearchOpen, setPatientSearchOpen] = useState(false);
|
||||||
|
const [patientSearchTerm, setPatientSearchTerm] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -118,6 +126,30 @@ export const LancarGTO: React.FC = () => {
|
|||||||
return procedimentos.filter(p => p.especialidadeId === selectedEsp);
|
return procedimentos.filter(p => p.especialidadeId === selectedEsp);
|
||||||
}, [selectedEsp, procedimentos]);
|
}, [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) => {
|
const toggleDente = (num: string) => {
|
||||||
if (selectedProc?.tipo_regiao !== 'DENTE') {
|
if (selectedProc?.tipo_regiao !== 'DENTE') {
|
||||||
toast.error("ESTE PROCEDIMENTO NÃO PERMITE SELEÇÃO DE DENTE INDIVIDUAL.");
|
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">
|
<div className="flex items-center gap-3 text-blue-600 font-black text-base uppercase tracking-wider">
|
||||||
<Users size={20} strokeWidth={3} /> 01. Paciente
|
<Users size={20} strokeWidth={3} /> 01. Paciente
|
||||||
</div>
|
</div>
|
||||||
<select
|
{store.paciente ? (
|
||||||
value={store.paciente?.id || ''}
|
<div className="flex items-center justify-between bg-blue-50 border-2 border-blue-200 rounded-2xl px-4 py-3">
|
||||||
onChange={(e) => {
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
const p = pacientes.find(p => p.id === e.target.value);
|
<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.setPaciente(p || null);
|
{store.paciente.nome.charAt(0)}
|
||||||
}}
|
</div>
|
||||||
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"
|
<span className="font-black text-blue-800 text-sm uppercase truncate">{store.paciente.nome}</span>
|
||||||
>
|
</div>
|
||||||
<option value="">SELECIONE O BENEFICIÁRIO</option>
|
<button
|
||||||
{pacientes.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
|
onClick={() => store.setPaciente(null)}
|
||||||
</select>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* STEP 2: ESPECIALIDADE & PROCEDIMENTO */}
|
{/* STEP 2: ESPECIALIDADE & PROCEDIMENTO */}
|
||||||
@@ -529,6 +573,62 @@ export const LancarGTO: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user