660 lines
44 KiB
TypeScript
660 lines
44 KiB
TypeScript
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 } from 'lucide-react';
|
|
import { HybridBackend } from '../services/backend.ts';
|
|
import { Paciente, Dentista, Agendamento } from '../types.ts';
|
|
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
|
import { useToast } from '../contexts/ToastContext.tsx';
|
|
|
|
// 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;
|
|
}
|
|
|
|
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-4">
|
|
<form onSubmit={handleSubmit} 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">
|
|
<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="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: '' });
|
|
|
|
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 {
|
|
await HybridBackend.savePaciente({ ...form, id: Math.random().toString() } as any);
|
|
toast.success('PACIENTE CADASTRADO!');
|
|
onSaved();
|
|
onClose();
|
|
} catch {
|
|
toast.error('ERRO AO CADASTRAR PACIENTE.');
|
|
}
|
|
};
|
|
|
|
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
|
|
</h2>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
|
</div>
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
|
<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" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<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" />
|
|
</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" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<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" />
|
|
</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" />
|
|
</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" />
|
|
</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
|
|
</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-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">HISTÓRICO — {patient.nome}</h2>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
|
</div>
|
|
<div className="p-4 max-h-96 overflow-y-auto 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 -----------
|
|
const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; onSaved: () => void }> = ({ patient, onClose, onSaved }) => {
|
|
const toast = useToast();
|
|
const [form, setForm] = useState({ ...patient });
|
|
|
|
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();
|
|
try {
|
|
await HybridBackend.updatePaciente(form);
|
|
toast.success('PACIENTE ATUALIZADO!');
|
|
onSaved();
|
|
onClose();
|
|
} catch {
|
|
toast.error('ERRO AO SALVAR.');
|
|
}
|
|
};
|
|
|
|
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
|
|
</h2>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
|
</div>
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
|
<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" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<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" />
|
|
</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" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<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" />
|
|
</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" />
|
|
</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>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
import { PageHeader } from '../components/PageHeader.tsx';
|
|
|
|
export const PatientsView: React.FC = () => {
|
|
const [busca, setBusca] = useState('');
|
|
const debouncedBusca = useDebounce(busca, 500);
|
|
|
|
const fetcher = useCallback(() => HybridBackend.getPacientes(debouncedBusca), [debouncedBusca]);
|
|
const { data: pacientes, isLoading, error, refresh } = useHybridBackend<Paciente[]>(fetcher, [debouncedBusca]);
|
|
|
|
const toast = useToast();
|
|
|
|
const [modal, setModal] = useState<{ type: string | null; patient: Paciente | null }>({ type: null, patient: 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 handleDocGeneration = () => {
|
|
toast.success(`DOCUMENTO GERADO E SALVO NO DRIVE PARA ${modal.patient?.nome}!`);
|
|
closeModal();
|
|
};
|
|
|
|
const handleAsaasSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
toast.success(`COBRANÇA GERADA NO ASAAS PARA ${modal.patient?.nome}!`);
|
|
closeModal();
|
|
};
|
|
|
|
const displayedPacientes = busca ? pacientes : pacientes?.slice(-3).reverse();
|
|
|
|
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>
|
|
|
|
<div className="bg-white p-4 rounded-xl border border-gray-200 shadow-sm mb-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
|
<input
|
|
type="text"
|
|
placeholder="DIGITE NOME OU CPF PARA BUSCAR..."
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none uppercase-input"
|
|
value={busca}
|
|
onChange={(e) => setBusca(e.target.value.toUpperCase())}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{!busca && (
|
|
<div className="mb-4">
|
|
<h3 className="text-sm font-bold text-gray-500 uppercase">
|
|
ÚLTIMOS CADASTRADOS ({pacientes?.length ?? 0})
|
|
</h3>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 overflow-y-auto pb-10">
|
|
{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 MYSQL...</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-lg transition-all duration-300 flex flex-col">
|
|
<div className="p-5 flex-grow flex flex-col">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<h3 className="font-bold text-blue-700 text-lg truncate pr-2 uppercase">{p.nome}</h3>
|
|
{p.convenio && (
|
|
<span className="bg-green-100 text-green-800 text-xs font-bold px-2.5 py-1 rounded-full uppercase shrink-0">{p.convenio}</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 mb-4 flex-grow">
|
|
<div className="bg-gray-50/70 border border-gray-200/50 rounded-lg p-2">
|
|
<label className="text-gray-400 text-[10px] font-bold uppercase">CPF</label>
|
|
<p className="text-gray-800 font-semibold text-sm truncate">{p.cpf}</p>
|
|
</div>
|
|
<div className="bg-gray-50/70 border border-gray-200/50 rounded-lg p-2">
|
|
<label className="text-gray-400 text-[10px] font-bold uppercase">TEL</label>
|
|
<p className="text-gray-800 font-semibold text-sm truncate">{p.telefone}</p>
|
|
</div>
|
|
<div className="bg-gray-50/70 border border-gray-200/50 rounded-lg p-2">
|
|
<label className="text-gray-400 text-[10px] font-bold uppercase">NASC</label>
|
|
<p className="text-gray-800 font-semibold text-sm truncate">{p.dataNascimento}</p>
|
|
</div>
|
|
<div className="bg-gray-50/70 border border-gray-200/50 rounded-lg p-2">
|
|
<label className="text-gray-400 text-[10px] font-bold uppercase">CONV</label>
|
|
<p className="text-gray-800 font-semibold text-sm truncate">{p.convenio}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex border-t border-gray-200 mt-auto">
|
|
<button onClick={() => setModal({ type: 'clinical', patient: p })} className="w-1/2 flex items-center justify-center gap-2 py-3 text-blue-600 hover:bg-blue-50 rounded-bl-xl text-sm font-bold transition-colors uppercase">
|
|
<FolderOpen size={16} /> Clínico
|
|
</button>
|
|
<div className="border-l border-gray-200"></div>
|
|
<button onClick={() => setModal({ type: 'financial', patient: p })} className="w-1/2 flex items-center justify-center gap-2 py-3 text-green-700 hover:bg-green-50 rounded-br-xl text-sm font-bold transition-colors uppercase">
|
|
<DollarSign size={16} /> Financeiro
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 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">
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg 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-8 font-bold uppercase">{modal.patient.nome}</p>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<button onClick={() => setModal({ type: 'agendamento', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 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">AGENDAR</span></button>
|
|
<button onClick={() => setModal({ type: 'doc-receita', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 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">RECEITA</span></button>
|
|
<button onClick={() => setModal({ type: 'doc-raiox', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 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">RAIO-X</span></button>
|
|
<button onClick={() => setModal({ type: 'editar', 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"><Pencil size={32} className="text-blue-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">EDITAR DADOS</span></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* MODAL DE AGENDAMENTO */}
|
|
{modal.type === 'agendamento' && modal.patient && (
|
|
<AgendamentoModal patient={modal.patient} onClose={closeModal} />
|
|
)}
|
|
|
|
{/* MODAL DOCUMENTOS */}
|
|
{(modal.type === 'doc-receita' || modal.type === 'doc-raiox') && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm">
|
|
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md w-full animate-in fade-in zoom-in-50 duration-200">
|
|
<h3 className="text-xl font-bold mb-4 uppercase">GERAR {modal.type === 'doc-receita' ? 'RECEITA' : 'PEDIDO DE RAIO-X'}</h3>
|
|
<p className="text-gray-600 mb-4 text-sm font-bold uppercase">O SISTEMA IRÁ GERAR UM DOCUMENTO NO GOOGLE DOCS USANDO O MODELO PADRÃO.</p>
|
|
<div className="flex justify-end gap-3">
|
|
<button onClick={closeModal} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg text-xs font-bold uppercase">CANCELAR</button>
|
|
<button onClick={handleDocGeneration} className="px-4 py-2 bg-gray-800 text-white rounded-lg flex items-center gap-2 text-xs font-bold uppercase"><Printer size={16} /> GERAR PDF</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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">
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg 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-3 gap-4">
|
|
<button onClick={() => toast.info?.('Em breve: Lançar Procedimento')} 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>
|
|
</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 overflow-y-auto p-4">
|
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl 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="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>
|
|
);
|
|
}; |