- 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
344 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|