- Avatar do paciente agora persiste após reload (adiciona timestamp para evitar cache) - Agendamento usa patient_id correto ao invés de user_id - Botão de download de PDF desbloqueado com logs detalhados
580 lines
20 KiB
TypeScript
580 lines
20 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { Calendar, User, FileText, CheckCircle, LogOut } from "lucide-react";
|
|
// import consultaService from "../services/consultaService"; // não utilizado após integração com appointmentService
|
|
import { appointmentService, patientService } from "../services";
|
|
import AvailableSlotsPicker from "../components/agenda/AvailableSlotsPicker";
|
|
import { doctorService } from "../services";
|
|
import toast from "react-hot-toast";
|
|
import { format, addDays } from "date-fns";
|
|
import { ptBR } from "date-fns/locale";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
interface Medico {
|
|
_id: string;
|
|
nome: string;
|
|
especialidade: string;
|
|
valorConsulta: number;
|
|
horarioAtendimento: Record<string, string[]>;
|
|
}
|
|
|
|
interface Paciente {
|
|
_id: string;
|
|
nome: string;
|
|
cpf: string;
|
|
telefone: string;
|
|
email: string;
|
|
}
|
|
|
|
const AgendamentoPaciente: React.FC = () => {
|
|
const [medicos, setMedicos] = useState<Medico[]>([]);
|
|
const [pacienteLogado, setPacienteLogado] = useState<Paciente | null>(null);
|
|
const [patientTableId, setPatientTableId] = useState<string | null>(null); // ID da tabela patients
|
|
const [loading, setLoading] = useState(false);
|
|
const [etapa, setEtapa] = useState(1);
|
|
|
|
const [agendamento, setAgendamento] = useState({
|
|
medicoId: "",
|
|
data: "",
|
|
horario: "",
|
|
tipoConsulta: "primeira-vez",
|
|
motivoConsulta: "",
|
|
observacoes: "",
|
|
});
|
|
|
|
// Slots são carregados diretamente pelo AvailableSlotsPicker
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
// Verificar se paciente está logado
|
|
const pacienteData = localStorage.getItem("pacienteLogado");
|
|
if (!pacienteData) {
|
|
console.log(
|
|
"[AgendamentoPaciente] Paciente não logado, redirecionando..."
|
|
);
|
|
navigate("/paciente");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const paciente = JSON.parse(pacienteData);
|
|
console.log("[AgendamentoPaciente] Paciente logado:", paciente);
|
|
setPacienteLogado(paciente);
|
|
|
|
// Buscar o patient.id (ID da tabela) usando o user_id
|
|
const fetchPatientTableId = async () => {
|
|
try {
|
|
const patientData = await patientService.getByUserId(paciente._id);
|
|
console.log(
|
|
"[AgendamentoPaciente] Patient data da tabela:",
|
|
patientData
|
|
);
|
|
if (patientData?.id) {
|
|
setPatientTableId(patientData.id);
|
|
console.log(
|
|
"[AgendamentoPaciente] Patient table ID:",
|
|
patientData.id
|
|
);
|
|
} else {
|
|
console.error(
|
|
"[AgendamentoPaciente] ❌ Paciente não encontrado na tabela"
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[AgendamentoPaciente] Erro ao buscar patient.id:",
|
|
error
|
|
);
|
|
}
|
|
};
|
|
|
|
void fetchPatientTableId();
|
|
void fetchMedicos();
|
|
} catch (error) {
|
|
console.error(
|
|
"[AgendamentoPaciente] Erro ao carregar dados do paciente:",
|
|
error
|
|
);
|
|
navigate("/paciente");
|
|
}
|
|
}, [navigate]);
|
|
|
|
// As consultas locais agora aparecem na Dashboard (AcompanhamentoPaciente)
|
|
|
|
const fetchMedicos = async () => {
|
|
try {
|
|
console.log("[AgendamentoPaciente] Iniciando busca de médicos...");
|
|
|
|
const doctors = await doctorService.list({ active: true });
|
|
console.log("[AgendamentoPaciente] Médicos recebidos:", doctors);
|
|
|
|
const mapped: Medico[] = doctors.map((m: any) => ({
|
|
_id: m.id,
|
|
nome: m.full_name,
|
|
especialidade: m.specialty || "",
|
|
valorConsulta: 0,
|
|
horarioAtendimento: {},
|
|
}));
|
|
|
|
console.log("[AgendamentoPaciente] Médicos mapeados:", mapped);
|
|
setMedicos(mapped);
|
|
|
|
if (mapped.length === 0) {
|
|
toast.error(
|
|
"Nenhum médico ativo encontrado. Por favor, cadastre médicos primeiro."
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("[AgendamentoPaciente] Erro ao carregar médicos:", error);
|
|
toast.error("Erro ao carregar lista de médicos");
|
|
}
|
|
};
|
|
|
|
// Horários disponíveis agora são resolvidos no componente de slots
|
|
|
|
const handleMedicoChange = (medicoId: string) => {
|
|
setAgendamento((prev) => ({ ...prev, medicoId, data: "", horario: "" }));
|
|
};
|
|
|
|
const handleDataChange = (data: string) => {
|
|
setAgendamento((prev) => ({ ...prev, data, horario: "" }));
|
|
};
|
|
|
|
const confirmarAgendamento = async () => {
|
|
if (!pacienteLogado) return;
|
|
|
|
// Verificar se temos o patient.id da tabela
|
|
if (!patientTableId) {
|
|
console.error(
|
|
"[AgendamentoPaciente] ❌ Patient table ID não encontrado!"
|
|
);
|
|
toast.error(
|
|
"Erro: Dados do paciente não carregados. Recarregue a página."
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
|
|
// Verificar se há token de autenticação
|
|
const token = localStorage.getItem("mediconnect_access_token");
|
|
console.log("[AgendamentoPaciente] Token presente?", !!token);
|
|
if (!token) {
|
|
console.error(
|
|
"[AgendamentoPaciente] ❌ Token não encontrado! Redirecionando para login..."
|
|
);
|
|
toast.error("Sessão expirada. Faça login novamente.");
|
|
navigate("/paciente");
|
|
return;
|
|
}
|
|
|
|
// NOTE: Removed remote CPF validation to avoid false negatives
|
|
|
|
// NOTE: remote CEP validation removed to avoid false negatives
|
|
|
|
const dataHora = new Date(
|
|
`${agendamento.data}T${agendamento.horario}:00.000Z`
|
|
);
|
|
|
|
// Payload conforme documentação da API Supabase
|
|
const payload = {
|
|
doctor_id: agendamento.medicoId,
|
|
patient_id: patientTableId,
|
|
scheduled_at: dataHora.toISOString(),
|
|
duration_minutes: 30,
|
|
created_by: pacienteLogado._id,
|
|
};
|
|
|
|
console.log("[AgendamentoPaciente] 📋 Dados para criar consulta:", {
|
|
patient_id: patientTableId,
|
|
patient_user_id: pacienteLogado._id,
|
|
doctor_id: agendamento.medicoId,
|
|
scheduled_at: dataHora.toISOString(),
|
|
chief_complaint: agendamento.motivoConsulta,
|
|
token_presente: !!token,
|
|
payload_completo: payload,
|
|
});
|
|
|
|
const resultado = await appointmentService.create(payload);
|
|
|
|
console.log(
|
|
"[AgendamentoPaciente] ✅ Consulta criada com sucesso:",
|
|
resultado
|
|
);
|
|
toast.success("Consulta agendada com sucesso!");
|
|
setEtapa(4); // Etapa de confirmação
|
|
} catch (error) {
|
|
console.error(
|
|
"[AgendamentoPaciente] ❌ Erro ao agendar consulta:",
|
|
error
|
|
);
|
|
toast.error("Erro ao agendar consulta. Tente novamente.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const resetarAgendamento = () => {
|
|
setAgendamento({
|
|
medicoId: "",
|
|
data: "",
|
|
horario: "",
|
|
tipoConsulta: "primeira-vez",
|
|
motivoConsulta: "",
|
|
observacoes: "",
|
|
});
|
|
setEtapa(1);
|
|
};
|
|
|
|
// Removido: criação/visualização local aqui. Use a Dashboard para ver.
|
|
|
|
const logout = () => {
|
|
localStorage.removeItem("pacienteLogado");
|
|
navigate("/paciente");
|
|
};
|
|
|
|
const proximosSeteDias = () => {
|
|
const dias = [];
|
|
for (let i = 1; i <= 7; i++) {
|
|
const data = addDays(new Date(), i);
|
|
dias.push({
|
|
valor: format(data, "yyyy-MM-dd"),
|
|
label: format(data, "EEEE, dd/MM", { locale: ptBR }),
|
|
});
|
|
}
|
|
return dias;
|
|
};
|
|
|
|
const medicoSelecionado = medicos.find((m) => m._id === agendamento.medicoId);
|
|
|
|
if (!pacienteLogado) {
|
|
return (
|
|
<div className="flex justify-center items-center min-h-screen">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (etapa === 4) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
|
<div className="max-w-2xl mx-auto">
|
|
<div className="bg-white rounded-lg sm:rounded-xl shadow-md p-6 sm:p-8 text-center">
|
|
<CheckCircle className="w-12 h-12 sm:w-16 sm:h-16 text-green-500 mx-auto mb-3 sm:mb-4" />
|
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
|
|
Consulta Agendada com Sucesso!
|
|
</h2>
|
|
<div className="bg-gray-50 rounded-lg p-4 sm:p-6 mb-4 sm:mb-6 text-left">
|
|
<h3 className="text-sm sm:text-base font-semibold mb-2 sm:mb-3">
|
|
Detalhes do Agendamento:
|
|
</h3>
|
|
<div className="space-y-1.5 sm:space-y-2 text-xs sm:text-sm">
|
|
<p className="break-words">
|
|
<strong>Paciente:</strong> {pacienteLogado.nome}
|
|
</p>
|
|
<p className="break-words">
|
|
<strong>Médico:</strong> {medicoSelecionado?.nome}
|
|
</p>
|
|
<p className="break-words">
|
|
<strong>Especialidade:</strong>{" "}
|
|
{medicoSelecionado?.especialidade}
|
|
</p>
|
|
<p>
|
|
<strong>Data:</strong>{" "}
|
|
{format(new Date(agendamento.data), "dd/MM/yyyy", {
|
|
locale: ptBR,
|
|
})}
|
|
</p>
|
|
<p>
|
|
<strong>Horário:</strong> {agendamento.horario}
|
|
</p>
|
|
<p>
|
|
<strong>Tipo:</strong> {agendamento.tipoConsulta}
|
|
</p>
|
|
{agendamento.motivoConsulta && (
|
|
<p className="break-words">
|
|
<strong>Motivo:</strong> {agendamento.motivoConsulta}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={resetarAgendamento}
|
|
className="btn-primary w-full sm:w-auto text-sm sm:text-base"
|
|
>
|
|
Fazer Novo Agendamento
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
|
<div className="max-w-4xl mx-auto space-y-4 sm:space-y-6 lg:space-y-8">
|
|
{/* Header com informações do paciente */}
|
|
<div className="bg-gradient-to-r from-blue-700 to-blue-500 rounded-lg sm:rounded-xl p-4 sm:p-6 text-white shadow">
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4">
|
|
<div className="min-w-0 flex-1">
|
|
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold truncate">
|
|
Bem-vindo(a), {pacienteLogado.nome}!
|
|
</h1>
|
|
<p className="opacity-90 text-sm sm:text-base">
|
|
Agende sua consulta médica
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={logout}
|
|
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-white/20 hover:bg-white/30 px-3 sm:px-4 py-2 rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/70 text-sm sm:text-base whitespace-nowrap"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
<span>Sair</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* As consultas locais serão exibidas na Dashboard do paciente */}
|
|
|
|
{/* Indicador de Etapas */}
|
|
<div className="flex items-center justify-center mb-6 sm:mb-8">
|
|
{[1, 2, 3].map((numero) => (
|
|
<React.Fragment key={numero}>
|
|
<div
|
|
className={`w-7 h-7 sm:w-8 sm:h-8 rounded-full flex items-center justify-center text-sm sm:text-base font-medium ${
|
|
etapa >= numero
|
|
? "bg-blue-600 text-white"
|
|
: "bg-gray-300 text-gray-600"
|
|
}`}
|
|
>
|
|
{numero}
|
|
</div>
|
|
{numero < 3 && (
|
|
<div
|
|
className={`w-12 sm:w-16 h-1 ${
|
|
etapa > numero ? "bg-blue-600" : "bg-gray-300"
|
|
}`}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg sm:rounded-xl shadow border border-gray-200 p-4 sm:p-6">
|
|
{/* Etapa 1: Seleção de Médico */}
|
|
{etapa === 1 && (
|
|
<div className="space-y-4 sm:space-y-6">
|
|
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
|
|
<User className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
|
|
Selecione o Médico
|
|
</h2>
|
|
|
|
<div>
|
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
|
Médico/Especialidade
|
|
</label>
|
|
<select
|
|
value={agendamento.medicoId}
|
|
onChange={(e) => handleMedicoChange(e.target.value)}
|
|
className="form-input text-sm sm:text-base"
|
|
required
|
|
>
|
|
<option value="">Selecione um médico</option>
|
|
{medicos.map((medico) => (
|
|
<option key={medico._id} value={medico._id}>
|
|
{medico.nome} - {medico.especialidade} (R${" "}
|
|
{medico.valorConsulta})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex justify-end pt-2">
|
|
<button
|
|
onClick={() => setEtapa(2)}
|
|
disabled={!agendamento.medicoId}
|
|
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto text-sm sm:text-base"
|
|
>
|
|
Próximo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Etapa 2: Seleção de Data e Horário */}
|
|
{etapa === 2 && (
|
|
<div className="space-y-4 sm:space-y-6">
|
|
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
|
|
<Calendar className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
|
|
Selecione Data e Horário
|
|
</h2>
|
|
|
|
<div>
|
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
|
Data da Consulta
|
|
</label>
|
|
<select
|
|
value={agendamento.data}
|
|
onChange={(e) => handleDataChange(e.target.value)}
|
|
className="form-input text-sm sm:text-base"
|
|
required
|
|
>
|
|
<option value="">Selecione uma data</option>
|
|
{proximosSeteDias().map((dia) => (
|
|
<option key={dia.valor} value={dia.valor}>
|
|
{dia.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{agendamento.data && agendamento.medicoId && (
|
|
<div>
|
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
|
Horários Disponíveis
|
|
</label>
|
|
<AvailableSlotsPicker
|
|
doctorId={agendamento.medicoId}
|
|
date={agendamento.data}
|
|
onSelect={(t) =>
|
|
setAgendamento((prev) => ({ ...prev, horario: t }))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col sm:flex-row justify-between gap-3 pt-2">
|
|
<button
|
|
onClick={() => setEtapa(1)}
|
|
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300 w-full sm:w-auto text-sm sm:text-base order-2 sm:order-1"
|
|
>
|
|
Voltar
|
|
</button>
|
|
<button
|
|
onClick={() => setEtapa(3)}
|
|
disabled={!agendamento.horario}
|
|
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 w-full sm:w-auto text-sm sm:text-base order-1 sm:order-2"
|
|
>
|
|
Próximo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Etapa 3: Informações Adicionais */}
|
|
{etapa === 3 && (
|
|
<div className="space-y-4 sm:space-y-6">
|
|
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
|
|
<FileText className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
|
|
Informações da Consulta
|
|
</h2>
|
|
|
|
<div>
|
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
|
Tipo de Consulta
|
|
</label>
|
|
<select
|
|
value={agendamento.tipoConsulta}
|
|
onChange={(e) =>
|
|
setAgendamento((prev) => ({
|
|
...prev,
|
|
tipoConsulta: e.target.value,
|
|
}))
|
|
}
|
|
className="form-input text-sm sm:text-base"
|
|
>
|
|
<option value="primeira-vez">Primeira Consulta</option>
|
|
<option value="retorno">Retorno</option>
|
|
<option value="urgencia">Urgência</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
|
Motivo da Consulta
|
|
</label>
|
|
<textarea
|
|
value={agendamento.motivoConsulta}
|
|
onChange={(e) =>
|
|
setAgendamento((prev) => ({
|
|
...prev,
|
|
motivoConsulta: e.target.value,
|
|
}))
|
|
}
|
|
className="form-input text-sm sm:text-base"
|
|
rows={3}
|
|
placeholder="Descreva brevemente o motivo da consulta"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
|
Observações (opcional)
|
|
</label>
|
|
<textarea
|
|
value={agendamento.observacoes}
|
|
onChange={(e) =>
|
|
setAgendamento((prev) => ({
|
|
...prev,
|
|
observacoes: e.target.value,
|
|
}))
|
|
}
|
|
className="form-input text-sm sm:text-base"
|
|
rows={2}
|
|
placeholder="Informações adicionais relevantes"
|
|
/>
|
|
</div>
|
|
|
|
{/* Resumo do Agendamento */}
|
|
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-200">
|
|
<h3 className="text-sm sm:text-base font-semibold mb-2 sm:mb-3">
|
|
Resumo do Agendamento:
|
|
</h3>
|
|
<div className="space-y-1 sm:space-y-1.5 text-xs sm:text-sm">
|
|
<p className="break-words">
|
|
<strong>Paciente:</strong> {pacienteLogado.nome}
|
|
</p>
|
|
<p className="break-words">
|
|
<strong>Médico:</strong> {medicoSelecionado?.nome}
|
|
</p>
|
|
<p>
|
|
<strong>Data:</strong>{" "}
|
|
{format(new Date(agendamento.data), "dd/MM/yyyy", {
|
|
locale: ptBR,
|
|
})}
|
|
</p>
|
|
<p>
|
|
<strong>Horário:</strong> {agendamento.horario}
|
|
</p>
|
|
<p>
|
|
<strong>Valor:</strong> R${" "}
|
|
{medicoSelecionado?.valorConsulta}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row justify-between gap-3 pt-2">
|
|
<button
|
|
onClick={() => setEtapa(2)}
|
|
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300 w-full sm:w-auto text-sm sm:text-base order-2 sm:order-1"
|
|
>
|
|
Voltar
|
|
</button>
|
|
<button
|
|
onClick={confirmarAgendamento}
|
|
disabled={loading}
|
|
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 w-full sm:w-auto text-sm sm:text-base order-1 sm:order-2"
|
|
>
|
|
{loading ? "Agendando..." : "Confirmar Agendamento"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AgendamentoPaciente;
|