From 4c39a046547d39ca46a7dd674bee83f1cee9daa8 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Thu, 14 May 2026 07:32:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(pacientes):=20grupo=20familiar=20+=20indic?= =?UTF-8?q?ado=20por=20=E2=80=94=20DB=20migration,=20routes,=20autocomplet?= =?UTF-8?q?e=20UI,=20family=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/services/backend.ts | 20 ++ frontend/types.ts | 12 + frontend/views/PatientsView.tsx | 444 +++++++++++++++++++++++++------- 3 files changed, 378 insertions(+), 98 deletions(-) diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index f2ba336..ccdc240 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -239,6 +239,26 @@ export const HybridBackend = { return await res.json(); }, + getGruposFamiliares: async (q?: string) => { + const url = q?.trim() ? `${API_URL}/grupos-familiares?q=${encodeURIComponent(q)}` : `${API_URL}/grupos-familiares`; + const res = await apiFetch(url); + return res.json(); + }, + + criarGrupoFamiliar: async (nome: string, responsavel_id?: string) => { + const res = await apiFetch(`${API_URL}/grupos-familiares`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nome, responsavel_id }) + }); + return res.json(); + }, + + getMembrosFamilia: async (grupoId: string) => { + const res = await apiFetch(`${API_URL}/grupos-familiares/${grupoId}/membros`); + return res.json(); + }, + getDentistas: async (clinicaId?: string): Promise => { const url = clinicaId ? `${API_URL}/dentistas?clinicaId=${clinicaId}` : `${API_URL}/dentistas`; const res = await apiFetch(url); diff --git a/frontend/types.ts b/frontend/types.ts index 2111eb7..74a7eff 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -1,3 +1,11 @@ +export interface GrupoFamiliar { + id: string; + nome: string; + responsavel_id?: string; + responsavel_nome?: string; + membros?: number; +} + export interface Paciente { id: string; nome: string; @@ -7,6 +15,10 @@ export interface Paciente { ultimoTratamento?: string; convenio?: string; dataNascimento?: string; + grupoFamiliarId?: string; + grupoFamiliarNome?: string; + indicadoPorId?: string; + indicadoPorNome?: string; } export interface Dentista { diff --git a/frontend/views/PatientsView.tsx b/frontend/views/PatientsView.tsx index 0a8ee8f..d7d6ecc 100644 --- a/frontend/views/PatientsView.tsx +++ b/frontend/views/PatientsView.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Search, Plus, DollarSign, X, Printer, CreditCard, Rocket, Loader2, FolderOpen, CalendarDays, RadioTower, Pencil, Wallet, PlusCircle, Banknote, History, FileText, Calendar, Copy, CheckCircle2 } from 'lucide-react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Search, Plus, DollarSign, X, Printer, CreditCard, Rocket, Loader2, FolderOpen, CalendarDays, RadioTower, Pencil, Wallet, PlusCircle, Banknote, History, FileText, Calendar, Copy, CheckCircle2, Users, UserCheck, ChevronRight, Crown } from 'lucide-react'; import { HybridBackend } from '../services/backend.ts'; -import { Paciente, Dentista, Agendamento } from '../types.ts'; +import { Paciente, Dentista, Agendamento, GrupoFamiliar } from '../types.ts'; import { useHybridBackend } from '../hooks/useHybridBackend.ts'; import { useToast } from '../contexts/ToastContext.tsx'; @@ -9,14 +9,131 @@ import { useToast } from '../contexts/ToastContext.tsx'; function useDebounce(value: string, delay: number) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); + const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => clearTimeout(handler); }, [value, delay]); return debouncedValue; } +// Autocomplete genérico com debounce +function AutocompleteField({ + label, placeholder, value, onSelect, onClear, fetchFn, renderOption, extraAction +}: { + label: string; placeholder: string; + value?: { id: string; nome: string } | null; + onSelect: (item: T) => void; onClear: () => void; + fetchFn: (q: string) => Promise; + renderOption?: (item: T) => React.ReactNode; + extraAction?: React.ReactNode; +}) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const debounced = useDebounce(query, 300); + const ref = useRef(null); + + useEffect(() => { + if (!debounced.trim() || value) { setResults([]); setOpen(false); return; } + setLoading(true); + fetchFn(debounced).then(r => { setResults(r); setOpen(r.length > 0); }).finally(() => setLoading(false)); + }, [debounced]); + + useEffect(() => { + const close = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, []); + + if (value) return ( +
+ {value.nome} + +
+ ); + + return ( +
+ +
+
+ + setQuery(e.target.value)} + placeholder={placeholder} + className="w-full pl-7 pr-3 py-2 border border-gray-200 rounded-lg text-xs focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none" + /> + {loading && } +
+ {extraAction} +
+ {open && results.length > 0 && ( +
+ {results.map(item => ( + + ))} +
+ )} +
+ ); +} + +// Modal de membros da família +const FamiliaModal: React.FC<{ paciente: Paciente; onClose: () => void }> = ({ paciente, onClose }) => { + const [membros, setMembros] = useState([]); + const [loading, setLoading] = useState(true); + useEffect(() => { + if (!paciente.grupoFamiliarId) return; + HybridBackend.getMembrosFamilia(paciente.grupoFamiliarId) + .then(setMembros).finally(() => setLoading(false)); + }, [paciente.grupoFamiliarId]); + useEffect(() => { + const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', h); + return () => document.removeEventListener('keydown', h); + }, [onClose]); + + return ( +
+
+
+
+
+ +
+
+

