- 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
419 lines
15 KiB
TypeScript
419 lines
15 KiB
TypeScript
// UI/UX refresh: melhorias visuais e de acessibilidade sem alterar a lógica
|
|
import React, { useEffect, useState } from "react";
|
|
import toast from "react-hot-toast";
|
|
import { appointmentService, patientService } from "../../services/index";
|
|
import type { Appointment } from "../../services/appointments/types";
|
|
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
|
|
|
interface Props {
|
|
doctorId: string;
|
|
}
|
|
|
|
interface CalendarDay {
|
|
date: Date;
|
|
dateStr: string;
|
|
isCurrentMonth: boolean;
|
|
isToday: boolean;
|
|
appointments: Appointment[];
|
|
}
|
|
|
|
const WEEKDAYS = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
|
|
const MONTHS = [
|
|
"Janeiro",
|
|
"Fevereiro",
|
|
"Março",
|
|
"Abril",
|
|
"Maio",
|
|
"Junho",
|
|
"Julho",
|
|
"Agosto",
|
|
"Setembro",
|
|
"Outubro",
|
|
"Novembro",
|
|
"Dezembro",
|
|
];
|
|
|
|
const DoctorCalendar: React.FC<Props> = ({ doctorId }) => {
|
|
const [currentDate, setCurrentDate] = useState(new Date());
|
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [selectedDay, setSelectedDay] = useState<CalendarDay | null>(null);
|
|
const [patientsById, setPatientsById] = useState<Record<string, string>>({});
|
|
|
|
useEffect(() => {
|
|
if (doctorId) {
|
|
loadAppointments();
|
|
loadPatients();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [doctorId, currentDate]);
|
|
|
|
async function loadAppointments() {
|
|
setLoading(true);
|
|
try {
|
|
const appointments = await appointmentService.list();
|
|
// Filtrar apenas do médico selecionado
|
|
const filtered = appointments.filter(
|
|
(apt: Appointment) => apt.doctor_id === doctorId
|
|
);
|
|
setAppointments(filtered);
|
|
} catch (error) {
|
|
console.error("Erro ao carregar agendamentos:", error);
|
|
toast.error("Erro ao carregar agendamentos");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function loadPatients() {
|
|
// Carrega pacientes para mapear nome pelo id (render amigável)
|
|
try {
|
|
const patients = await patientService.list();
|
|
const map: Record<string, string> = {};
|
|
for (const p of patients) {
|
|
if (p?.id) {
|
|
map[p.id] = p.full_name || p.email || p.cpf || p.id;
|
|
}
|
|
}
|
|
setPatientsById(map);
|
|
} catch {
|
|
// silencioso; não bloqueia calendário
|
|
}
|
|
}
|
|
|
|
function getPatientName(id?: string) {
|
|
if (!id) return "";
|
|
return patientsById[id] || id;
|
|
}
|
|
|
|
function getCalendarDays(): CalendarDay[] {
|
|
const year = currentDate.getFullYear();
|
|
const month = currentDate.getMonth();
|
|
|
|
// Primeiro dia do mês
|
|
const firstDay = new Date(year, month, 1);
|
|
// Último dia do mês
|
|
const lastDay = new Date(year, month + 1, 0);
|
|
|
|
// Dia da semana do primeiro dia (0 = domingo)
|
|
const startingDayOfWeek = firstDay.getDay();
|
|
|
|
const days: CalendarDay[] = [];
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
// Adicionar dias do mês anterior
|
|
const prevMonthLastDay = new Date(year, month, 0);
|
|
for (let i = startingDayOfWeek - 1; i >= 0; i--) {
|
|
const date = new Date(year, month - 1, prevMonthLastDay.getDate() - i);
|
|
const dateStr = formatDateISO(date);
|
|
days.push({
|
|
date,
|
|
dateStr,
|
|
isCurrentMonth: false,
|
|
isToday: false,
|
|
appointments: getAppointmentsForDate(dateStr),
|
|
});
|
|
}
|
|
|
|
// Adicionar dias do mês atual
|
|
for (let day = 1; day <= lastDay.getDate(); day++) {
|
|
const date = new Date(year, month, day);
|
|
const dateStr = formatDateISO(date);
|
|
const isToday = date.getTime() === today.getTime();
|
|
days.push({
|
|
date,
|
|
dateStr,
|
|
isCurrentMonth: true,
|
|
isToday,
|
|
appointments: getAppointmentsForDate(dateStr),
|
|
});
|
|
}
|
|
|
|
// Adicionar dias do próximo mês para completar a grade
|
|
const remainingDays = 42 - days.length; // 6 semanas x 7 dias
|
|
for (let day = 1; day <= remainingDays; day++) {
|
|
const date = new Date(year, month + 1, day);
|
|
const dateStr = formatDateISO(date);
|
|
days.push({
|
|
date,
|
|
dateStr,
|
|
isCurrentMonth: false,
|
|
isToday: false,
|
|
appointments: getAppointmentsForDate(dateStr),
|
|
});
|
|
}
|
|
|
|
return days;
|
|
}
|
|
|
|
function formatDateISO(date: Date): string {
|
|
return date.toISOString().split("T")[0];
|
|
}
|
|
|
|
function getAppointmentsForDate(dateStr: string): Appointment[] {
|
|
return appointments.filter((apt) => {
|
|
if (!apt.scheduled_at) return false;
|
|
const aptDate = apt.scheduled_at.split("T")[0];
|
|
return aptDate === dateStr;
|
|
});
|
|
}
|
|
|
|
function previousMonth() {
|
|
setCurrentDate(
|
|
new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)
|
|
);
|
|
}
|
|
|
|
function nextMonth() {
|
|
setCurrentDate(
|
|
new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)
|
|
);
|
|
}
|
|
|
|
function goToToday() {
|
|
setCurrentDate(new Date());
|
|
}
|
|
|
|
function getStatusColor(status?: string): string {
|
|
switch (status) {
|
|
case "confirmed":
|
|
return "bg-blue-500";
|
|
case "completed":
|
|
return "bg-green-500";
|
|
case "cancelled":
|
|
return "bg-red-500";
|
|
case "no_show":
|
|
return "bg-gray-500";
|
|
case "checked_in":
|
|
return "bg-purple-500";
|
|
case "in_progress":
|
|
return "bg-yellow-500";
|
|
default:
|
|
return "bg-orange-500"; // requested
|
|
}
|
|
}
|
|
|
|
function getStatusLabel(status?: string): string {
|
|
const labels: Record<string, string> = {
|
|
requested: "Solicitado",
|
|
confirmed: "Confirmado",
|
|
checked_in: "Check-in",
|
|
in_progress: "Em andamento",
|
|
completed: "Concluído",
|
|
cancelled: "Cancelado",
|
|
no_show: "Faltou",
|
|
};
|
|
return labels[status || "requested"] || status || "Solicitado";
|
|
}
|
|
|
|
const calendarDays = getCalendarDays();
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-md p-6 border border-gray-200">
|
|
{/* Cabeçalho modernizado: melhor contraste, foco e navegação */}
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between mb-6">
|
|
<h3 className="text-xl font-semibold text-gray-900">
|
|
Calendário de Consultas
|
|
</h3>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={goToToday}
|
|
className="px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
|
>
|
|
Hoje
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={previousMonth}
|
|
className="p-2 hover:bg-gray-100 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
|
aria-label="Mês anterior"
|
|
>
|
|
<ChevronLeft className="w-5 h-5" />
|
|
</button>
|
|
<span className="text-lg font-medium min-w-[200px] text-center">
|
|
{MONTHS[currentDate.getMonth()]} {currentDate.getFullYear()}
|
|
</span>
|
|
<button
|
|
onClick={nextMonth}
|
|
className="p-2 hover:bg-gray-100 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
|
aria-label="Próximo mês"
|
|
>
|
|
<ChevronRight className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex justify-center py-12">
|
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Cabeçalhos dos dias da semana */}
|
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
|
{WEEKDAYS.map((day) => (
|
|
<div
|
|
key={day}
|
|
className="text-center text-sm font-semibold text-gray-600 py-2"
|
|
>
|
|
{day}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Grid do calendário com células interativas acessíveis */}
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{calendarDays.map((day, index) => (
|
|
<div
|
|
key={index}
|
|
// UI: estados visuais modernizados, mantendo a interação por clique
|
|
className={`group min-h-[110px] border rounded-lg p-2 transition-colors ${
|
|
day.isCurrentMonth
|
|
? "bg-white border-gray-200"
|
|
: "bg-gray-50 border-gray-100"
|
|
} ${day.isToday ? "ring-2 ring-blue-500" : ""} ${
|
|
day.appointments.length > 0
|
|
? "cursor-pointer hover:bg-blue-50"
|
|
: ""
|
|
}`}
|
|
onClick={() =>
|
|
day.appointments.length > 0 && setSelectedDay(day)
|
|
}
|
|
>
|
|
{/* Número do dia com destaque para hoje */}
|
|
<div
|
|
className={`text-sm font-medium mb-2 ${
|
|
day.isCurrentMonth ? "text-gray-900" : "text-gray-400"
|
|
} ${day.isToday ? "text-blue-600 font-bold" : ""}`}
|
|
>
|
|
{day.date.getDate()}
|
|
</div>
|
|
{/* Chips de horários com cores por status */}
|
|
<div className="space-y-1">
|
|
{day.appointments.slice(0, 3).map((apt, idx) => (
|
|
<div
|
|
key={apt.id || idx}
|
|
className={`text-xs px-1 py-0.5 rounded text-white ${getStatusColor(
|
|
apt.status
|
|
)} truncate`}
|
|
title={`${apt.scheduled_at?.slice(
|
|
11,
|
|
16
|
|
)} - ${getStatusLabel(apt.status)}`}
|
|
>
|
|
{apt.scheduled_at?.slice(11, 16)}
|
|
</div>
|
|
))}
|
|
{day.appointments.length > 3 && (
|
|
<div className="text-xs text-gray-500 font-medium">
|
|
+{day.appointments.length - 3} mais
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Modal de detalhes do dia - melhorado com acessibilidade e botão de fechar */}
|
|
{selectedDay && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 backdrop-blur-[2px] flex items-center justify-center z-50"
|
|
onClick={() => setSelectedDay(null)}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Consultas do dia selecionado"
|
|
>
|
|
<div
|
|
className="bg-white rounded-xl shadow-2xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-auto ring-1 ring-black/5"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
|
|
<h3 className="text-xl font-semibold text-gray-900">
|
|
Consultas de{" "}
|
|
{selectedDay.date.toLocaleDateString("pt-BR", {
|
|
weekday: "long",
|
|
day: "numeric",
|
|
month: "long",
|
|
year: "numeric",
|
|
})}
|
|
</h3>
|
|
<button
|
|
onClick={() => setSelectedDay(null)}
|
|
aria-label="Fechar"
|
|
className="p-2 rounded-md 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>
|
|
<div className="p-6 space-y-3">
|
|
{selectedDay.appointments.length === 0 ? (
|
|
<p className="text-gray-500 text-center py-4">
|
|
Nenhuma consulta agendada para este dia.
|
|
</p>
|
|
) : (
|
|
selectedDay.appointments.map((apt) => (
|
|
<div
|
|
key={apt.id}
|
|
className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50"
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="font-medium text-gray-900">
|
|
{apt.scheduled_at?.slice(11, 16)}
|
|
</span>
|
|
<span
|
|
className={`px-2 py-0.5 rounded-full text-xs font-medium text-white ${getStatusColor(
|
|
apt.status
|
|
)}`}
|
|
>
|
|
{getStatusLabel(apt.status)}
|
|
</span>
|
|
</div>
|
|
<div className="text-sm text-gray-600 space-y-1">
|
|
<div>
|
|
<span className="font-medium">Paciente:</span>{" "}
|
|
{getPatientName(apt.patient_id)}
|
|
</div>
|
|
{apt.appointment_type && (
|
|
<div>
|
|
<span className="font-medium">Tipo:</span>{" "}
|
|
{apt.appointment_type === "presencial"
|
|
? "Presencial"
|
|
: "Telemedicina"}
|
|
</div>
|
|
)}
|
|
{apt.chief_complaint && (
|
|
<div>
|
|
<span className="font-medium">Queixa:</span>{" "}
|
|
{apt.chief_complaint}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
<div className="p-6 border-t border-gray-200 flex justify-end">
|
|
<button
|
|
onClick={() => setSelectedDay(null)}
|
|
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
|
>
|
|
Fechar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DoctorCalendar;
|