import { useState, useRef, useCallback, useEffect } from 'react' import type { ReactNode } from 'react' import { addDays, format, startOfDay, differenceInDays, parseISO, isToday } from 'date-fns' import { ru } from 'date-fns/locale' import { ChevronLeft, ChevronRight, Plus, CalendarDays, ChevronDown, AlignJustify, Clock } from 'lucide-react' import { cn, BOOKING_STATUS_COLORS, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils' import type { Room, Booking, DraftBooking } from '../../types' import type { RentalObject, RentalBooking } from '../../data/rentalData' import { BookingModal } from '../bookings/BookingModal' import { BookingDetailPanel } from '../bookings/BookingDetailPanel' import { RentalBookingModal } from '../rental/RentalBookingModal' const CELL_WIDTH = 52 const ROW_HEIGHT = 56 const ROW_HEIGHT_COMPACT = 34 const LABEL_WIDTH = 160 const DAYS_VISIBLE = 30 export type BookingLock = { checkIn: string; checkOut: string; lockedBy: string } interface BookingCalendarProps { rooms: Room[] bookings: Booking[] slug?: string onBookingCreate: (b: Partial) => void onBookingUpdate: (id: string, b: Partial) => void onBookingBulkUpdate?: (updates: Array<{ id: string; data: Partial }>) => void fadingBookingIds?: Set rentalObjects?: RentalObject[] rentalBookings?: RentalBooking[] onRentalBookingCreate?: (b: RentalBooking) => void locks?: Map onDraftStart?: (roomId: string, checkIn: string, checkOut: string) => void onDraftCancel?: (roomId: string) => void wsConnected?: boolean } const CATEGORY_ORDER: Record = { 'Стандарт': 0, 'Делюкс': 1, 'Полулюкс': 2, 'Люкс': 3, 'Пентхаус': 4, 'Апартаменты': 5, } function getRoomTypeColor(type: string): string { const map: Record = { 'Стандарт': 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300', 'Делюкс': 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300', 'Полулюкс': 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300', 'Люкс': 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300', 'Пентхаус': 'bg-rose-100 dark:bg-rose-900/30 text-rose-700 dark:text-rose-300', 'Апартаменты': 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300', } return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300' } export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected }: BookingCalendarProps) { const [startDate, setStartDate] = useState(() => startOfDay(new Date())) const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE) // Date picker state const [showNavPicker, setShowNavPicker] = useState(false) const [pickerDateInput, setPickerDateInput] = useState(format(new Date(), 'yyyy-MM-dd')) const navPickerRef = useRef(null) useEffect(() => { if (!showNavPicker) return const handler = (e: MouseEvent) => { if (navPickerRef.current && !navPickerRef.current.contains(e.target as Node)) { setShowNavPicker(false) } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [showNavPicker]) // Drag-to-book state const [draft, setDraft] = useState(null) const [dragStart, setDragStart] = useState<{ roomId: string; dayIdx: number } | null>(null) const [dragEnd, setDragEnd] = useState(null) // Drag-to-move booking state const [movingBooking, setMovingBooking] = useState(null) const [moveTargetRoomId, setMoveTargetRoomId] = useState(null) const [ghostPos, setGhostPos] = useState<{ x: number; y: number } | null>(null) const movingBookingRef = useRef(null) const moveTargetRoomIdRef = useRef(null) // всегда актуален, без stale closure const bookingsRef = useRef(bookings) const didDragRef = useRef(false) movingBookingRef.current = movingBooking bookingsRef.current = bookings // Compact mode (default from Settings → Appearance) const [compact, setCompact] = useState( () => localStorage.getItem('calendarCompact') === 'true', ) const rowHeight = compact ? ROW_HEIGHT_COMPACT : ROW_HEIGHT // Mobile detection const [isMobile, setIsMobile] = useState(() => window.innerWidth < 640) useEffect(() => { const h = () => setIsMobile(window.innerWidth < 640) window.addEventListener('resize', h) return () => window.removeEventListener('resize', h) }, []) const labelWidth = isMobile ? 80 : LABEL_WIDTH // Modals const [bookingModalDraft, setBookingModalDraft] = useState(null) const [selectedBooking, setSelectedBooking] = useState(null) const [rentalModal, setRentalModal] = useState<{ obj: RentalObject; date: string } | null>(null) const gridRef = useRef(null) const dates = Array.from({ length: visibleDays }, (_, i) => addDays(startDate, i)) // Sorted+grouped rooms const sortedRooms = [...rooms].sort((a, b) => { const ao = CATEGORY_ORDER[a.type] ?? 99 const bo = CATEGORY_ORDER[b.type] ?? 99 if (ao !== bo) return ao - bo return a.number.localeCompare(b.number, 'ru', { numeric: true }) }) const shiftDays = (n: number) => setStartDate(d => addDays(d, n)) const getBlockStyle = (booking: Booking) => { const start = parseISO(booking.checkIn) const end = parseISO(booking.checkOut) const rawColStart = differenceInDays(start, startDate) const rawColEnd = differenceInDays(end, startDate) if (rawColStart >= visibleDays || rawColEnd <= 0) return null const INSET = Math.round(CELL_WIDTH / 3) const isClippedLeft = rawColStart <= 0 const isClippedRight = rawColEnd > visibleDays const colStart = Math.max(0, rawColStart) const colEnd = Math.min(visibleDays, rawColEnd) const leftPx = colStart * CELL_WIDTH + (isClippedLeft ? 0 : INSET) const rightPx = colEnd * CELL_WIDTH + (isClippedRight ? 0 : INSET) return { left: leftPx + 2, width: Math.max(8, rightPx - leftPx - 4), } } const handleCellMouseDown = useCallback((roomId: string, dayIdx: number, e: React.MouseEvent) => { if (e.button !== 0) return if (movingBookingRef.current) return e.preventDefault() setDragStart({ roomId, dayIdx }) setDragEnd(dayIdx) }, []) const handleCellMouseEnter = useCallback((dayIdx: number) => { if (!dragStart) return setDragEnd(dayIdx) }, [dragStart]) const handleMouseUp = useCallback(() => { if (!dragStart || dragEnd === null) return const minDay = Math.min(dragStart.dayIdx, dragEnd) const maxDay = Math.max(dragStart.dayIdx, dragEnd) const checkIn = format(addDays(startDate, minDay), 'yyyy-MM-dd') const checkOut = format(addDays(startDate, maxDay + 1), 'yyyy-MM-dd') setBookingModalDraft({ roomId: dragStart.roomId, checkIn, checkOut }) onDraftStart?.(dragStart.roomId, checkIn, checkOut) setDragStart(null) setDragEnd(null) setDraft(null) }, [dragStart, dragEnd, startDate, onDraftStart, onBookingUpdate]) const getLockStyle = (roomId: string) => { const lock = locks.get(roomId) if (!lock) return null return getBlockStyle({ checkIn: lock.checkIn, checkOut: lock.checkOut, roomId } as Booking) } const getDraftStyle = (roomId: string) => { if (!dragStart || dragEnd === null || dragStart.roomId !== roomId) return null const minDay = Math.min(dragStart.dayIdx, dragEnd) const maxDay = Math.max(dragStart.dayIdx, dragEnd) return { left: minDay * CELL_WIDTH, width: (maxDay - minDay + 1) * CELL_WIDTH - 2, } } return (
{/* Toolbar */}
{/* Date / period picker */}
{showNavPicker && (

Начало периода

setPickerDateInput(e.target.value)} className="input text-sm py-1.5 flex-1" />

Дней в окне

{[14, 21, 30, 45, 60].map(d => ( ))}
)}
{format(startDate, 'LLLL yyyy', { locale: ru })} {/* Mobile-only + button next to month name */}
{/* Legend */}
{(['confirmed', 'checked_in', 'checked_out', 'inquiry'] as const).map(s => (
{BOOKING_STATUS_LABELS[s]}
))}
{/* WebSocket status indicator */} {wsConnected !== undefined && (
{wsConnected ? 'Онлайн' : 'Офлайн'}
)}
{/* Grid */}
{/* Date header row */}
Номер
{dates.map((date, i) => { const isWe = date.getDay() === 0 || date.getDay() === 6 const isTod = isToday(date) return (
{format(date, 'EEEEEE', { locale: ru })} {format(date, 'd')}
) })}
{/* Room rows grouped by category */} {(() => { const rows: ReactNode[] = [] let lastCategory = '' sortedRooms.forEach(room => { // Category header if (room.type !== lastCategory) { lastCategory = room.type rows.push(
c.startsWith('text-')).join(' '))}> {room.type}
{dates.map((_, i) => (
))}
) } // Room row const roomBookings = bookings.filter(b => b.roomId === room.id && b.status !== 'cancelled') const draftStyle = getDraftStyle(room.id) rows.push(
{/* Room label */}
{room.number} {room.name && !isMobile && {room.name}}
{!compact && !isMobile && ( {room.type} )}
{/* Day cells + booking blocks */}
{dates.map((date, i) => { const isWe = date.getDay() === 0 || date.getDay() === 6 const isTod = isToday(date) return (
handleCellMouseDown(room.id, i, e)} onMouseEnter={() => handleCellMouseEnter(i)} /> ) })}
{/* Booking blocks */} {roomBookings.map(booking => { const style = getBlockStyle(booking) if (!style) return null const nights = differenceInDays(parseISO(booking.checkOut), parseISO(booking.checkIn)) const isFading = fadingBookingIds?.has(booking.id) const isUnpaid = booking.paidAmount < booking.totalAmount const guestCount = (booking.adults ?? 0) + (booking.children ?? 0) const hourlyMatch = booking.notes?.match(/\[Почасово: (\d+):00–(\d+):00\]/) return (
{ if (e.button !== 0) return e.stopPropagation() e.preventDefault() const startX = e.clientX const startY = e.clientY let dragging = false const onMove = (ev: MouseEvent) => { if (!dragging) { if (Math.abs(ev.clientX - startX) > 5 || Math.abs(ev.clientY - startY) > 5) { dragging = true setMovingBooking(booking) moveTargetRoomIdRef.current = booking.roomId setMoveTargetRoomId(booking.roomId) document.body.style.cursor = 'grabbing' } return } setGhostPos({ x: ev.clientX, y: ev.clientY }) // Находим строку под курсором и запоминаем в ref (без stale closure) const el = document.elementFromPoint(ev.clientX, ev.clientY) const row = el?.closest('[data-room-id]') as HTMLElement | null if (row?.dataset.roomId) { moveTargetRoomIdRef.current = row.dataset.roomId setMoveTargetRoomId(row.dataset.roomId) } } const onUp = () => { window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) document.body.style.cursor = '' if (dragging) { // Читаем из ref — всегда актуальное значение, не stale closure const targetRoomId = moveTargetRoomIdRef.current if (targetRoomId && targetRoomId !== booking.roomId) { // Проверяем конфликт в целевом номере const hasConflict = bookingsRef.current.some(b => b.roomId === targetRoomId && b.id !== booking.id && b.status !== 'cancelled' && b.status !== 'no_show' && b.status !== 'checked_out' && b.checkIn < booking.checkOut && b.checkOut > booking.checkIn, ) if (!hasConflict) { didDragRef.current = true onBookingUpdate(booking.id, { roomId: targetRoomId }) } } setMovingBooking(null) setMoveTargetRoomId(null) setGhostPos(null) moveTargetRoomIdRef.current = null } } window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) }} onClick={(e) => { e.stopPropagation() if (didDragRef.current) { didDragRef.current = false; return } setSelectedBooking(booking) }} title={`${booking.guestName} • ${booking.checkIn}${hourlyMatch ? ` ${hourlyMatch[1]}:00–${hourlyMatch[2]}:00` : ` – ${booking.checkOut}`}${isUnpaid ? ' • Не оплачено' : ''}`} > {isUnpaid && ( )} {hourlyMatch && } {booking.guestName} {!compact && hourlyMatch && style.width > 80 && ( {hourlyMatch[1]}–{hourlyMatch[2]}ч )} {!compact && !hourlyMatch && style.width > 90 && guestCount > 0 && ( {guestCount}г )} {!compact && !hourlyMatch && style.width > 130 && ( {nights}н )}
) })} {/* Draft overlay */} {draftStyle && (
)} {/* Lock overlay — another manager is editing this room */} {(() => { const lockStyle = getLockStyle(room.id) if (!lockStyle) return null const lock = locks.get(room.id)! return (
{lockStyle.width > 60 && ( ✏️ {lock.lockedBy} )}
) })()}
) }) return rows })()} {/* ── Rental section ── */} {rentalObjects && rentalObjects.length > 0 && ( <> {/* Separator */}
Аренда объектов
{dates.map((_, i) => (
))}
{/* Rental object rows */} {rentalObjects.map(obj => { const totalSpan = obj.closeHour - obj.openHour const rentalRowH = Math.max(rowHeight, ROW_HEIGHT) return (
{/* Label */}
{obj.icon}

{obj.name}

{obj.pricePerHour.toLocaleString('ru-RU')} ₽/ч · {obj.pricePerDay.toLocaleString('ru-RU')} ₽/д

{/* Day cells */} {dates.map((date, i) => { const dateStr = format(date, 'yyyy-MM-dd') const isWe = date.getDay() === 0 || date.getDay() === 6 const isTod = isToday(date) const dayBookings = (rentalBookings ?? []).filter( b => b.objectId === obj.id && b.date === dateStr && b.status !== 'cancelled', ) const hasFullDay = dayBookings.some(b => b.isFullDay) return (
setRentalModal({ obj, date: dateStr })} className={cn( 'shrink-0 border-r border-slate-200 dark:border-slate-700 cursor-pointer relative', 'hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors', isWe && 'bg-amber-50/30 dark:bg-amber-900/10', isTod && 'bg-brand-50/40 dark:bg-brand-900/10', )} style={{ width: CELL_WIDTH, height: rentalRowH }} > {hasFullDay ? ( /* Full day booking */
Весь день
) : dayBookings.length > 0 ? ( /* Hourly bookings — squares + times */
{/* One colored square per booking */}
{dayBookings.map(b => (
))}
{/* Working hours range */} {obj.openHour}:00–{obj.closeHour}:00 {/* Booked time slots */} {dayBookings.map(b => ( {b.startHour}–{b.endHour} ))}
) : ( /* Empty — show "+" hint on hover */
+
)}
) })}
) })} )}
{/* Booking create modal */} {bookingModalDraft && ( { if (bookingModalDraft) onDraftCancel?.(bookingModalDraft.roomId) setBookingModalDraft(null) }} onSave={(data) => { onBookingCreate(data) setBookingModalDraft(null) }} /> )} {/* Booking detail panel */} {selectedBooking && ( r.id === selectedBooking.roomId)} rooms={rooms} allBookings={bookings} slug={slug} onClose={() => setSelectedBooking(null)} onUpdate={(id, data) => { onBookingUpdate(id, data) setSelectedBooking(null) }} onBulkUpdate={onBookingBulkUpdate ? (updates) => { onBookingBulkUpdate(updates) setSelectedBooking(null) } : undefined} /> )} {/* Drag ghost element */} {movingBooking && ghostPos && (
{movingBooking.guestName}
)} {/* Rental booking modal */} {rentalModal && ( b.objectId === rentalModal.obj.id && b.date === rentalModal.date, )} bookings={bookings} rooms={rooms} onClose={() => setRentalModal(null)} onSave={(b) => { onRentalBookingCreate?.(b) setRentalModal(null) }} /> )}
) }