Реализация календаря доступности для бронирования на сайте
Календарь доступности — центральный UI-элемент системы бронирования. Показывает, какие даты открыты для записи, какие заняты, какие заблокированы. От корректности отображения зависит пользовательский опыт и количество ошибочных броней.
Логика доступности
Доступность слота определяется пересечением нескольких источников:
interface AvailabilitySlot {
datetime: Date;
available: boolean;
reason?: 'booked' | 'blocked' | 'outside_hours' | 'capacity_full';
capacity?: number; // для группового бронирования
remaining?: number;
}
class AvailabilityService {
async getAvailability(resourceId: number, from: Date, to: Date): Promise<AvailabilitySlot[]> {
const [schedule, bookings, blocks] = await Promise.all([
this.getWorkingSchedule(resourceId), // часы работы
this.getBookings(resourceId, from, to),
this.getBlocks(resourceId, from, to), // ручные блокировки
]);
return this.generateSlots(from, to, schedule, bookings, blocks);
}
}
Компонент календаря (React)
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { startOfMonth, endOfMonth, eachDayOfInterval, format, isSameDay } from 'date-fns';
import { ru } from 'date-fns/locale';
interface DayAvailability {
date: string;
hasSlots: boolean;
allBooked: boolean;
}
export function AvailabilityCalendar({ resourceId, onDateSelect }: Props) {
const [currentMonth, setCurrentMonth] = useState(new Date());
const { data: availability } = useQuery({
queryKey: ['availability', resourceId, format(currentMonth, 'yyyy-MM')],
queryFn: () => fetchMonthAvailability(resourceId, currentMonth),
staleTime: 60_000,
});
const days = eachDayOfInterval({
start: startOfMonth(currentMonth),
end: endOfMonth(currentMonth),
});
return (
<div className="grid grid-cols-7 gap-1">
{/* Заголовки дней недели */}
{['Пн','Вт','Ср','Чт','Пт','Сб','Вс'].map(d => (
<div key={d} className="text-center text-xs text-gray-500 py-2">{d}</div>
))}
{/* Дни месяца */}
{days.map(day => {
const dateStr = format(day, 'yyyy-MM-dd');
const dayData = availability?.find(a => a.date === dateStr);
const isToday = isSameDay(day, new Date());
return (
<button
key={dateStr}
disabled={!dayData?.hasSlots || dayData?.allBooked}
onClick={() => onDateSelect(day)}
className={cn(
'aspect-square rounded-lg text-sm font-medium transition-colors',
isToday && 'ring-2 ring-blue-500',
dayData?.hasSlots && !dayData?.allBooked
? 'bg-green-50 text-green-700 hover:bg-green-100'
: 'bg-gray-50 text-gray-300 cursor-not-allowed'
)}
>
{format(day, 'd')}
</button>
);
})}
</div>
);
}
Оптимизация загрузки
Доступность загружается помесячно и кэшируется 60 секунд. При выборе даты грузятся временные слоты конкретного дня. Инвалидация кэша — через WebSocket-событие при создании нового бронирования.
Синхронизация нескольких ресурсов
Для сервисов с несколькими специалистами/залами — выбор конкретного ресурса или показ первого доступного слота среди всех:
async function getFirstAvailableSlot(date: Date, serviceId: number): Promise<Slot | null> {
const resources = await getServiceResources(serviceId);
const slots = await Promise.all(
resources.map(r => getAvailableSlots(r.id, date))
);
return slots.flat().sort((a, b) => a.datetime - b.datetime)[0] ?? null;
}
Сроки
Календарь доступности с API и компонентом: 4–6 рабочих дней.







