feat(patients): fix date bug, convenio dropdown, redesign grupo familiar
- Fix dataNascimento date format error: convert dd/MM/yyyy → yyyy-MM-dd when loading EditarPacienteModal so <input type="date"> validates correctly - Change convênio field from free text to dropdown populated from planos table - Redesign grupo familiar: search a patient as family member instead of named group, with parentesco field (datalist with common options, accepts custom) - GET /api/pacientes: proper camelCase aliases, LEFT JOINs for grupoFamiliarNome and indicadoPorNome, sort parameter support (az/za/convenio/recentes) - Add startup migrations: ADD COLUMN IF NOT EXISTS for grupofamiliarid, grupofamiliarrelacao, indicadorporid on pacientes table - savePaciente: preserve caller-supplied id instead of overwriting with Math.random - Add grupoFamiliarRelacao to Paciente type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+99
-101
@@ -1,10 +1,19 @@
|
||||
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 { Search, Plus, DollarSign, X, Printer, CreditCard, Rocket, Loader2, FolderOpen, CalendarDays, RadioTower, Pencil, Wallet, PlusCircle, Banknote, History, FileText, Calendar, Copy, CheckCircle2, Users, UserCheck } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Paciente, Dentista, Agendamento, GrupoFamiliar } from '../types.ts';
|
||||
import { Paciente, Dentista, Agendamento, Plano } from '../types.ts';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
const RELACOES_COMUNS = ['MÃE', 'PAI', 'FILHO', 'FILHA', 'IRMÃO', 'IRMÃ', 'CÔNJUGE', 'AVÔ', 'AVÓ', 'NETO', 'NETA', 'TIO', 'TIA', 'SOBRINHO', 'SOBRINHA', 'PRIMO', 'PRIMA', 'PADRASTO', 'MADRASTA', 'ENTEADO', 'ENTEADA'];
|
||||
|
||||
function toInputDate(str?: string): string {
|
||||
if (!str) return '';
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
|
||||
const m = str.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
||||
return m ? `${m[3]}-${m[2]}-${m[1]}` : '';
|
||||
}
|
||||
|
||||
// Debounce hook to avoid excessive API calls on search
|
||||
function useDebounce(value: string, delay: number) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
@@ -81,15 +90,8 @@ function AutocompleteField<T extends { id: string; nome: string }>({
|
||||
);
|
||||
}
|
||||
|
||||
// Modal de membros da família
|
||||
// Modal de familiar vinculado
|
||||
const FamiliaModal: React.FC<{ paciente: Paciente; onClose: () => void }> = ({ paciente, onClose }) => {
|
||||
const [membros, setMembros] = useState<any[]>([]);
|
||||
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);
|
||||
@@ -98,33 +100,33 @@ const FamiliaModal: React.FC<{ paciente: Paciente; onClose: () => void }> = ({ p
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md animate-in fade-in zoom-in-50 duration-200">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm animate-in fade-in zoom-in-50 duration-200">
|
||||
<div className="p-5 border-b border-gray-100 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-indigo-100 rounded-lg flex items-center justify-center">
|
||||
<Users size={16} className="text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-black text-gray-800 uppercase">{paciente.grupoFamiliarNome}</p>
|
||||
<p className="text-[10px] text-gray-400 font-medium">Grupo Familiar</p>
|
||||
<p className="text-xs font-black text-gray-800 uppercase">Vínculo Familiar</p>
|
||||
<p className="text-[10px] text-gray-400 font-medium uppercase">{paciente.nome}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="p-4 space-y-2 max-h-72 overflow-y-auto">
|
||||
{loading && <div className="flex justify-center py-6"><Loader2 className="animate-spin text-gray-400" size={20} /></div>}
|
||||
{!loading && membros.map(m => (
|
||||
<div key={m.id} className="flex items-center gap-3 p-2.5 bg-gray-50 rounded-xl border border-gray-100">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 ${m.is_responsavel ? 'bg-amber-100' : 'bg-gray-200'}`}>
|
||||
{m.is_responsavel ? <Crown size={13} className="text-amber-600" /> : <UserCheck size={13} className="text-gray-500" />}
|
||||
<div className="p-4">
|
||||
{paciente.grupoFamiliarId ? (
|
||||
<div className="flex items-center gap-3 p-3 bg-indigo-50 rounded-xl border border-indigo-100">
|
||||
<div className="w-9 h-9 rounded-full bg-indigo-200 flex items-center justify-center flex-shrink-0">
|
||||
<UserCheck size={15} className="text-indigo-700" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold text-gray-800 uppercase truncate">{m.nome}</p>
|
||||
<p className="text-[10px] text-gray-400">{m.cpf}{m.is_responsavel ? ' · Responsável' : ''}</p>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-gray-800 uppercase">{paciente.grupoFamiliarNome}</p>
|
||||
<p className="text-[10px] text-indigo-500 font-bold uppercase">{paciente.grupoFamiliarRelacao || 'Familiar'}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!loading && membros.length === 0 && <p className="text-center text-xs text-gray-400 py-4">Nenhum membro encontrado.</p>}
|
||||
) : (
|
||||
<p className="text-center text-xs text-gray-400 py-4">Nenhum familiar vinculado.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-100 flex justify-end">
|
||||
<button onClick={onClose} className="px-5 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs font-bold rounded-lg uppercase">Fechar</button>
|
||||
@@ -287,10 +289,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<GrupoFamiliar | null>(null);
|
||||
const [criandoGrupo, setCriandoGrupo] = useState(false);
|
||||
const [novoGrupoNome, setNovoGrupoNome] = useState('');
|
||||
const [familiar, setFamiliar] = useState<{ id: string; nome: string } | null>(null);
|
||||
const [familiarRelacao, setFamiliarRelacao] = useState('');
|
||||
const [indicadoPor, setIndicadoPor] = useState<{ id: string; nome: string } | null>(null);
|
||||
const { data: planos } = useHybridBackend<Plano[]>(HybridBackend.getPlanos);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
@@ -298,14 +300,6 @@ const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }>
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [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();
|
||||
if (!form.nome || !form.cpf) { toast.error('PREENCHA NOME E CPF.'); return; }
|
||||
@@ -313,7 +307,8 @@ const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }>
|
||||
const id = Date.now().toString();
|
||||
await HybridBackend.savePaciente({
|
||||
...form, id,
|
||||
grupoFamiliarId: grupo?.id,
|
||||
grupoFamiliarId: familiar?.id,
|
||||
grupoFamiliarRelacao: familiarRelacao || undefined,
|
||||
indicadoPorId: indicadoPor?.id,
|
||||
} as any);
|
||||
toast.success('PACIENTE CADASTRADO!');
|
||||
@@ -333,7 +328,6 @@ const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4 max-h-[80vh] overflow-y-auto">
|
||||
{/* Dados básicos */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Nome Completo *</label>
|
||||
<input required value={form.nome} onChange={e => setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))} className={input} placeholder="NOME COMPLETO" />
|
||||
@@ -355,7 +349,10 @@ const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Convênio</label>
|
||||
<input value={form.convenio} onChange={e => setForm(f => ({ ...f, convenio: e.target.value.toUpperCase() }))} className={input} placeholder="Ex: CASSEMS" />
|
||||
<select value={form.convenio} onChange={e => setForm(f => ({ ...f, convenio: e.target.value }))} className={input}>
|
||||
<option value="">— Particular —</option>
|
||||
{planos?.map(p => <option key={p.id} value={p.nome}>{p.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -363,38 +360,36 @@ const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }>
|
||||
<input type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} className={input} placeholder="email@exemplo.com" />
|
||||
</div>
|
||||
|
||||
{/* Separador */}
|
||||
<div className="border-t border-gray-100 pt-3 space-y-3">
|
||||
{/* Grupo Familiar */}
|
||||
<AutocompleteField<GrupoFamiliar>
|
||||
label="Grupo Familiar"
|
||||
placeholder="Buscar grupo existente..."
|
||||
value={grupo}
|
||||
onSelect={g => setGrupo(g)}
|
||||
onClear={() => setGrupo(null)}
|
||||
fetchFn={q => HybridBackend.getGruposFamiliares(q)}
|
||||
renderOption={g => (
|
||||
{/* Familiar */}
|
||||
<AutocompleteField<Paciente>
|
||||
label="Familiar (vínculo)"
|
||||
placeholder="Buscar paciente familiar..."
|
||||
value={familiar}
|
||||
onSelect={p => { setFamiliar({ id: p.id, nome: p.nome }); }}
|
||||
onClear={() => { setFamiliar(null); setFamiliarRelacao(''); }}
|
||||
fetchFn={q => HybridBackend.getPacientes(q).then(r => r.data)}
|
||||
renderOption={p => (
|
||||
<span className="flex items-center gap-2 w-full">
|
||||
<Users size={12} className="text-indigo-400 flex-shrink-0" />
|
||||
<span className="font-bold flex-1">{g.nome}</span>
|
||||
<span className="text-[10px] text-gray-400">{g.membros} membro{g.membros !== 1 ? 's' : ''}</span>
|
||||
<span className="font-bold flex-1">{p.nome}</span>
|
||||
<span className="text-[10px] text-gray-400">{p.cpf}</span>
|
||||
</span>
|
||||
)}
|
||||
extraAction={
|
||||
<button type="button" onClick={() => setCriandoGrupo(v => !v)}
|
||||
className="flex-shrink-0 px-2.5 py-2 bg-indigo-50 hover:bg-indigo-100 text-indigo-600 rounded-lg text-[11px] font-bold border border-indigo-100 transition-colors">
|
||||
+ Novo
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{criandoGrupo && (
|
||||
<div className="flex gap-2 items-center pl-1">
|
||||
<input autoFocus value={novoGrupoNome} onChange={e => 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" />
|
||||
<button type="button" onClick={criarGrupo} className="px-3 py-2 bg-indigo-600 text-white rounded-lg text-xs font-bold hover:bg-indigo-700">Criar</button>
|
||||
<button type="button" onClick={() => { setCriandoGrupo(false); setNovoGrupoNome(''); }} className="text-gray-400 hover:text-gray-600"><X size={14} /></button>
|
||||
{familiar && (
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Parentesco</label>
|
||||
<input
|
||||
list="relacoes-list-novo"
|
||||
value={familiarRelacao}
|
||||
onChange={e => setFamiliarRelacao(e.target.value.toUpperCase())}
|
||||
placeholder="EX: MÃE, PAI, IRMÃO..."
|
||||
className={input}
|
||||
/>
|
||||
<datalist id="relacoes-list-novo">
|
||||
{RELACOES_COMUNS.map(r => <option key={r} value={r} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -472,12 +467,12 @@ const HistoricoModal: React.FC<{ patient: Paciente; onClose: () => void }> = ({
|
||||
// ----------- EDITAR PACIENTE MODAL -----------
|
||||
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<GrupoFamiliar | null>(
|
||||
const { data: planos } = useHybridBackend<Plano[]>(HybridBackend.getPlanos);
|
||||
const [form, setForm] = useState({ ...patient, dataNascimento: toInputDate(patient.dataNascimento) });
|
||||
const [familiar, setFamiliar] = useState<{ id: string; nome: string } | null>(
|
||||
patient.grupoFamiliarId ? { id: patient.grupoFamiliarId, nome: patient.grupoFamiliarNome || '' } : null
|
||||
);
|
||||
const [criandoGrupo, setCriandoGrupo] = useState(false);
|
||||
const [novoGrupoNome, setNovoGrupoNome] = useState('');
|
||||
const [familiarRelacao, setFamiliarRelacao] = useState(patient.grupoFamiliarRelacao || '');
|
||||
const [indicadoPor, setIndicadoPor] = useState<{ id: string; nome: string } | null>(
|
||||
patient.indicadoPorId ? { id: patient.indicadoPorId, nome: patient.indicadoPorNome || '' } : null
|
||||
);
|
||||
@@ -488,16 +483,16 @@ const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; on
|
||||
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, grupoFamiliarId: grupo?.id, indicadoPorId: indicadoPor?.id });
|
||||
const { grupoFamiliarNome: _gn, indicadoPorNome: _in, ...formData } = form;
|
||||
await HybridBackend.updatePaciente({
|
||||
...formData,
|
||||
grupoFamiliarId: familiar?.id,
|
||||
grupoFamiliarRelacao: familiarRelacao || undefined,
|
||||
indicadoPorId: indicadoPor?.id,
|
||||
} as any);
|
||||
toast.success('PACIENTE ATUALIZADO!');
|
||||
onSaved(); onClose();
|
||||
} catch { toast.error('ERRO AO SALVAR.'); }
|
||||
@@ -536,40 +531,43 @@ const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; on
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Convênio</label>
|
||||
<input value={form.convenio} onChange={e => setForm(f => ({ ...f, convenio: e.target.value.toUpperCase() }))} className={input} />
|
||||
<select value={form.convenio || ''} onChange={e => setForm(f => ({ ...f, convenio: e.target.value }))} className={input}>
|
||||
<option value="">— Particular —</option>
|
||||
{planos?.map(p => <option key={p.id} value={p.nome}>{p.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 pt-3 space-y-3">
|
||||
<AutocompleteField<GrupoFamiliar>
|
||||
label="Grupo Familiar"
|
||||
placeholder="Buscar grupo existente..."
|
||||
value={grupo}
|
||||
onSelect={g => setGrupo(g)}
|
||||
onClear={() => setGrupo(null)}
|
||||
fetchFn={q => HybridBackend.getGruposFamiliares(q)}
|
||||
renderOption={g => (
|
||||
{/* Familiar */}
|
||||
<AutocompleteField<Paciente>
|
||||
label="Familiar (vínculo)"
|
||||
placeholder="Buscar paciente familiar..."
|
||||
value={familiar}
|
||||
onSelect={p => { setFamiliar({ id: p.id, nome: p.nome }); }}
|
||||
onClear={() => { setFamiliar(null); setFamiliarRelacao(''); }}
|
||||
fetchFn={q => HybridBackend.getPacientes(q).then(r => r.data)}
|
||||
renderOption={p => (
|
||||
<span className="flex items-center gap-2 w-full">
|
||||
<Users size={12} className="text-indigo-400 flex-shrink-0" />
|
||||
<span className="font-bold flex-1">{g.nome}</span>
|
||||
<span className="text-[10px] text-gray-400">{g.membros} membro{g.membros !== 1 ? 's' : ''}</span>
|
||||
<span className="font-bold flex-1">{p.nome}</span>
|
||||
<span className="text-[10px] text-gray-400">{p.cpf}</span>
|
||||
</span>
|
||||
)}
|
||||
extraAction={
|
||||
<button type="button" onClick={() => setCriandoGrupo(v => !v)}
|
||||
className="flex-shrink-0 px-2.5 py-2 bg-indigo-50 hover:bg-indigo-100 text-indigo-600 rounded-lg text-[11px] font-bold border border-indigo-100">
|
||||
+ Novo
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{criandoGrupo && (
|
||||
<div className="flex gap-2 items-center pl-1">
|
||||
<input autoFocus value={novoGrupoNome} onChange={e => 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" />
|
||||
<button type="button" onClick={criarGrupo} className="px-3 py-2 bg-indigo-600 text-white rounded-lg text-xs font-bold">Criar</button>
|
||||
<button type="button" onClick={() => { setCriandoGrupo(false); setNovoGrupoNome(''); }} className="text-gray-400"><X size={14} /></button>
|
||||
{familiar && (
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Parentesco</label>
|
||||
<input
|
||||
list="relacoes-list-editar"
|
||||
value={familiarRelacao}
|
||||
onChange={e => setFamiliarRelacao(e.target.value.toUpperCase())}
|
||||
placeholder="EX: MÃE, PAI, IRMÃO..."
|
||||
className={input}
|
||||
/>
|
||||
<datalist id="relacoes-list-editar">
|
||||
{RELACOES_COMUNS.map(r => <option key={r} value={r} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
)}
|
||||
<AutocompleteField<Paciente>
|
||||
@@ -747,7 +745,7 @@ export const PatientsView: React.FC = () => {
|
||||
{p.grupoFamiliarNome && (
|
||||
<button onClick={() => setFamiliaModal(p)}
|
||||
className="flex items-center gap-1 bg-indigo-50 hover:bg-indigo-100 text-indigo-600 px-1.5 py-0.5 rounded-full text-[9px] font-bold uppercase transition-colors">
|
||||
<Users size={9} />{p.grupoFamiliarNome}
|
||||
<Users size={9} />{p.grupoFamiliarRelacao ? `${p.grupoFamiliarRelacao}: ${p.grupoFamiliarNome}` : p.grupoFamiliarNome}
|
||||
</button>
|
||||
)}
|
||||
{p.indicadoPorNome && (
|
||||
|
||||
Reference in New Issue
Block a user