riseup-squad18/src/components/agenda/CalendarPicker.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

344 lines
12 KiB
TypeScript

import { useState, useEffect } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isBefore, startOfDay, addMonths, subMonths, getDay } from "date-fns";
import { ptBR } from "date-fns/locale";
import { availabilityService, appointmentService } from "../../services";
import type { DoctorAvailability, DoctorException } from "../../services";
interface CalendarPickerProps {
doctorId: string;
selectedDate?: string;
onSelectDate: (date: string) => void;
}
interface DayStatus {
date: Date;
available: boolean; // Tem horários disponíveis
hasAvailability: boolean; // Médico trabalha neste dia da semana
hasBlockException: boolean; // Dia bloqueado por exceção
isPast: boolean; // Data já passou
}
export function CalendarPicker({ doctorId, selectedDate, onSelectDate }: CalendarPickerProps) {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [availabilities, setAvailabilities] = useState<DoctorAvailability[]>([]);
const [exceptions, setExceptions] = useState<DoctorException[]>([]);
const [loading, setLoading] = useState(false);
const [availableSlots, setAvailableSlots] = useState<Record<string, boolean>>({});
// Carregar disponibilidades e exceções do médico
useEffect(() => {
if (!doctorId) return;
const loadData = async () => {
setLoading(true);
try {
const [availData, exceptData] = await Promise.all([
availabilityService.list({ doctor_id: doctorId, active: true }),
availabilityService.listExceptions({ doctor_id: doctorId }),
]);
setAvailabilities(Array.isArray(availData) ? availData : []);
setExceptions(Array.isArray(exceptData) ? exceptData : []);
} catch (error) {
console.error("Erro ao carregar dados do calendário:", error);
} finally {
setLoading(false);
}
};
loadData();
}, [doctorId, currentMonth]);
// Calcular disponibilidade de slots localmente (sem chamar Edge Function)
useEffect(() => {
if (!doctorId || availabilities.length === 0) return;
const checkAvailableSlots = async () => {
const start = startOfMonth(currentMonth);
const end = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start, end });
const slotsMap: Record<string, boolean> = {};
// Verificar apenas dias futuros que têm configuração de disponibilidade
const today = startOfDay(new Date());
const daysToCheck = days.filter((day) => {
const dayOfWeek = getDay(day); // 0-6
const hasConfig = availabilities.some((a) => a.weekday === dayOfWeek);
return !isBefore(day, today) && hasConfig;
});
// Buscar todos os agendamentos do médico uma vez só
let allAppointments: Array<{ scheduled_at: string; status: string }> = [];
try {
const appointments = await appointmentService.list({ doctor_id: doctorId });
allAppointments = Array.isArray(appointments) ? appointments : [];
} catch (error) {
console.error("[CalendarPicker] Erro ao buscar agendamentos:", error);
}
// Calcular slots para cada dia
for (const day of daysToCheck) {
try {
const dateStr = format(day, "yyyy-MM-dd");
const dayOfWeek = getDay(day);
// Filtra disponibilidades para o dia da semana
const dayAvailability = availabilities.filter(
(avail) => avail.weekday === dayOfWeek && avail.active
);
if (dayAvailability.length === 0) {
slotsMap[dateStr] = false;
continue;
}
// Verifica se há exceção de bloqueio
const hasBlockException = exceptions.some(
(exc) => exc.date === dateStr && exc.kind === "bloqueio"
);
if (hasBlockException) {
slotsMap[dateStr] = false;
continue;
}
// Gera todos os slots possíveis
const allSlots: string[] = [];
for (const avail of dayAvailability) {
const startTime = avail.start_time;
const endTime = avail.end_time;
const slotMinutes = avail.slot_minutes || 30;
const [startHour, startMin] = startTime.split(":").map(Number);
const [endHour, endMin] = endTime.split(":").map(Number);
let currentMinutes = startHour * 60 + startMin;
const endMinutes = endHour * 60 + endMin;
while (currentMinutes < endMinutes) {
const hours = Math.floor(currentMinutes / 60);
const minutes = currentMinutes % 60;
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
allSlots.push(timeStr);
currentMinutes += slotMinutes;
}
}
// Filtra agendamentos já ocupados para esta data
const bookedSlots = allAppointments
.filter((apt) => {
if (!apt.scheduled_at) return false;
const aptDate = new Date(apt.scheduled_at);
return (
format(aptDate, "yyyy-MM-dd") === dateStr &&
apt.status !== "cancelled" &&
apt.status !== "no_show"
);
})
.map((apt) => {
const aptDate = new Date(apt.scheduled_at);
return format(aptDate, "HH:mm");
});
// Verifica se há pelo menos um slot disponível
const availableSlots = allSlots.filter((slot) => !bookedSlots.includes(slot));
slotsMap[dateStr] = availableSlots.length > 0;
} catch (error) {
console.error(`[CalendarPicker] Erro ao verificar slots para ${format(day, "yyyy-MM-dd")}:`, error);
slotsMap[format(day, "yyyy-MM-dd")] = false;
}
}
setAvailableSlots(slotsMap);
};
checkAvailableSlots();
}, [doctorId, currentMonth, availabilities, exceptions]);
const getDayStatus = (date: Date): DayStatus => {
const today = startOfDay(new Date());
const isPast = isBefore(date, today);
const dayOfWeek = getDay(date); // 0-6 (domingo-sábado)
const dateStr = format(date, "yyyy-MM-dd");
// Verifica se há exceção de bloqueio para este dia
const hasBlockException = exceptions.some(
(exc) => exc.date === dateStr && exc.kind === "bloqueio"
);
// Verifica se médico trabalha neste dia da semana
const hasAvailability = availabilities.some((a) => a.weekday === dayOfWeek);
// Verifica se há slots disponíveis (baseado na verificação assíncrona)
const available = availableSlots[dateStr] === true;
return {
date,
available,
hasAvailability,
hasBlockException,
isPast,
};
};
const getDayClasses = (status: DayStatus, isSelected: boolean): string => {
const base = "w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition-colors";
if (isSelected) {
return `${base} bg-blue-600 text-white ring-2 ring-blue-400`;
}
if (status.isPast) {
return `${base} bg-gray-100 text-gray-400 cursor-not-allowed`;
}
if (status.hasBlockException) {
return `${base} bg-red-100 text-red-700 cursor-not-allowed`;
}
if (status.available) {
return `${base} bg-blue-100 text-blue-700 hover:bg-blue-200 cursor-pointer`;
}
if (status.hasAvailability) {
return `${base} bg-gray-50 text-gray-600 hover:bg-gray-100 cursor-pointer`;
}
return `${base} bg-white text-gray-400 cursor-not-allowed`;
};
const handlePrevMonth = () => {
setCurrentMonth(subMonths(currentMonth, 1));
};
const handleNextMonth = () => {
setCurrentMonth(addMonths(currentMonth, 1));
};
const handleDayClick = (date: Date, status: DayStatus) => {
if (status.isPast || status.hasBlockException) return;
if (!status.hasAvailability && !status.available) return;
const dateStr = format(date, "yyyy-MM-dd");
onSelectDate(dateStr);
};
const renderCalendar = () => {
const start = startOfMonth(currentMonth);
const end = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start, end });
// Preencher dias do início (para alinhar o primeiro dia da semana)
const startDayOfWeek = getDay(start);
const emptyDays = Array(startDayOfWeek).fill(null);
const allDays = [...emptyDays, ...days];
return (
<div className="grid grid-cols-7 gap-1">
{/* Cabeçalho dos dias da semana */}
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => (
<div key={day} className="text-center text-xs font-semibold text-gray-600 py-2">
{day}
</div>
))}
{/* Dias do mês */}
{allDays.map((day, index) => {
if (!day) {
return <div key={`empty-${index}`} className="w-10 h-10" />;
}
const status = getDayStatus(day);
const isSelected = selectedDate === format(day, "yyyy-MM-dd");
const classes = getDayClasses(status, isSelected);
return (
<div key={format(day, "yyyy-MM-dd")} className="flex justify-center">
<button
type="button"
onClick={() => handleDayClick(day, status)}
disabled={status.isPast || status.hasBlockException || (!status.hasAvailability && !status.available)}
className={classes}
title={
status.isPast
? "Data passada"
: status.hasBlockException
? "Dia bloqueado"
: status.available
? "Horários disponíveis"
: status.hasAvailability
? "Verificando disponibilidade..."
: "Médico não trabalha neste dia"
}
>
{format(day, "d")}
</button>
</div>
);
})}
</div>
);
};
return (
<div className="bg-white rounded-lg border border-gray-200 p-4">
{/* Navegação do mês */}
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={handlePrevMonth}
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
>
<ChevronLeft className="w-5 h-5 text-gray-600" />
</button>
<h3 className="text-lg font-semibold text-gray-800 capitalize">
{format(currentMonth, "MMMM yyyy", { locale: ptBR })}
</h3>
<button
type="button"
onClick={handleNextMonth}
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
>
<ChevronRight className="w-5 h-5 text-gray-600" />
</button>
</div>
{loading ? (
<div className="flex justify-center items-center py-10">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<>
{renderCalendar()}
{/* Legenda */}
<div className="mt-4 pt-4 border-t border-gray-200 grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-blue-100"></div>
<span className="text-gray-600">Disponível</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-red-100"></div>
<span className="text-gray-600">Bloqueado</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gray-100"></div>
<span className="text-gray-600">Data passada</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gray-50"></div>
<span className="text-gray-600">Sem horários</span>
</div>
</div>
</>
)}
</div>
);
}