Merge claude/youthful-mendel-0c569f: grupo familiar + indicado por
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Dentista[]> => {
|
||||
const url = clinicaId ? `${API_URL}/dentistas?clinicaId=${clinicaId}` : `${API_URL}/dentistas`;
|
||||
const res = await apiFetch(url);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+346
-98
@@ -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<T extends { id: string; nome: string }>({
|
||||
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<T[]>;
|
||||
renderOption?: (item: T) => React.ReactNode;
|
||||
extraAction?: React.ReactNode;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<T[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const debounced = useDebounce(query, 300);
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
<div className="flex items-center justify-between bg-blue-50 border border-blue-100 rounded-lg px-3 py-2">
|
||||
<span className="text-xs font-bold text-blue-700 uppercase truncate">{value.nome}</span>
|
||||
<button onClick={onClear} className="ml-2 text-blue-300 hover:text-blue-600 flex-shrink-0"><X size={13} /></button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">{label}</label>
|
||||
<div className="flex gap-1">
|
||||
<div className="relative flex-1">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
value={query} onChange={e => 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 && <Loader2 size={12} className="absolute right-2.5 top-1/2 -translate-y-1/2 animate-spin text-gray-400" />}
|
||||
</div>
|
||||
{extraAction}
|
||||
</div>
|
||||
{open && results.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
|
||||
{results.map(item => (
|
||||
<button key={item.id} onClick={() => { onSelect(item); setQuery(''); setOpen(false); }}
|
||||
className="w-full text-left px-3 py-2 hover:bg-blue-50 text-xs font-medium text-gray-700 flex items-center gap-2 border-b border-gray-50 last:border-0">
|
||||
{renderOption ? renderOption(item) : item.nome}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal de membros da família
|
||||
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);
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
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="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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
{!loading && membros.length === 0 && <p className="text-center text-xs text-gray-400 py-4">Nenhum membro encontrado.</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AgendamentoModal: React.FC<{ patient: Paciente, onClose: () => void }> = ({ patient, onClose }) => {
|
||||
const { data: dentistas } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
||||
const { data: agendamentos, refresh: refreshAgendamentos } = useHybridBackend<Agendamento[]>(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<GrupoFamiliar | null>(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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg animate-in fade-in zoom-in-50 duration-200">
|
||||
<div className="p-5 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 className="text-base font-bold text-gray-800 uppercase flex items-center gap-2">
|
||||
<Plus size={18} className="text-green-500" /> NOVO PACIENTE
|
||||
<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-lg animate-in fade-in zoom-in-50 duration-200">
|
||||
<div className="p-5 border-b border-gray-100 flex justify-between items-center">
|
||||
<h2 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2">
|
||||
<Plus size={16} className="text-green-500" /> Novo Paciente
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4 max-h-[80vh] overflow-y-auto">
|
||||
{/* Dados básicos */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">NOME COMPLETO *</label>
|
||||
<input type="text" required value={form.nome}
|
||||
onChange={e => 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" />
|
||||
<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" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">CPF *</label>
|
||||
<input type="text" required value={form.cpf}
|
||||
onChange={e => 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" />
|
||||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">CPF *</label>
|
||||
<input required value={form.cpf} onChange={e => setForm(f => ({ ...f, cpf: e.target.value }))} className={input} placeholder="000.000.000-00" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">TELEFONE</label>
|
||||
<input type="text" value={form.telefone}
|
||||
onChange={e => 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" />
|
||||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Telefone</label>
|
||||
<input value={form.telefone} onChange={e => setForm(f => ({ ...f, telefone: e.target.value }))} className={input} placeholder="(67) 99999-9999" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">DATA NASCIMENTO</label>
|
||||
<input type="date" value={form.dataNascimento}
|
||||
onChange={e => setForm(f => ({ ...f, dataNascimento: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" />
|
||||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Nascimento</label>
|
||||
<input type="date" value={form.dataNascimento} onChange={e => setForm(f => ({ ...f, dataNascimento: e.target.value }))} className={input} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">CONVÊNIO</label>
|
||||
<input type="text" value={form.convenio}
|
||||
onChange={e => 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" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">E-MAIL</label>
|
||||
<input type="email" value={form.email}
|
||||
onChange={e => 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" />
|
||||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">E-mail</label>
|
||||
<input type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} className={input} placeholder="email@exemplo.com" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-sm uppercase">CANCELAR</button>
|
||||
<button type="submit" className="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg text-sm uppercase flex items-center gap-2">
|
||||
<Plus size={16} /> CADASTRAR
|
||||
|
||||
{/* 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 => (
|
||||
<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>
|
||||
)}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicado por */}
|
||||
<AutocompleteField<Paciente>
|
||||
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 => (
|
||||
<span className="flex items-center gap-2 w-full">
|
||||
<UserCheck size={12} className="text-green-400 flex-shrink-0" />
|
||||
<span className="font-bold flex-1">{p.nome}</span>
|
||||
<span className="text-[10px] text-gray-400">{p.cpf}</span>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={onClose} className="px-5 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-xs uppercase">Cancelar</button>
|
||||
<button type="submit" className="px-5 py-2 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg text-xs uppercase flex items-center gap-1.5">
|
||||
<Plus size={14} /> Cadastrar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -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<GrupoFamiliar | null>(
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg animate-in fade-in zoom-in-50 duration-200">
|
||||
<div className="p-5 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 className="text-base font-bold text-gray-800 uppercase flex items-center gap-2">
|
||||
<Pencil size={18} className="text-blue-500" /> EDITAR PACIENTE
|
||||
<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-lg animate-in fade-in zoom-in-50 duration-200">
|
||||
<div className="p-5 border-b border-gray-100 flex justify-between items-center">
|
||||
<h2 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2">
|
||||
<Pencil size={15} className="text-blue-500" /> Editar Paciente
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4 max-h-[80vh] overflow-y-auto">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">NOME COMPLETO</label>
|
||||
<input type="text" required value={form.nome}
|
||||
onChange={e => setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 font-semibold" />
|
||||
<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} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">CPF</label>
|
||||
<input type="text" value={form.cpf}
|
||||
onChange={e => setForm(f => ({ ...f, cpf: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" />
|
||||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">CPF</label>
|
||||
<input value={form.cpf} onChange={e => setForm(f => ({ ...f, cpf: e.target.value }))} className={input} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">TELEFONE</label>
|
||||
<input type="text" value={form.telefone}
|
||||
onChange={e => setForm(f => ({ ...f, telefone: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" />
|
||||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Telefone</label>
|
||||
<input value={form.telefone} onChange={e => setForm(f => ({ ...f, telefone: e.target.value }))} className={input} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">DATA NASCIMENTO</label>
|
||||
<input type="date" value={form.dataNascimento}
|
||||
onChange={e => setForm(f => ({ ...f, dataNascimento: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" />
|
||||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Nascimento</label>
|
||||
<input type="date" value={form.dataNascimento} onChange={e => setForm(f => ({ ...f, dataNascimento: e.target.value }))} className={input} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">CONVÊNIO</label>
|
||||
<input type="text" value={form.convenio}
|
||||
onChange={e => setForm(f => ({ ...f, convenio: e.target.value.toUpperCase() }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" />
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-sm uppercase">CANCELAR</button>
|
||||
<button type="submit" className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg text-sm uppercase">SALVAR</button>
|
||||
|
||||
<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 => (
|
||||
<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>
|
||||
)}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
<AutocompleteField<Paciente>
|
||||
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 => (
|
||||
<span className="flex items-center gap-2 w-full">
|
||||
<UserCheck size={12} className="text-green-400 flex-shrink-0" />
|
||||
<span className="font-bold flex-1">{p.nome}</span>
|
||||
<span className="text-[10px] text-gray-400">{p.cpf}</span>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={onClose} className="px-5 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-xs uppercase">Cancelar</button>
|
||||
<button type="submit" className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg text-xs uppercase">Salvar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -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<Paciente | null>(null);
|
||||
const [isNovoPacienteOpen, setIsNovoPacienteOpen] = useState(false);
|
||||
|
||||
const closeModal = useCallback(() => setModal({ type: null, patient: null }), []);
|
||||
@@ -514,6 +741,22 @@ export const PatientsView: React.FC = () => {
|
||||
<span className="bg-green-100 text-green-700 text-[9px] font-black px-1.5 py-0.5 rounded-full uppercase shrink-0">{p.convenio}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* chips família / indicação */}
|
||||
{(p.grupoFamiliarNome || p.indicadoPorNome) && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{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}
|
||||
</button>
|
||||
)}
|
||||
{p.indicadoPorNome && (
|
||||
<span className="flex items-center gap-1 bg-emerald-50 text-emerald-600 px-1.5 py-0.5 rounded-full text-[9px] font-bold uppercase">
|
||||
<UserCheck size={9} />{p.indicadoPorNome}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<CopyField label="CPF" value={p.cpf} />
|
||||
<CopyField label="Telefone" value={p.telefone} />
|
||||
@@ -534,6 +777,11 @@ export const PatientsView: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* FAMILIA MODAL */}
|
||||
{familiaModal && (
|
||||
<FamiliaModal paciente={familiaModal} onClose={() => setFamiliaModal(null)} />
|
||||
)}
|
||||
|
||||
{/* NOVO PACIENTE MODAL */}
|
||||
{isNovoPacienteOpen && (
|
||||
<NovoPacienteModal onClose={() => setIsNovoPacienteOpen(false)} onSaved={refresh} />
|
||||
|
||||
Reference in New Issue
Block a user