d064aa9058
- Tabelas contratos e contrato_itens com migrações automáticas - Endpoints CRUD com itens em transação - ContratosView: listagem, filtro por status, cards, stats - ContratoModal: wizard 3 abas (Dados/Itens/Cláusulas), catálogo de procedimentos, DnD para reordenar, financeiro completo, print A4 - Sidebar: novo item CONTRATOS - App.tsx: nova rota /contratos com permissões dentista/funcionario - PatientsView: botão CONTRATOS no menu financeiro Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1317 lines
88 KiB
TypeScript
1317 lines
88 KiB
TypeScript
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, ChevronLeft, ChevronRight, Save, Stethoscope, ClipboardList, BarChart3, MapPin } from 'lucide-react';
|
|
import { HybridBackend } from '../services/backend.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);
|
|
useEffect(() => {
|
|
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 => { const arr = Array.isArray(r) ? r : []; setResults(arr); setOpen(arr.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 familiar vinculado
|
|
const FamiliaModal: React.FC<{ paciente: Paciente; onClose: () => void }> = ({ paciente, onClose }) => {
|
|
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-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-2xl flex flex-col 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">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">
|
|
{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>
|
|
<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>
|
|
) : (
|
|
<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>
|
|
</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);
|
|
const toast = useToast();
|
|
|
|
const [duration, setDuration] = useState(30);
|
|
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
|
const [selectedTime, setSelectedTime] = useState('');
|
|
const [selectedDentistId, setSelectedDentistId] = useState<string | undefined>(undefined);
|
|
const [procedimento, setProcedimento] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (dentistas && !selectedDentistId) {
|
|
setSelectedDentistId(dentistas[0]?.id);
|
|
}
|
|
}, [dentistas, selectedDentistId]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') onClose();
|
|
};
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [onClose]);
|
|
|
|
const selectedDentist = dentistas?.find(d => d.id === selectedDentistId);
|
|
|
|
const generateTimeSlots = useCallback(() => {
|
|
const slots: { time: string, available: boolean }[] = [];
|
|
if (!selectedDentistId || !selectedDate) return slots;
|
|
const startTime = 8 * 60, endTime = 18 * 60, lunchStart = 12 * 60, lunchEnd = 14 * 60;
|
|
const existingAppointments = (agendamentos ?? []).filter(ag =>
|
|
ag.dentistaId === selectedDentistId &&
|
|
new Date(ag.start).toISOString().split('T')[0] === selectedDate
|
|
).map(ag => ({
|
|
start: new Date(ag.start).getHours() * 60 + new Date(ag.start).getMinutes(),
|
|
end: new Date(ag.end).getHours() * 60 + new Date(ag.end).getMinutes(),
|
|
}));
|
|
|
|
for (let time = startTime; time < endTime; time += duration) {
|
|
const slotStart = time, slotEnd = time + duration;
|
|
if (slotEnd > endTime) continue;
|
|
const isInLunch = (slotStart >= lunchStart && slotStart < lunchEnd) || (slotEnd > lunchStart && slotEnd <= lunchEnd) || (slotStart < lunchStart && slotEnd > lunchEnd);
|
|
if (isInLunch) continue;
|
|
const isOccupied = existingAppointments.some(app => (slotStart >= app.start && slotStart < app.end) || (slotEnd > app.start && slotEnd <= app.end) || (slotStart < app.start && slotEnd > app.end));
|
|
if (!isOccupied) {
|
|
const hour = Math.floor(slotStart / 60), minute = slotStart % 60;
|
|
slots.push({ time: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`, available: true });
|
|
}
|
|
}
|
|
return slots;
|
|
}, [selectedDentistId, selectedDate, agendamentos, duration]);
|
|
|
|
const timeSlots = generateTimeSlots();
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!selectedDate || !selectedTime || !selectedDentistId || !procedimento) {
|
|
toast.error("PREENCHA TODOS OS CAMPOS PARA AGENDAR.");
|
|
return;
|
|
}
|
|
const startDateTime = new Date(`${selectedDate}T${selectedTime}:00`);
|
|
const endDateTime = new Date(startDateTime.getTime() + duration * 60000);
|
|
try {
|
|
await HybridBackend.salvarAgendamento({
|
|
pacienteNome: patient.nome,
|
|
dentistaId: selectedDentistId,
|
|
start: startDateTime.toISOString(),
|
|
end: endDateTime.toISOString(),
|
|
status: 'Pendente',
|
|
procedimento: procedimento.toUpperCase(),
|
|
});
|
|
toast.success(`AGENDAMENTO PARA ${patient.nome} SALVO!`);
|
|
refreshAgendamentos();
|
|
onClose();
|
|
} catch {
|
|
toast.error("ERRO AO SALVAR AGENDAMENTO.");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
|
<form onSubmit={handleSubmit} className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-xl flex flex-col animate-in fade-in zoom-in-50 duration-200">
|
|
<div className="p-5 border-b border-gray-200">
|
|
<div className="flex justify-between items-center">
|
|
<h2 className="text-lg font-bold text-gray-800 flex items-center gap-2">
|
|
<Calendar size={20} className="text-orange-500" /> AGENDAR CONSULTA
|
|
</h2>
|
|
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">PACIENTE</label>
|
|
<div className="w-full bg-gray-100 p-3 rounded-lg mt-1 font-bold text-gray-800 uppercase">{patient.nome}</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">DENTISTA RESPONSÁVEL</label>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<select value={selectedDentistId} onChange={(e) => setSelectedDentistId(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select">
|
|
{dentistas?.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
|
</select>
|
|
<div className="w-7 h-7 rounded-full border-2 border-white shadow flex-shrink-0" style={{ backgroundColor: selectedDentist?.corAgenda || '#ccc' }}></div>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">DATA</label>
|
|
<input type="date" value={selectedDate} onChange={e => setSelectedDate(e.target.value)} className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">TEMPO</label>
|
|
<select value={duration} onChange={e => setDuration(Number(e.target.value))} className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select">
|
|
<option value={15}>15 MIN</option>
|
|
<option value={30}>30 MIN</option>
|
|
<option value={45}>45 MIN</option>
|
|
<option value={60}>60 MIN</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">HORÁRIOS</label>
|
|
<div className="grid grid-cols-6 gap-2 mt-2">
|
|
{timeSlots.map(slot => (
|
|
<button type="button" key={slot.time} onClick={() => setSelectedTime(slot.time)}
|
|
className={`py-2 rounded-lg text-sm font-bold border transition-colors ${selectedTime === slot.time ? 'bg-blue-600 text-white border-blue-600' : 'bg-white border-gray-300 hover:border-blue-500'}`}>
|
|
{slot.time}
|
|
</button>
|
|
))}
|
|
{timeSlots.length === 0 && <p className="col-span-6 text-center text-xs text-gray-500 p-4 bg-gray-50 rounded-lg">NENHUM HORÁRIO DISPONÍVEL PARA ESTA DATA/DURAÇÃO.</p>}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase">PROCEDIMENTO</label>
|
|
<input type="text" value={procedimento} onChange={(e) => setProcedimento(e.target.value.toUpperCase())} placeholder="EX: AVALIAÇÃO..."
|
|
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 uppercase-input" />
|
|
</div>
|
|
</div>
|
|
<div className="p-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
|
|
<button type="button" onClick={onClose} className="px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-bold rounded-lg transition-colors text-sm uppercase">CANCELAR</button>
|
|
<button type="submit" className="px-6 py-2 bg-orange-500 hover:bg-orange-600 text-white font-bold rounded-lg shadow-sm transition-colors text-sm uppercase">CONFIRMAR</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)
|
|
};
|
|
|
|
// ----------- NOVO PACIENTE MODAL -----------
|
|
const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
|
|
const toast = useToast();
|
|
const [form, setForm] = useState({ nome: '', cpf: '', telefone: '', dataNascimento: '', convenio: '', email: '' });
|
|
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(); };
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
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 {
|
|
const id = Date.now().toString();
|
|
await HybridBackend.savePaciente({
|
|
...form, id,
|
|
grupoFamiliarId: familiar?.id,
|
|
grupoFamiliarRelacao: familiarRelacao || undefined,
|
|
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/40 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-2xl flex flex-col 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={18} /></button>
|
|
</div>
|
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-5 space-y-4">
|
|
<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" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<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="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-3">
|
|
<div>
|
|
<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="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Convênio</label>
|
|
<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>
|
|
<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="border-t border-gray-100 pt-3 space-y-3">
|
|
{/* 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">{p.nome}</span>
|
|
<span className="text-[10px] text-gray-400">{p.cpf}</span>
|
|
</span>
|
|
)}
|
|
/>
|
|
{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>
|
|
)}
|
|
|
|
{/* 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>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ----------- HISTORICO MODAL -----------
|
|
const HistoricoModal: React.FC<{ patient: Paciente; onClose: () => void }> = ({ patient, onClose }) => {
|
|
const { data: agendamentos, isLoading } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
|
const meus = agendamentos?.filter(a => a.pacienteNome === patient.nome) || [];
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-xl flex flex-col 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">HISTÓRICO — {patient.nome}</h2>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
|
{isLoading && <p className="text-center text-gray-400 text-sm">CARREGANDO...</p>}
|
|
{!isLoading && meus.length === 0 && <p className="text-center text-gray-400 text-sm py-6">NENHUM AGENDAMENTO ENCONTRADO.</p>}
|
|
{meus.map(a => (
|
|
<div key={a.id} className="flex items-center justify-between border border-gray-100 rounded-lg p-3 bg-gray-50">
|
|
<div>
|
|
<p className="font-bold text-gray-800 text-sm uppercase">{a.procedimento}</p>
|
|
<p className="text-xs text-gray-500">{new Date(a.start).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</p>
|
|
</div>
|
|
<span className={`text-xs font-bold px-2 py-1 rounded-full uppercase ${a.status === 'Confirmado' ? 'bg-green-100 text-green-700' : a.status === 'Pendente' ? 'bg-amber-100 text-amber-700' : 'bg-gray-100 text-gray-600'}`}>
|
|
{a.status}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="p-4 border-t border-gray-200 flex justify-end">
|
|
<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">FECHAR</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ----------- EDITAR PACIENTE MODAL (4 páginas) -----------
|
|
const ESTADOS_BR = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO'];
|
|
const EDIT_STEPS = ['Dados', 'Família', 'Endereço', 'Histórico'];
|
|
|
|
const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; onSaved: () => void }> = ({ patient, onClose, onSaved }) => {
|
|
const toast = useToast();
|
|
const { data: planos } = useHybridBackend<Plano[]>(HybridBackend.getPlanos);
|
|
const { data: agendamentos } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
|
const [page, setPage] = useState(0);
|
|
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 [familiarRelacao, setFamiliarRelacao] = useState(patient.grupoFamiliarRelacao || '');
|
|
const [indicadoPor, setIndicadoPor] = useState<{ id: string; nome: string } | null>(
|
|
patient.indicadoPorId ? { id: patient.indicadoPorId, nome: patient.indicadoPorNome || '' } : null
|
|
);
|
|
|
|
const meusAgendamentos = (agendamentos ?? []).filter(a => a.pacienteNome === patient.nome);
|
|
|
|
useEffect(() => {
|
|
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
document.addEventListener('keydown', h);
|
|
return () => document.removeEventListener('keydown', h);
|
|
}, [onClose]);
|
|
|
|
const handleSubmit = async () => {
|
|
try {
|
|
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.'); }
|
|
};
|
|
|
|
const inp = "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/40 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-2xl flex flex-col animate-in fade-in zoom-in-50 duration-200">
|
|
|
|
{/* Header */}
|
|
<div className="p-5 border-b border-gray-100 flex justify-between items-center flex-shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<Pencil size={15} className="text-blue-500" />
|
|
<div>
|
|
<h2 className="text-sm font-black text-gray-800 uppercase">Editar Paciente</h2>
|
|
<p className="text-[10px] text-gray-400 font-medium uppercase">{patient.nome}</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
|
|
</div>
|
|
|
|
{/* Step indicator */}
|
|
<div className="px-6 pt-4 pb-0 flex-shrink-0">
|
|
<div className="flex items-center">
|
|
{EDIT_STEPS.map((s, i) => (
|
|
<React.Fragment key={i}>
|
|
<button onClick={() => setPage(i)}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] font-black uppercase transition-all whitespace-nowrap ${
|
|
page === i ? 'bg-blue-600 text-white' : i < page ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
|
|
}`}>
|
|
<span className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-black">{i < page ? '✓' : i + 1}</span>
|
|
{s}
|
|
</button>
|
|
{i < EDIT_STEPS.length - 1 && (
|
|
<div className={`flex-1 h-0.5 mx-1 ${i < page ? 'bg-green-300' : 'bg-gray-200'}`} />
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Page content */}
|
|
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
|
|
|
|
{/* Página 1: Dados básicos */}
|
|
{page === 0 && (<>
|
|
<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={inp} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<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={inp} />
|
|
</div>
|
|
<div>
|
|
<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={inp} />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<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={inp} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Convênio</label>
|
|
<select value={form.convenio || ''} onChange={e => setForm(f => ({ ...f, convenio: e.target.value }))} className={inp}>
|
|
<option value="">— Particular —</option>
|
|
{planos?.map(p => <option key={p.id} value={p.nome}>{p.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<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={inp} />
|
|
</div>
|
|
</>)}
|
|
|
|
{/* Página 2: Família e indicação */}
|
|
{page === 1 && (
|
|
<div className="space-y-4">
|
|
<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">{p.nome}</span>
|
|
<span className="text-[10px] text-gray-400">{p.cpf}</span>
|
|
</span>
|
|
)}
|
|
/>
|
|
{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={inp}
|
|
/>
|
|
<datalist id="relacoes-list-editar">
|
|
{RELACOES_COMUNS.map(r => <option key={r} value={r} />)}
|
|
</datalist>
|
|
</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>
|
|
)}
|
|
|
|
{/* Página 3: Endereço */}
|
|
{page === 2 && (
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div>
|
|
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">CEP</label>
|
|
<input value={form.cep || ''} onChange={e => setForm(f => ({ ...f, cep: e.target.value }))} className={inp} placeholder="00000-000" maxLength={9} />
|
|
</div>
|
|
<div className="col-span-2">
|
|
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Logradouro</label>
|
|
<input value={form.logradouro || ''} onChange={e => setForm(f => ({ ...f, logradouro: e.target.value.toUpperCase() }))} className={inp} placeholder="RUA, AV, TRAVESSA..." />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div>
|
|
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Número</label>
|
|
<input value={form.numero || ''} onChange={e => setForm(f => ({ ...f, numero: e.target.value }))} className={inp} placeholder="123" />
|
|
</div>
|
|
<div className="col-span-2">
|
|
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Complemento</label>
|
|
<input value={form.complemento || ''} onChange={e => setForm(f => ({ ...f, complemento: e.target.value.toUpperCase() }))} className={inp} placeholder="APTO, BLOCO..." />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="col-span-2">
|
|
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Bairro</label>
|
|
<input value={form.bairro || ''} onChange={e => setForm(f => ({ ...f, bairro: e.target.value.toUpperCase() }))} className={inp} placeholder="BAIRRO" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">UF</label>
|
|
<select value={form.estado || ''} onChange={e => setForm(f => ({ ...f, estado: e.target.value }))} className={inp}>
|
|
<option value="">—</option>
|
|
{ESTADOS_BR.map(uf => <option key={uf}>{uf}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Cidade</label>
|
|
<input value={form.cidade || ''} onChange={e => setForm(f => ({ ...f, cidade: e.target.value.toUpperCase() }))} className={inp} placeholder="CIDADE" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Página 4: Histórico e observações */}
|
|
{page === 3 && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Observações Clínicas</label>
|
|
<textarea
|
|
rows={4}
|
|
value={form.observacoes || ''}
|
|
onChange={e => setForm(f => ({ ...f, observacoes: e.target.value }))}
|
|
className={inp + " resize-none"}
|
|
placeholder="ALERGIAS, HISTÓRICO MÉDICO, OBSERVAÇÕES..."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] font-black text-gray-500 uppercase tracking-wider mb-2">
|
|
Histórico de Atendimentos <span className="text-blue-600">({meusAgendamentos.length})</span>
|
|
</p>
|
|
{meusAgendamentos.length === 0 && (
|
|
<p className="text-xs text-gray-400 text-center py-4 border-2 border-dashed border-gray-100 rounded-xl">Nenhum atendimento registrado.</p>
|
|
)}
|
|
<div className="space-y-1.5 max-h-52 overflow-y-auto">
|
|
{meusAgendamentos.map(a => (
|
|
<div key={a.id} className="flex items-center justify-between border border-gray-100 rounded-lg p-2.5 bg-gray-50/70">
|
|
<div>
|
|
<p className="font-bold text-gray-800 text-xs uppercase">{a.procedimento}</p>
|
|
<p className="text-[10px] text-gray-400">{new Date(a.start).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' })}</p>
|
|
</div>
|
|
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${
|
|
a.status === 'Confirmado' ? 'bg-green-100 text-green-700' :
|
|
a.status === 'Pendente' ? 'bg-amber-100 text-amber-700' :
|
|
a.status === 'Faltou' ? 'bg-red-100 text-red-600' :
|
|
'bg-gray-100 text-gray-600'
|
|
}`}>{a.status}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer navigation */}
|
|
<div className="p-4 border-t border-gray-100 flex justify-between items-center flex-shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => page > 0 ? setPage(p => p - 1) : onClose()}
|
|
className="px-5 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-xs uppercase flex items-center gap-1.5"
|
|
>
|
|
<ChevronLeft size={14} /> {page === 0 ? 'Cancelar' : 'Voltar'}
|
|
</button>
|
|
{page < EDIT_STEPS.length - 1 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setPage(p => p + 1)}
|
|
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg text-xs uppercase flex items-center gap-1.5"
|
|
>
|
|
Próximo <ChevronRight size={14} />
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
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"
|
|
>
|
|
<Save size={14} /> Salvar Tudo
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
import { PageHeader } from '../components/PageHeader.tsx';
|
|
import { ReceitaModal } from './ReceitaModal.tsx';
|
|
import { PedidoExameModal } from './PedidoExameModal.tsx';
|
|
import { useGTOStore } from './LancarGTO.tsx';
|
|
import { LancarProcedimentoModal } from './LancarProcedimentoModal.tsx';
|
|
import { ContratoModal } from './ContratoModal.tsx';
|
|
|
|
// ----------- TRATAMENTOS MODAL -----------
|
|
const TratamentosModal: React.FC<{ patient: Paciente; onClose: () => void }> = ({ patient, onClose }) => {
|
|
const { data: agendamentos, isLoading } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
|
const meus = (agendamentos ?? []).filter(a => a.pacienteNome === patient.nome);
|
|
const concluidos = meus.filter(a => a.status === 'Concluido');
|
|
const faltou = meus.filter(a => a.status === 'Faltou');
|
|
|
|
const procedureCounts = concluidos.reduce<Record<string, number>>((acc, a) => {
|
|
acc[a.procedimento] = (acc[a.procedimento] || 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
|
|
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-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-2xl flex flex-col animate-in fade-in zoom-in-50 duration-200">
|
|
<div className="p-5 border-b border-gray-100 flex items-center justify-between flex-shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 bg-teal-100 rounded-lg flex items-center justify-center"><Stethoscope size={16} className="text-teal-600" /></div>
|
|
<div>
|
|
<p className="text-xs font-black text-gray-800 uppercase">Tratamentos</p>
|
|
<p className="text-[10px] text-gray-400 font-medium uppercase">{patient.nome}</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-3 gap-3 p-5 flex-shrink-0">
|
|
<div className="bg-blue-50 rounded-xl p-3 text-center">
|
|
<p className="text-2xl font-black text-blue-600">{meus.length}</p>
|
|
<p className="text-[10px] font-bold text-blue-400 uppercase">Total Consultas</p>
|
|
</div>
|
|
<div className="bg-green-50 rounded-xl p-3 text-center">
|
|
<p className="text-2xl font-black text-green-600">{concluidos.length}</p>
|
|
<p className="text-[10px] font-bold text-green-400 uppercase">Concluídas</p>
|
|
</div>
|
|
<div className="bg-red-50 rounded-xl p-3 text-center">
|
|
<p className="text-2xl font-black text-red-600">{faltou.length}</p>
|
|
<p className="text-[10px] font-bold text-red-400 uppercase">Faltas</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-5 pb-5 space-y-5">
|
|
{/* Procedimentos realizados */}
|
|
{Object.keys(procedureCounts).length > 0 && (
|
|
<div>
|
|
<p className="text-[10px] font-black text-gray-500 uppercase tracking-wider mb-2">Procedimentos Realizados</p>
|
|
<div className="space-y-1.5">
|
|
{Object.entries(procedureCounts).sort(([,a],[,b]) => b - a).map(([proc, count]) => (
|
|
<div key={proc} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
|
<p className="text-xs font-bold text-gray-700 uppercase">{proc}</p>
|
|
<span className="text-[10px] font-black bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">{count}x</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Histórico cronológico */}
|
|
<div>
|
|
<p className="text-[10px] font-black text-gray-500 uppercase tracking-wider mb-2">Histórico Cronológico ({meus.length})</p>
|
|
{isLoading && <p className="text-xs text-gray-400 text-center py-4">Carregando...</p>}
|
|
{!isLoading && meus.length === 0 && <p className="text-xs text-gray-400 text-center py-4 border-2 border-dashed border-gray-100 rounded-xl">Nenhum atendimento registrado.</p>}
|
|
<div className="space-y-1.5">
|
|
{[...meus].sort((a, b) => new Date(b.start).getTime() - new Date(a.start).getTime()).map(a => (
|
|
<div key={a.id} className="flex items-center justify-between border border-gray-100 rounded-lg p-2.5">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-bold text-gray-800 text-xs uppercase truncate">{a.procedimento}</p>
|
|
<p className="text-[10px] text-gray-400">{new Date(a.start).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' })}</p>
|
|
</div>
|
|
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase flex-shrink-0 ml-2 ${
|
|
a.status === 'Concluido' ? 'bg-green-100 text-green-700' :
|
|
a.status === 'Confirmado' ? 'bg-blue-100 text-blue-700' :
|
|
a.status === 'Pendente' ? 'bg-amber-100 text-amber-700' :
|
|
'bg-red-100 text-red-600'
|
|
}`}>{a.status}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 border-t border-gray-100 flex justify-end flex-shrink-0">
|
|
<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>
|
|
);
|
|
};
|
|
|
|
// ----------- RELATORIO PACIENTE MODAL -----------
|
|
const RelatorioPacienteModal: React.FC<{ patient: Paciente; onClose: () => void }> = ({ patient, onClose }) => {
|
|
const { data: agendamentos } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
|
const meus = (agendamentos ?? []).filter(a => a.pacienteNome === patient.nome);
|
|
const concluidos = meus.filter(a => a.status === 'Concluido').length;
|
|
const faltou = meus.filter(a => a.status === 'Faltou').length;
|
|
const hoje = new Date().toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' });
|
|
|
|
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-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-2xl flex flex-col animate-in fade-in zoom-in-50 duration-200">
|
|
<div className="p-4 border-b border-gray-100 flex items-center justify-between flex-shrink-0 print:hidden">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 bg-violet-100 rounded-lg flex items-center justify-center"><BarChart3 size={16} className="text-violet-600" /></div>
|
|
<div>
|
|
<p className="text-xs font-black text-gray-800 uppercase">Relatório do Paciente</p>
|
|
<p className="text-[10px] text-gray-400 font-medium uppercase">{patient.nome}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={() => window.print()} className="flex items-center gap-1.5 px-3 py-2 bg-gray-900 text-white rounded-lg text-xs font-bold hover:bg-black">
|
|
<Printer size={13} /> Imprimir
|
|
</button>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto bg-gray-50 p-6 print:p-0 print:bg-white">
|
|
<div className="max-w-2xl mx-auto bg-white rounded-2xl shadow-sm border border-gray-200 p-8 print:shadow-none print:rounded-none print:border-0 print:max-w-full space-y-6">
|
|
|
|
{/* Cabeçalho */}
|
|
<div className="border-b-2 border-gray-800 pb-4">
|
|
<h1 className="text-lg font-black text-gray-900 uppercase">Relatório do Paciente</h1>
|
|
<p className="text-xs text-gray-500 mt-1">Gerado em {hoje}</p>
|
|
</div>
|
|
|
|
{/* Dados pessoais */}
|
|
<div>
|
|
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-100 pb-1">Dados Pessoais</h2>
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div><span className="text-gray-400 text-xs font-bold uppercase block">Nome</span><p className="font-bold text-gray-800 uppercase">{patient.nome}</p></div>
|
|
<div><span className="text-gray-400 text-xs font-bold uppercase block">CPF</span><p className="font-bold text-gray-800">{patient.cpf || '—'}</p></div>
|
|
<div><span className="text-gray-400 text-xs font-bold uppercase block">Telefone</span><p className="font-bold text-gray-800">{patient.telefone || '—'}</p></div>
|
|
<div><span className="text-gray-400 text-xs font-bold uppercase block">E-mail</span><p className="font-bold text-gray-800">{patient.email || '—'}</p></div>
|
|
<div><span className="text-gray-400 text-xs font-bold uppercase block">Nascimento</span><p className="font-bold text-gray-800">{patient.dataNascimento || '—'}</p></div>
|
|
<div><span className="text-gray-400 text-xs font-bold uppercase block">Convênio</span><p className="font-bold text-gray-800">{patient.convenio || 'Particular'}</p></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Endereço */}
|
|
{(patient.logradouro || patient.cidade) && (
|
|
<div>
|
|
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-100 pb-1 flex items-center gap-1"><MapPin size={11} /> Endereço</h2>
|
|
<p className="text-sm text-gray-700">
|
|
{[patient.logradouro, patient.numero, patient.complemento].filter(Boolean).join(', ')}
|
|
{patient.bairro && <span className="block">{patient.bairro}{patient.cidade && ` — ${patient.cidade}/${patient.estado}`}</span>}
|
|
{patient.cep && <span className="block text-gray-400 text-xs">CEP {patient.cep}</span>}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Vínculos */}
|
|
{(patient.grupoFamiliarNome || patient.indicadoPorNome) && (
|
|
<div>
|
|
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-100 pb-1">Vínculos</h2>
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
{patient.grupoFamiliarNome && <div><span className="text-gray-400 text-xs font-bold uppercase block">Familiar</span><p className="font-bold text-gray-800 uppercase">{patient.grupoFamiliarNome} {patient.grupoFamiliarRelacao && `(${patient.grupoFamiliarRelacao})`}</p></div>}
|
|
{patient.indicadoPorNome && <div><span className="text-gray-400 text-xs font-bold uppercase block">Indicado por</span><p className="font-bold text-gray-800 uppercase">{patient.indicadoPorNome}</p></div>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Resumo de atendimentos */}
|
|
<div>
|
|
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-100 pb-1">Resumo de Atendimentos</h2>
|
|
<div className="grid grid-cols-3 gap-3 mb-4">
|
|
<div className="bg-blue-50 rounded-xl p-3 text-center"><p className="text-xl font-black text-blue-600">{meus.length}</p><p className="text-[10px] font-bold text-blue-400 uppercase">Total</p></div>
|
|
<div className="bg-green-50 rounded-xl p-3 text-center"><p className="text-xl font-black text-green-600">{concluidos}</p><p className="text-[10px] font-bold text-green-400 uppercase">Concluídos</p></div>
|
|
<div className="bg-red-50 rounded-xl p-3 text-center"><p className="text-xl font-black text-red-600">{faltou}</p><p className="text-[10px] font-bold text-red-400 uppercase">Faltas</p></div>
|
|
</div>
|
|
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
|
{[...meus].sort((a, b) => new Date(b.start).getTime() - new Date(a.start).getTime()).slice(0, 20).map(a => (
|
|
<div key={a.id} className="flex items-center justify-between border border-gray-100 rounded-lg px-3 py-2">
|
|
<div><p className="text-xs font-bold text-gray-800 uppercase">{a.procedimento}</p><p className="text-[10px] text-gray-400">{new Date(a.start).toLocaleDateString('pt-BR')}</p></div>
|
|
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${a.status === 'Concluido' ? 'bg-green-100 text-green-700' : a.status === 'Faltou' ? 'bg-red-100 text-red-600' : 'bg-amber-100 text-amber-700'}`}>{a.status}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Observações */}
|
|
{patient.observacoes && (
|
|
<div>
|
|
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-100 pb-1">Observações Clínicas</h2>
|
|
<p className="text-sm text-gray-700 italic">{patient.observacoes}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="pt-6 border-t-2 border-gray-800 flex justify-between items-end">
|
|
<div><p className="text-xs text-gray-500">Assinatura / Responsável</p><div className="h-8" /></div>
|
|
<p className="text-xs text-gray-400">{hoje}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const CopyField: React.FC<{ label: string; value?: string | null }> = ({ label, value }) => {
|
|
const [copied, setCopied] = useState(false);
|
|
const copy = () => {
|
|
if (!value) return;
|
|
navigator.clipboard.writeText(value).then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
});
|
|
};
|
|
return (
|
|
<div className="flex items-center justify-between bg-gray-50/70 border border-gray-200/50 rounded-lg px-2 py-1.5 gap-1 min-w-0">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-gray-400 text-[9px] font-bold uppercase leading-none mb-0.5">{label}</p>
|
|
<p className="text-gray-800 font-semibold text-xs truncate leading-tight">{value || '—'}</p>
|
|
</div>
|
|
{value && (
|
|
<button onClick={copy} className="flex-shrink-0 text-gray-300 hover:text-blue-500 transition-colors ml-1" title={`Copiar ${label}`}>
|
|
{copied ? <CheckCircle2 size={13} className="text-green-500" /> : <Copy size={13} />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type SortOpt = 'recentes' | 'az' | 'za' | 'convenio';
|
|
const SORT_OPTS: { key: SortOpt; label: string }[] = [
|
|
{ key: 'recentes', label: 'Recentes' },
|
|
{ key: 'az', label: 'A→Z' },
|
|
{ key: 'za', label: 'Z→A' },
|
|
{ key: 'convenio', label: 'Convênio' },
|
|
];
|
|
|
|
export const PatientsView: React.FC = () => {
|
|
const [busca, setBusca] = useState('');
|
|
const [sort, setSort] = useState<SortOpt>('recentes');
|
|
const debouncedBusca = useDebounce(busca, 500);
|
|
|
|
const fetcher = useCallback(() => HybridBackend.getPacientes(debouncedBusca, sort), [debouncedBusca, sort]);
|
|
const { data: result, isLoading, error, refresh } = useHybridBackend<{ data: Paciente[]; total: number }>(fetcher, [debouncedBusca, sort]);
|
|
const pacientes = result?.data ?? [];
|
|
const totalPacientes = result?.total ?? 0;
|
|
|
|
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 }), []);
|
|
|
|
// Effect to handle Esc key to close modals
|
|
useEffect(() => {
|
|
const isModalActive = modal.type !== null && modal.type !== 'agendamento' && modal.type !== 'historico' && modal.type !== 'editar';
|
|
if (!isModalActive) return;
|
|
const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') closeModal(); };
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [modal.type, closeModal]);
|
|
|
|
// Asaas form state
|
|
const [asaasForm, setAsaasForm] = useState({ tratamento: '', valorTotal: '', desconto: '', entrada: '', parcelas: 1, vencimento: new Date().toISOString().split('T')[0] });
|
|
useEffect(() => {
|
|
if (modal.type === 'asaas') {
|
|
setAsaasForm({ tratamento: '', valorTotal: '', desconto: '', entrada: '', parcelas: 1, vencimento: new Date().toISOString().split('T')[0] });
|
|
}
|
|
}, [modal.type, modal.patient]);
|
|
|
|
const handleAsaasFormChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
|
const { name, value } = e.target;
|
|
setAsaasForm(prev => ({ ...prev, [name]: value }));
|
|
};
|
|
|
|
const valorTotalNum = parseFloat(asaasForm.valorTotal) || 0;
|
|
const descontoNum = parseFloat(asaasForm.desconto) || 0;
|
|
const entradaNum = parseFloat(asaasForm.entrada) || 0;
|
|
const saldoAParcelar = valorTotalNum - descontoNum - entradaNum;
|
|
const valorParcela = saldoAParcelar > 0 && asaasForm.parcelas > 0 ? saldoAParcelar / Number(asaasForm.parcelas) : 0;
|
|
|
|
const handleAsaasSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
toast.success(`COBRANÇA GERADA NO ASAAS PARA ${modal.patient?.nome}!`);
|
|
closeModal();
|
|
};
|
|
|
|
const displayedPacientes = pacientes;
|
|
|
|
return (
|
|
<div className="h-full flex flex-col relative">
|
|
<PageHeader title="PACIENTES">
|
|
<button onClick={() => setIsNovoPacienteOpen(true)} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors uppercase font-bold text-sm shadow-sm">
|
|
<Plus size={18} />
|
|
NOVO PACIENTE
|
|
</button>
|
|
</PageHeader>
|
|
|
|
{/* Search + sort bar */}
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" size={16} />
|
|
<input
|
|
type="text"
|
|
placeholder="Nome ou CPF..."
|
|
className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none bg-white"
|
|
value={busca}
|
|
onChange={(e) => setBusca(e.target.value.toUpperCase())}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
|
{SORT_OPTS.map(o => (
|
|
<button
|
|
key={o.key}
|
|
onClick={() => setSort(o.key)}
|
|
className={`px-2.5 py-1 rounded-md text-[11px] font-bold transition-all whitespace-nowrap ${sort === o.key ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
|
>
|
|
{o.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-3 overflow-y-auto pb-6">
|
|
{isLoading && <div className="col-span-3 text-center p-10 text-gray-500 uppercase font-bold flex justify-center items-center gap-2"><Loader2 className="animate-spin" /> CARREGANDO DADOS DO POSTGRESQL...</div>}
|
|
{error && <div className="col-span-3 text-center p-10 text-red-600 uppercase font-bold">ERRO AO CARREGAR: {error.message}</div>}
|
|
|
|
{displayedPacientes && displayedPacientes.map(p => (
|
|
<div key={p.id} className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-all duration-200 flex flex-col">
|
|
<div className="px-3 pt-3 pb-2 flex-grow flex flex-col gap-2">
|
|
<div className="flex items-start justify-between gap-1.5">
|
|
<h3 className="font-bold text-blue-700 text-[11px] leading-snug uppercase break-words flex-1">{p.nome}</h3>
|
|
{p.convenio && (
|
|
<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.grupoFamiliarRelacao ? `${p.grupoFamiliarRelacao}: ${p.grupoFamiliarNome}` : 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} />
|
|
<CopyField label="Nascimento" value={p.dataNascimento} />
|
|
<CopyField label="Convênio" value={p.convenio} />
|
|
</div>
|
|
</div>
|
|
<div className="flex border-t border-gray-100">
|
|
<button onClick={() => setModal({ type: 'clinical', patient: p })} className="w-1/2 flex items-center justify-center gap-1.5 py-2 text-blue-600 hover:bg-blue-50 rounded-bl-xl text-[10px] font-bold transition-colors uppercase">
|
|
<FolderOpen size={12} /> Clínico
|
|
</button>
|
|
<div className="border-l border-gray-100" />
|
|
<button onClick={() => setModal({ type: 'financial', patient: p })} className="w-1/2 flex items-center justify-center gap-1.5 py-2 text-green-700 hover:bg-green-50 rounded-br-xl text-[10px] font-bold transition-colors uppercase">
|
|
<DollarSign size={12} /> Financeiro
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* FAMILIA MODAL */}
|
|
{familiaModal && (
|
|
<FamiliaModal paciente={familiaModal} onClose={() => setFamiliaModal(null)} />
|
|
)}
|
|
|
|
{/* NOVO PACIENTE MODAL */}
|
|
{isNovoPacienteOpen && (
|
|
<NovoPacienteModal onClose={() => setIsNovoPacienteOpen(false)} onSaved={refresh} />
|
|
)}
|
|
|
|
{/* EDITAR PACIENTE MODAL */}
|
|
{modal.type === 'editar' && modal.patient && (
|
|
<EditarPacienteModal patient={modal.patient} onClose={closeModal} onSaved={refresh} />
|
|
)}
|
|
|
|
{/* HISTORICO MODAL */}
|
|
{modal.type === 'historico' && modal.patient && (
|
|
<HistoricoModal patient={modal.patient} onClose={closeModal} />
|
|
)}
|
|
|
|
{/* MODAL MENU CLÍNICO */}
|
|
{modal.type === 'clinical' && modal.patient && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-2xl flex flex-col p-6 animate-in fade-in zoom-in-50 duration-200">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<div className="flex items-center gap-3 text-gray-700 font-bold text-xl"><FolderOpen size={28} className="text-gray-400" />Menu Clínico</div>
|
|
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
|
</div>
|
|
<p className="text-gray-500 text-sm mb-6 font-bold uppercase">{modal.patient.nome}</p>
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 flex-1 content-start">
|
|
<button onClick={() => setModal({ type: 'agendamento', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-orange-400 hover:bg-orange-50/50 transition-all group shadow-sm"><CalendarDays size={32} className="text-orange-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">AGENDAR</span></button>
|
|
<button onClick={() => setModal({ type: 'historico', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-sky-400 hover:bg-sky-50/50 transition-all group shadow-sm"><Calendar size={32} className="text-sky-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">AGENDAMENTOS</span></button>
|
|
<button onClick={() => setModal({ type: 'tratamentos', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-teal-400 hover:bg-teal-50/50 transition-all group shadow-sm"><Stethoscope size={32} className="text-teal-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">TRATAMENTOS</span></button>
|
|
<button onClick={() => {
|
|
useGTOStore.getState().setPaciente(modal.patient);
|
|
closeModal();
|
|
window.location.hash = '#/lancargto';
|
|
}} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-yellow-400 hover:bg-yellow-50/50 transition-all group shadow-sm"><ClipboardList size={32} className="text-yellow-600 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">GTOs</span></button>
|
|
<button onClick={() => setModal({ type: 'doc-receita', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-red-400 hover:bg-red-50/50 transition-all group shadow-sm"><FileText size={32} className="text-red-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">RECEITA</span></button>
|
|
<button onClick={() => setModal({ type: 'doc-raiox', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-purple-400 hover:bg-purple-50/50 transition-all group shadow-sm"><RadioTower size={32} className="text-purple-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">RAIO-X</span></button>
|
|
<button onClick={() => setModal({ type: 'relatorio-paciente', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-violet-400 hover:bg-violet-50/50 transition-all group shadow-sm"><BarChart3 size={32} className="text-violet-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">RELATÓRIOS</span></button>
|
|
<button onClick={() => setModal({ type: 'editar', patient: modal.patient })} className="flex flex-col items-center justify-center p-5 bg-white border border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50/50 transition-all group shadow-sm"><Pencil size={32} className="text-blue-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm text-center">EDITAR DADOS</span></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* MODAL DE AGENDAMENTO */}
|
|
{modal.type === 'agendamento' && modal.patient && (
|
|
<AgendamentoModal patient={modal.patient} onClose={closeModal} />
|
|
)}
|
|
|
|
{/* RECEITA MODAL */}
|
|
{modal.type === 'doc-receita' && modal.patient && (
|
|
<ReceitaModal paciente={modal.patient} onClose={closeModal} />
|
|
)}
|
|
|
|
{/* PEDIDO DE EXAME MODAL */}
|
|
{modal.type === 'doc-raiox' && modal.patient && (
|
|
<PedidoExameModal paciente={modal.patient} onClose={closeModal} />
|
|
)}
|
|
|
|
{/* TRATAMENTOS MODAL */}
|
|
{modal.type === 'tratamentos' && modal.patient && (
|
|
<TratamentosModal patient={modal.patient} onClose={closeModal} />
|
|
)}
|
|
|
|
{/* RELATÓRIO PACIENTE MODAL */}
|
|
{modal.type === 'relatorio-paciente' && modal.patient && (
|
|
<RelatorioPacienteModal patient={modal.patient} onClose={closeModal} />
|
|
)}
|
|
|
|
{/* LANÇAR PROCEDIMENTO MODAL */}
|
|
{modal.type === 'lancar' && modal.patient && (
|
|
<LancarProcedimentoModal patient={modal.patient} onClose={closeModal} onSaved={refresh} />
|
|
)}
|
|
|
|
{/* CONTRATO MODAL */}
|
|
{modal.type === 'contrato' && modal.patient && (
|
|
<ContratoModal paciente={modal.patient} onClose={closeModal} onSaved={refresh} />
|
|
)}
|
|
|
|
{/* MODAL MENU FINANCEIRO */}
|
|
{modal.type === 'financial' && modal.patient && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-2xl flex flex-col p-6 animate-in fade-in zoom-in-50 duration-200">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<div className="flex items-center gap-3 text-gray-700 font-bold text-xl"><Wallet size={28} className="text-gray-400" />Menu Financeiro</div>
|
|
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
|
</div>
|
|
<p className="text-gray-500 text-sm mb-8 font-bold uppercase">{modal.patient.nome}</p>
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
<button onClick={() => setModal({ type: 'lancar', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-green-400 hover:bg-green-50/50 transition-all group shadow-sm"><PlusCircle size={32} className="text-green-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">LANÇAR</span></button>
|
|
<button onClick={() => setModal({ type: 'asaas', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50/50 transition-all group shadow-sm"><Banknote size={32} className="text-blue-600 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">ASAAS</span></button>
|
|
<button onClick={() => setModal({ type: 'historico', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-gray-400 hover:bg-gray-50/50 transition-all group shadow-sm"><History size={32} className="text-gray-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">HISTÓRICO</span></button>
|
|
<button onClick={() => setModal({ type: 'contrato', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-indigo-400 hover:bg-indigo-50/50 transition-all group shadow-sm"><FileText size={32} className="text-indigo-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">CONTRATOS</span></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ASAAS MODAL */}
|
|
{modal.type === 'asaas' && modal.patient && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
|
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-xl flex flex-col animate-in fade-in zoom-in-50 duration-200">
|
|
<form onSubmit={handleAsaasSubmit}>
|
|
<div className="p-6 border-b border-gray-200">
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-gray-800 flex items-center gap-2">
|
|
<span className="w-6 h-4 bg-yellow-400 rounded-sm flex items-center justify-center text-yellow-800">
|
|
<CreditCard size={14} />
|
|
</span>
|
|
PARCELAMENTO INTELIGENTE (ASAAS)
|
|
</h2>
|
|
<p className="text-sm text-gray-500 mt-2">
|
|
Cliente: <span className="font-bold uppercase">{modal.patient.nome}</span>
|
|
</p>
|
|
</div>
|
|
<button type="button" onClick={closeModal} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">TRATAMENTO</label>
|
|
<input type="text" name="tratamento" value={asaasForm.tratamento} onChange={handleAsaasFormChange} placeholder="EX: ORTODONTIA COMPLETA" className="w-full border border-gray-300 rounded-md p-2 text-sm uppercase-input" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">VALOR TOTAL (R$)</label>
|
|
<input type="number" step="0.01" name="valorTotal" value={asaasForm.valorTotal} onChange={handleAsaasFormChange} placeholder="0,00" className="w-full border border-gray-300 rounded-md p-2 text-sm" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">DESCONTO (R$)</label>
|
|
<input type="number" step="0.01" name="desconto" value={asaasForm.desconto} onChange={handleAsaasFormChange} placeholder="0,00" className="w-full border border-gray-300 rounded-md p-2 text-sm" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">ENTRADA (R$)</label>
|
|
<input type="number" step="0.01" name="entrada" value={asaasForm.entrada} onChange={handleAsaasFormChange} placeholder="0,00" className="w-full border border-gray-300 rounded-md p-2 text-sm" />
|
|
</div>
|
|
<div className="bg-blue-50/70 p-3 rounded-md flex justify-between items-center border border-blue-100/50">
|
|
<span className="font-semibold text-blue-700 text-sm">SALDO A PARCELAR:</span>
|
|
<span className="font-bold text-blue-700 text-lg">{saldoAParcelar.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4 pt-2">
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">PARCELAS (ATÉ 48X)</label>
|
|
<select name="parcelas" value={asaasForm.parcelas} onChange={handleAsaasFormChange} className="w-full border border-gray-300 rounded-md p-2 text-sm bg-white">
|
|
{Array.from({ length: 48 }, (_, i) => i + 1).map(num => (
|
|
<option key={num} value={num}>{num}x</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">VENCIMENTO 1ª PARCELA</label>
|
|
<input type="date" name="vencimento" value={asaasForm.vencimento} onChange={handleAsaasFormChange} className="w-full border border-gray-300 rounded-md p-2 text-sm" />
|
|
</div>
|
|
</div>
|
|
<div className="text-center text-blue-600 font-bold text-lg py-2">
|
|
{asaasForm.parcelas}x DE {valorParcela.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
|
|
</div>
|
|
</div>
|
|
<div className="p-6 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
|
|
<button type="button" onClick={closeModal} className="px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-bold rounded-lg transition-colors 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 shadow-sm flex items-center justify-center gap-2 transition-colors text-sm uppercase">
|
|
<Rocket size={16} />
|
|
GERAR CARNÊ/PIX
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}; |