{paciente.grupoFamiliarNome}

+

Grupo Familiar

+
+
+ +
+
+ {loading &&
} + {!loading && membros.map(m => ( +
+
+ {m.is_responsavel ? : } +
+
+

{m.nome}

+

{m.cpf}{m.is_responsavel ? ' · Responsável' : ''}

+
+
+ ))} + {!loading && membros.length === 0 &&

Nenhum membro encontrado.

} +
+
+ +
+
+
+ ); +}; + const AgendamentoModal: React.FC<{ patient: Paciente, onClose: () => void }> = ({ patient, onClose }) => { const { data: dentistas } = useHybridBackend(HybridBackend.getDentistas); const { data: agendamentos, refresh: refreshAgendamentos } = useHybridBackend(HybridBackend.getAgendamentos); @@ -170,6 +287,10 @@ const AgendamentoModal: React.FC<{ patient: Paciente, onClose: () => void }> = ( const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => { const toast = useToast(); const [form, setForm] = useState({ nome: '', cpf: '', telefone: '', dataNascimento: '', convenio: '', email: '' }); + const [grupo, setGrupo] = useState(null); + const [criandoGrupo, setCriandoGrupo] = useState(false); + const [novoGrupoNome, setNovoGrupoNome] = useState(''); + const [indicadoPor, setIndicadoPor] = useState<{ id: string; nome: string } | null>(null); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; @@ -177,76 +298,128 @@ const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }> return () => document.removeEventListener('keydown', handleKeyDown); }, [onClose]); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!form.nome || !form.cpf) { - toast.error('PREENCHA NOME E CPF.'); - return; - } - try { - await HybridBackend.savePaciente({ ...form, id: Math.random().toString() } as any); - toast.success('PACIENTE CADASTRADO!'); - onSaved(); - onClose(); - } catch { - toast.error('ERRO AO CADASTRAR PACIENTE.'); - } + const criarGrupo = async () => { + if (!novoGrupoNome.trim()) return; + const g = await HybridBackend.criarGrupoFamiliar(novoGrupoNome); + setGrupo(g); + setCriandoGrupo(false); + setNovoGrupoNome(''); }; + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!form.nome || !form.cpf) { toast.error('PREENCHA NOME E CPF.'); return; } + try { + const id = Date.now().toString(); + await HybridBackend.savePaciente({ + ...form, id, + grupoFamiliarId: grupo?.id, + indicadoPorId: indicadoPor?.id, + } as any); + toast.success('PACIENTE CADASTRADO!'); + onSaved(); onClose(); + } catch { toast.error('ERRO AO CADASTRAR PACIENTE.'); } + }; + + const input = "w-full border border-gray-200 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none"; + return ( -
-
-
-

- NOVO PACIENTE +
+
+
+

+ Novo Paciente

- +
-
+ + {/* Dados básicos */}
- - setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))} - className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 font-semibold" placeholder="NOME COMPLETO" /> + + setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))} className={input} placeholder="NOME COMPLETO" />
-
+
- - setForm(f => ({ ...f, cpf: e.target.value }))} - className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" placeholder="000.000.000-00" /> + + setForm(f => ({ ...f, cpf: e.target.value }))} className={input} placeholder="000.000.000-00" />
- - setForm(f => ({ ...f, telefone: e.target.value }))} - className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" placeholder="(XX) XXXXX-XXXX" /> + + setForm(f => ({ ...f, telefone: e.target.value }))} className={input} placeholder="(67) 99999-9999" />
-
+
- - setForm(f => ({ ...f, dataNascimento: e.target.value }))} - className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" /> + + setForm(f => ({ ...f, dataNascimento: e.target.value }))} className={input} />
- - setForm(f => ({ ...f, convenio: e.target.value.toUpperCase() }))} - className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" placeholder="EX: CASSEMS" /> + + setForm(f => ({ ...f, convenio: e.target.value.toUpperCase() }))} className={input} placeholder="Ex: CASSEMS" />
- - setForm(f => ({ ...f, email: e.target.value }))} - className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" placeholder="EMAIL@EXEMPLO.COM" /> + + setForm(f => ({ ...f, email: e.target.value }))} className={input} placeholder="email@exemplo.com" />
-
- - + } + /> + {criandoGrupo && ( +
+ setNovoGrupoNome(e.target.value.toUpperCase())} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); criarGrupo(); } }} + placeholder="NOME DO GRUPO (EX: FAMÍLIA SILVA)" + className="flex-1 border border-indigo-200 rounded-lg px-3 py-2 text-xs focus:ring-2 focus:ring-indigo-100 outline-none" /> + + +
+ )} + + {/* Indicado por */} + + label="Indicado por" + placeholder="Buscar paciente que indicou..." + value={indicadoPor} + onSelect={p => setIndicadoPor({ id: p.id, nome: p.nome })} + onClear={() => setIndicadoPor(null)} + fetchFn={q => HybridBackend.getPacientes(q).then(r => r.data)} + renderOption={p => ( + + + {p.nome} + {p.cpf} + + )} + /> +
+ +
+ +
@@ -300,72 +473,125 @@ const HistoricoModal: React.FC<{ patient: Paciente; onClose: () => void }> = ({ const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; onSaved: () => void }> = ({ patient, onClose, onSaved }) => { const toast = useToast(); const [form, setForm] = useState({ ...patient }); + const [grupo, setGrupo] = useState( + patient.grupoFamiliarId ? { id: patient.grupoFamiliarId, nome: patient.grupoFamiliarNome || '' } : null + ); + const [criandoGrupo, setCriandoGrupo] = useState(false); + const [novoGrupoNome, setNovoGrupoNome] = useState(''); + const [indicadoPor, setIndicadoPor] = useState<{ id: string; nome: string } | null>( + patient.indicadoPorId ? { id: patient.indicadoPorId, nome: patient.indicadoPorNome || '' } : null + ); useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', h); + return () => document.removeEventListener('keydown', h); }, [onClose]); + const criarGrupo = async () => { + if (!novoGrupoNome.trim()) return; + const g = await HybridBackend.criarGrupoFamiliar(novoGrupoNome); + setGrupo(g); setCriandoGrupo(false); setNovoGrupoNome(''); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { - await HybridBackend.updatePaciente(form); + await HybridBackend.updatePaciente({ ...form, grupoFamiliarId: grupo?.id, indicadoPorId: indicadoPor?.id }); toast.success('PACIENTE ATUALIZADO!'); - onSaved(); - onClose(); - } catch { - toast.error('ERRO AO SALVAR.'); - } + onSaved(); onClose(); + } catch { toast.error('ERRO AO SALVAR.'); } }; + const input = "w-full border border-gray-200 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none"; + return ( -
-
-
-

- EDITAR PACIENTE +
+
+
+

+ Editar Paciente

- +
-
+
- - setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))} - className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 font-semibold" /> + + setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))} className={input} />
-
+
- - setForm(f => ({ ...f, cpf: e.target.value }))} - className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" /> + + setForm(f => ({ ...f, cpf: e.target.value }))} className={input} />
- - setForm(f => ({ ...f, telefone: e.target.value }))} - className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" /> + + setForm(f => ({ ...f, telefone: e.target.value }))} className={input} />
-
+
- - setForm(f => ({ ...f, dataNascimento: e.target.value }))} - className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" /> + + setForm(f => ({ ...f, dataNascimento: e.target.value }))} className={input} />
- - setForm(f => ({ ...f, convenio: e.target.value.toUpperCase() }))} - className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" /> + + setForm(f => ({ ...f, convenio: e.target.value.toUpperCase() }))} className={input} />
-
- - + +
+ + label="Grupo Familiar" + placeholder="Buscar grupo existente..." + value={grupo} + onSelect={g => setGrupo(g)} + onClear={() => setGrupo(null)} + fetchFn={q => HybridBackend.getGruposFamiliares(q)} + renderOption={g => ( + + + {g.nome} + {g.membros} membro{g.membros !== 1 ? 's' : ''} + + )} + extraAction={ + + } + /> + {criandoGrupo && ( +
+ setNovoGrupoNome(e.target.value.toUpperCase())} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); criarGrupo(); } }} + placeholder="NOME DO GRUPO" + className="flex-1 border border-indigo-200 rounded-lg px-3 py-2 text-xs focus:ring-2 focus:ring-indigo-100 outline-none" /> + + +
+ )} + + label="Indicado por" + placeholder="Buscar paciente que indicou..." + value={indicadoPor} + onSelect={p => setIndicadoPor({ id: p.id, nome: p.nome })} + onClear={() => setIndicadoPor(null)} + fetchFn={q => HybridBackend.getPacientes(q).then(r => r.data)} + renderOption={p => ( + + + {p.nome} + {p.cpf} + + )} + /> +
+ +
+ +
@@ -420,6 +646,7 @@ export const PatientsView: React.FC = () => { const toast = useToast(); const [modal, setModal] = useState<{ type: string | null; patient: Paciente | null }>({ type: null, patient: null }); + const [familiaModal, setFamiliaModal] = useState(null); const [isNovoPacienteOpen, setIsNovoPacienteOpen] = useState(false); const closeModal = useCallback(() => setModal({ type: null, patient: null }), []); @@ -514,6 +741,22 @@ export const PatientsView: React.FC = () => { {p.convenio} )}
+ {/* chips família / indicação */} + {(p.grupoFamiliarNome || p.indicadoPorNome) && ( +
+ {p.grupoFamiliarNome && ( + + )} + {p.indicadoPorNome && ( + + {p.indicadoPorNome} + + )} +
+ )}
@@ -534,6 +777,11 @@ export const PatientsView: React.FC = () => { ))}
+ {/* FAMILIA MODAL */} + {familiaModal && ( + setFamiliaModal(null)} /> + )} + {/* NOVO PACIENTE MODAL */} {isNovoPacienteOpen && ( setIsNovoPacienteOpen(false)} onSaved={refresh} />