riseup-squad18/src/components/agenda/ScheduleAppointmentModal.tsx
guisilvagomes 3443e46ca3 feat: implementa chatbot AI, gerenciamento de disponibilidade médica, visualização de laudos e melhorias no painel da secretária
- Adiciona chatbot AI com interface responsiva e posicionamento otimizado
- Implementa gerenciamento completo de disponibilidade e exceções médicas
- Adiciona modal de visualização detalhada de laudos no painel do paciente
- Corrige relatórios da secretária para mostrar nomes de médicos
- Implementa mensagem de boas-vindas personalizada com nome real
- Remove mensagens duplicadas de login
- Remove arquivo cleanup-deps.ps1 desnecessário
- Atualiza README com todas as novas funcionalidades
2025-11-05 16:51:33 -03:00

425 lines
14 KiB
TypeScript

// UI/UX: adiciona refs e ícones para melhorar acessibilidade e feedback visual
import React, { useState, useEffect, useMemo, useRef } from "react";
import {
Calendar as CalendarIcon,
Clock,
Loader2,
Stethoscope,
X,
} from "lucide-react";
import toast from "react-hot-toast";
import {
appointmentService,
doctorService,
patientService,
} from "../../services/index";
import type { Patient } from "../../services/patients/types";
import type { Doctor } from "../../services/doctors/types";
import AvailableSlotsPicker from "./AvailableSlotsPicker";
interface Props {
isOpen: boolean;
onClose: () => void;
patientId?: string; // opcional: quando não informado, seleciona paciente no modal
patientName?: string; // opcional
onSuccess?: () => void;
}
const ScheduleAppointmentModal: React.FC<Props> = ({
isOpen,
onClose,
patientId,
patientName,
onSuccess,
}) => {
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loadingDoctors, setLoadingDoctors] = useState(false);
const [patients, setPatients] = useState<Patient[]>([]);
const [loadingPatients, setLoadingPatients] = useState(false);
const [selectedDoctorId, setSelectedDoctorId] = useState("");
const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState("");
const [appointmentType, setAppointmentType] = useState<
"presencial" | "telemedicina"
>("presencial");
const [reason, setReason] = useState("");
const [loading, setLoading] = useState(false);
const [selectedPatientId, setSelectedPatientId] = useState("");
const [selectedPatientName, setSelectedPatientName] = useState("");
// A11y & UX: refs para foco inicial e fechamento via overlay/ESC
const overlayRef = useRef<HTMLDivElement | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
const firstFieldRef = useRef<HTMLSelectElement | null>(null);
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
// A11y: IDs para aria-labelledby/aria-describedby
const titleId = useMemo(
() => `schedule-modal-title-${patientId ?? "novo"}`,
[patientId]
);
const descId = useMemo(
() => `schedule-modal-desc-${patientId ?? "novo"}`,
[patientId]
);
useEffect(() => {
if (isOpen) {
loadDoctors();
if (!patientId) {
loadPatients();
} else {
// Garantir estados internos alinhados com props
setSelectedPatientId(patientId);
setSelectedPatientName(patientName || "");
}
// UX: foco no primeiro campo quando abrir
setTimeout(() => firstFieldRef.current?.focus(), 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
async function loadDoctors() {
setLoadingDoctors(true);
try {
const doctors = await doctorService.list();
setDoctors(doctors);
} catch (error) {
console.error("Erro ao carregar médicos:", error);
toast.error("Erro ao carregar médicos");
} finally {
setLoadingDoctors(false);
}
}
async function loadPatients() {
setLoadingPatients(true);
try {
const patients = await patientService.list();
setPatients(patients);
} catch (error) {
console.error("Erro ao carregar pacientes:", error);
toast.error("Erro ao carregar pacientes");
} finally {
setLoadingPatients(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const finalPatientId = patientId || selectedPatientId;
if (
!selectedDoctorId ||
!selectedDate ||
!selectedTime ||
!finalPatientId
) {
toast.error("Preencha médico, data e horário");
return;
}
setLoading(true);
const datetime = `${selectedDate}T${selectedTime}:00`;
try {
await appointmentService.create({
patient_id: finalPatientId,
doctor_id: selectedDoctorId,
scheduled_at: datetime,
appointment_type: appointmentType,
chief_complaint: reason || undefined,
});
toast.success("Agendamento criado com sucesso!");
onSuccess?.();
handleClose();
} catch (error) {
console.error("Erro ao criar agendamento:", error);
toast.error("Erro ao criar agendamento");
} finally {
setLoading(false);
}
}
function handleClose() {
setSelectedDoctorId("");
setSelectedDate("");
setSelectedTime("");
setAppointmentType("presencial");
setReason("");
setSelectedPatientId("");
setSelectedPatientName("");
onClose();
}
if (!isOpen) return null;
const selectedDoctor = doctors.find((d) => d.id === selectedDoctorId);
const patientPreselected = !!patientId;
const effectivePatientName = patientPreselected
? patientName
: selectedPatientName ||
(patients.find((p) => p.id === selectedPatientId)?.full_name ?? "");
// UX: handlers para ESC e clique fora
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") {
e.stopPropagation();
handleClose();
}
}
function onOverlayClick(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === overlayRef.current) handleClose();
}
return (
<div
ref={overlayRef}
className="fixed inset-0 bg-black/50 backdrop-blur-[2px] flex items-center justify-center z-50 p-4"
onClick={onOverlayClick}
onKeyDown={onKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descId}
>
<div
ref={dialogRef}
className="bg-white rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto ring-1 ring-black/5 animate-in fade-in zoom-in duration-150"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-gradient-to-r from-blue-50 to-white border-b px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Stethoscope className="w-5 h-5 text-blue-600" aria-hidden="true" />
<h2
id={titleId}
className="text-lg md:text-xl font-semibold text-gray-900"
>
Agendar consulta {" "}
<span className="font-normal text-gray-700">
{effectivePatientName}
</span>
</h2>
</div>
<button
ref={closeBtnRef}
onClick={handleClose}
aria-label="Fechar modal de agendamento"
className="inline-flex items-center justify-center rounded-md p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<X className="w-5 h-5" />
</button>
</div>
<p id={descId} className="sr-only">
Selecione o médico, a data, o tipo de consulta e um horário disponível
para criar um novo agendamento.
</p>
<form
onSubmit={handleSubmit}
className="p-6 space-y-6"
aria-busy={loading}
>
{/* Paciente (apenas quando não veio por props) */}
{!patientPreselected && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Paciente *
</label>
{loadingPatients ? (
// Skeleton para carregamento de pacientes
<div
className="h-10 w-full rounded-lg bg-gray-100 animate-pulse"
aria-live="polite"
aria-label="Carregando pacientes"
/>
) : (
<select
value={selectedPatientId}
onChange={(e) => {
setSelectedPatientId(e.target.value);
const p = patients.find((px) => px.id === e.target.value);
setSelectedPatientName(p?.full_name || "");
}}
className="form-input"
required
>
<option value="">-- Selecione um paciente --</option>
{patients.map((p) => (
<option key={p.id} value={p.id}>
{p.full_name} {p.cpf ? `- ${p.cpf}` : ""}
</option>
))}
</select>
)}
</div>
)}
{/* Médico */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Médico{" "}
<span className="text-red-500" aria-hidden="true">
*
</span>
</label>
{loadingDoctors ? (
<div
className="h-10 w-full rounded-lg bg-gray-100 animate-pulse"
aria-live="polite"
aria-label="Carregando médicos"
/>
) : (
<select
value={selectedDoctorId}
onChange={(e) => setSelectedDoctorId(e.target.value)}
ref={firstFieldRef}
className="form-input"
required
>
<option value="">-- Selecione um médico --</option>
{doctors.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.full_name} - {doc.specialty}
</option>
))}
</select>
)}
{selectedDoctor && (
<div className="mt-2 text-sm text-gray-600">
CRM: {selectedDoctor.crm}
</div>
)}
</div>
{/* Data */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data{" "}
<span className="text-red-500" aria-hidden="true">
*
</span>
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => {
setSelectedDate(e.target.value);
setSelectedTime(""); // Limpa o horário ao mudar a data
}}
min={new Date().toISOString().split("T")[0]}
className="form-input"
required
/>
<p className="mt-1 text-xs text-gray-500 flex items-center gap-1">
<CalendarIcon className="w-3.5 h-3.5" /> Selecione uma data para
ver os horários disponíveis.
</p>
</div>
{/* Tipo de Consulta */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de Consulta{" "}
<span className="text-red-500" aria-hidden="true">
*
</span>
</label>
<select
value={appointmentType}
onChange={(e) =>
setAppointmentType(
e.target.value as "presencial" | "telemedicina"
)
}
className="form-input"
required
>
<option value="presencial">Presencial</option>
<option value="telemedicina">Telemedicina</option>
</select>
<p className="mt-1 text-xs text-gray-500 flex items-center gap-1">
<Clock className="w-3.5 h-3.5" /> O tipo de consulta pode alterar
a disponibilidade de horários.
</p>
</div>
{/* Horários Disponíveis */}
{selectedDoctorId && selectedDate && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Horários Disponíveis *
</label>
<AvailableSlotsPicker
doctorId={selectedDoctorId}
date={selectedDate}
appointment_type={appointmentType}
onSelect={(time) => setSelectedTime(time)}
/>
{selectedTime && (
<div className="mt-2 inline-flex items-center gap-2 rounded-md bg-green-50 px-3 py-1.5 text-sm text-green-700 ring-1 ring-green-600/20">
<span aria-hidden></span> Horário selecionado:{" "}
<span className="font-semibold">{selectedTime}</span>
</div>
)}
</div>
)}
{/* Motivo */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Motivo da Consulta (opcional)
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="form-input"
placeholder="Ex: Consulta de rotina, dor de cabeça..."
/>
</div>
{/* Botões */}
<div className="flex flex-col-reverse sm:flex-row gap-3 pt-4 border-t">
<button
type="button"
onClick={handleClose}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
disabled={loading}
>
Cancelar
</button>
<button
type="submit"
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
disabled={
loading ||
!selectedDoctorId ||
!selectedDate ||
!selectedTime ||
(!patientPreselected && !selectedPatientId)
}
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" aria-hidden />{" "}
Agendando...
</>
) : (
"Confirmar Agendamento"
)}
</button>
</div>
</form>
</div>
</div>
);
};
export default ScheduleAppointmentModal;