Files
hotelsync/src/components/calendar/BookingCalendar.tsx
HotelSync db05898c08 feat: 15-minute rental slots + compact calendar cell display
- Rental bookings now use minutes since midnight (start_hour/end_hour
  store e.g. 495 for 8:15, 570 for 9:30) — DB migration 017
- Buffer applied in exact minutes (no hour ceiling): 15min buffer after
  9:00 booking → next slot available at 9:15
- Calendar cell: removed working-hours line (8:00–22:00), now shows
  colored dot + compact time per booking (8–10, 8:15–9:15)
- Time selects show HH:MM format for 15-min intervals

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:02:24 +03:00

808 lines
38 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Booking>) => void
onBookingUpdate: (id: string, b: Partial<Booking>) => void
onBookingBulkUpdate?: (updates: Array<{ id: string; data: Partial<Booking> }>) => void
fadingBookingIds?: Set<string>
rentalObjects?: RentalObject[]
rentalBookings?: RentalBooking[]
onRentalBookingCreate?: (b: RentalBooking) => void
locks?: Map<string, BookingLock>
onDraftStart?: (roomId: string, checkIn: string, checkOut: string) => void
onDraftCancel?: (roomId: string) => void
wsConnected?: boolean
}
const CATEGORY_ORDER: Record<string, number> = {
'Стандарт': 0,
'Делюкс': 1,
'Полулюкс': 2,
'Люкс': 3,
'Пентхаус': 4,
'Апартаменты': 5,
}
function getRoomTypeColor(type: string): string {
const map: Record<string, string> = {
'Стандарт': '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<HTMLDivElement>(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<DraftBooking | null>(null)
const [dragStart, setDragStart] = useState<{ roomId: string; dayIdx: number } | null>(null)
const [dragEnd, setDragEnd] = useState<number | null>(null)
// Drag-to-move booking state
const [movingBooking, setMovingBooking] = useState<Booking | null>(null)
const [moveTargetRoomId, setMoveTargetRoomId] = useState<string | null>(null)
const [ghostPos, setGhostPos] = useState<{ x: number; y: number } | null>(null)
const movingBookingRef = useRef<Booking | null>(null)
const moveTargetRoomIdRef = useRef<string | null>(null) // всегда актуален, без stale closure
const bookingsRef = useRef<Booking[]>(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<DraftBooking | null>(null)
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null)
const [rentalModal, setRentalModal] = useState<{ obj: RentalObject; date: string } | null>(null)
const gridRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-col h-full" onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
{/* Toolbar */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shrink-0 flex-wrap gap-y-2">
<div className="flex items-center gap-1.5">
<button onClick={() => shiftDays(-7)} className="btn-ghost p-2"><ChevronLeft size={16} /></button>
{/* Date / period picker */}
<div className="relative" ref={navPickerRef}>
<button
onClick={() => setShowNavPicker(v => !v)}
className={cn(
'btn-secondary flex items-center gap-1.5 px-2.5 py-1.5 text-xs',
showNavPicker && 'bg-slate-200 dark:bg-slate-600',
)}
>
<CalendarDays size={12} className={isToday(startDate) ? 'text-brand-600' : 'text-slate-400'} />
<span className={cn('font-medium', isToday(startDate) ? 'text-brand-600' : '')}>
{isToday(startDate) ? 'Сегодня' : format(startDate, 'd MMM', { locale: ru })}
</span>
<span className="text-slate-400">·</span>
<span className="text-slate-400">{visibleDays}д</span>
<ChevronDown size={11} className="text-slate-400" />
</button>
{showNavPicker && (
<div className="absolute top-full left-0 mt-1 z-50 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl p-4 w-64">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Начало периода</p>
<div className="flex gap-2 mb-3">
<input
type="date"
value={pickerDateInput}
onChange={e => setPickerDateInput(e.target.value)}
className="input text-sm py-1.5 flex-1"
/>
<button
onClick={() => {
const today = format(new Date(), 'yyyy-MM-dd')
setPickerDateInput(today)
setStartDate(startOfDay(new Date()))
}}
className="btn-secondary text-xs px-2.5 shrink-0"
>
Сег.
</button>
</div>
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Дней в окне</p>
<div className="flex gap-1.5 flex-wrap mb-4">
{[14, 21, 30, 45, 60].map(d => (
<button
key={d}
onClick={() => setVisibleDays(d)}
className={cn(
'px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
visibleDays === d
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
)}
>
{d}д
</button>
))}
</div>
<button
onClick={() => {
const parsed = new Date(pickerDateInput)
if (!isNaN(parsed.getTime())) setStartDate(startOfDay(parsed))
setShowNavPicker(false)
}}
className="btn-primary w-full justify-center text-sm py-1.5"
>
Показать
</button>
</div>
)}
</div>
<button onClick={() => shiftDays(7)} className="btn-ghost p-2"><ChevronRight size={16} /></button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 capitalize">
{format(startDate, 'LLLL yyyy', { locale: ru })}
</span>
{/* Mobile-only + button next to month name */}
<button
className="sm:hidden btn-primary p-1.5"
onClick={() => {
const today = format(new Date(), 'yyyy-MM-dd')
const tomorrow = format(addDays(new Date(), 1), 'yyyy-MM-dd')
setBookingModalDraft({ roomId: rooms[0]?.id ?? '', checkIn: today, checkOut: tomorrow })
}}
>
<Plus size={15} />
</button>
</div>
<div className="flex-1" />
{/* Legend */}
<div className="hidden lg:flex items-center gap-3 text-xs text-slate-500">
{(['confirmed', 'checked_in', 'checked_out', 'inquiry'] as const).map(s => (
<div key={s} className="flex items-center gap-1.5">
<div className={cn('w-3 h-3 rounded-sm', BOOKING_STATUS_COLORS[s].split(' ')[0])} />
<span>{BOOKING_STATUS_LABELS[s]}</span>
</div>
))}
</div>
{/* WebSocket status indicator */}
{wsConnected !== undefined && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs text-slate-400 dark:text-slate-500"
title={wsConnected ? 'Синхронизация активна' : 'Нет соединения — переподключение...'}
>
<span className={cn(
'w-1.5 h-1.5 rounded-full',
wsConnected ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600 animate-pulse',
)} />
<span className="hidden sm:inline">{wsConnected ? 'Онлайн' : 'Офлайн'}</span>
</div>
)}
<button
onClick={() => setCompact(v => { const next = !v; localStorage.setItem('calendarCompact', String(next)); return next })}
title={compact ? 'Обычный вид' : 'Компактный вид'}
className={cn(
'hidden sm:flex btn-ghost items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium',
compact && 'bg-brand-50 dark:bg-brand-900/20 text-brand-600 dark:text-brand-400',
)}
>
<AlignJustify size={14} />
Вид
</button>
<button
onClick={() => {
const today = format(new Date(), 'yyyy-MM-dd')
const tomorrow = format(addDays(new Date(), 1), 'yyyy-MM-dd')
setBookingModalDraft({ roomId: rooms[0]?.id ?? '', checkIn: today, checkOut: tomorrow })
}}
className="hidden sm:inline-flex btn-primary"
>
<Plus size={15} />
<span className="hidden sm:inline">Новое бронирование</span>
</button>
</div>
{/* Grid */}
<div className="flex-1 overflow-auto" ref={gridRef}>
<div style={{ minWidth: labelWidth + visibleDays * CELL_WIDTH }}>
{/* Date header row */}
<div
className="flex sticky top-0 z-20 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 shadow-sm"
style={{ height: 48 }}
>
<div
className="shrink-0 sticky left-0 z-30 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 flex items-center px-3"
style={{ width: labelWidth }}
>
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Номер</span>
</div>
{dates.map((date, i) => {
const isWe = date.getDay() === 0 || date.getDay() === 6
const isTod = isToday(date)
return (
<div
key={i}
className={cn(
'shrink-0 flex flex-col items-center justify-center border-r border-slate-200 dark:border-slate-700 select-none',
isWe && 'bg-slate-50 dark:bg-slate-800/60',
isTod && 'bg-brand-50 dark:bg-brand-900/20',
)}
style={{ width: CELL_WIDTH }}
>
<span className={cn(
'text-xs font-medium',
isTod ? 'text-brand-600 dark:text-brand-400' : isWe ? 'text-slate-400' : 'text-slate-500 dark:text-slate-400',
)}>
{format(date, 'EEEEEE', { locale: ru })}
</span>
<span className={cn(
'text-sm font-bold',
isTod ? 'text-brand-600 dark:text-brand-400' : 'text-slate-700 dark:text-slate-200',
)}>
{format(date, 'd')}
</span>
</div>
)
})}
</div>
{/* Room rows grouped by category */}
{(() => {
const rows: ReactNode[] = []
let lastCategory = ''
sortedRooms.forEach(room => {
// Category header
if (room.type !== lastCategory) {
lastCategory = room.type
rows.push(
<div
key={`cat-${room.type}`}
className="flex sticky left-0 bg-slate-50 dark:bg-slate-700/40 border-b border-t border-slate-200 dark:border-slate-600"
style={{ height: 28 }}
>
<div
className="shrink-0 sticky left-0 z-10 bg-slate-50 dark:bg-slate-700/40 border-r border-slate-200 dark:border-slate-600 flex items-center px-2 overflow-hidden"
style={{ width: labelWidth }}
>
<span className={cn('text-xs font-semibold uppercase tracking-wide truncate', getRoomTypeColor(room.type).split(' ').filter(c => c.startsWith('text-')).join(' '))}>
{room.type}
</span>
</div>
{dates.map((_, i) => (
<div key={i} className="shrink-0 border-r border-slate-200 dark:border-slate-600" style={{ width: CELL_WIDTH }} />
))}
</div>
)
}
// Room row
const roomBookings = bookings.filter(b => b.roomId === room.id && b.status !== 'cancelled')
const draftStyle = getDraftStyle(room.id)
rows.push(
<div
key={room.id}
data-room-id={room.id}
className={cn(
'flex border-b border-slate-200 dark:border-slate-700 group hover:bg-slate-50/50 dark:hover:bg-slate-800/30',
movingBooking && moveTargetRoomId === room.id && 'bg-brand-50/60 dark:bg-brand-900/20',
)}
style={{ height: rowHeight }}
>
{/* Room label */}
<div
className={cn('shrink-0 sticky left-0 z-10 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 flex items-center gap-2 group-hover:bg-slate-50 dark:group-hover:bg-slate-800', isMobile ? 'px-2' : 'px-3')}
style={{ width: labelWidth }}
>
<div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">{room.number}</span>
{room.name && !isMobile && <span className="text-xs text-slate-500 dark:text-slate-400">{room.name}</span>}
</div>
{!compact && !isMobile && (
<span className={cn('text-xs px-1.5 py-0.5 rounded-md font-medium', getRoomTypeColor(room.type))}>
{room.type}
</span>
)}
</div>
</div>
{/* Day cells + booking blocks */}
<div className="relative flex-1">
<div className="flex h-full">
{dates.map((date, i) => {
const isWe = date.getDay() === 0 || date.getDay() === 6
const isTod = isToday(date)
return (
<div
key={i}
className={cn(
'shrink-0 h-full border-r border-slate-200 dark:border-slate-700 cursor-crosshair',
isWe && 'bg-slate-50 dark:bg-slate-800/60',
isTod && 'bg-brand-50/50 dark:bg-brand-900/10',
)}
style={{ width: CELL_WIDTH }}
onMouseDown={(e) => handleCellMouseDown(room.id, i, e)}
onMouseEnter={() => handleCellMouseEnter(i)}
/>
)
})}
</div>
{/* 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 (
<div
key={booking.id}
className={cn(
'booking-block border-l-4 transition-all duration-700',
BOOKING_STATUS_COLORS[booking.status],
isFading && 'opacity-0 scale-y-0',
booking.status === 'checked_out' && 'opacity-50 pointer-events-none',
movingBooking?.id === booking.id && 'opacity-40 pointer-events-none',
booking.status !== 'checked_out' && !movingBooking && 'cursor-grab',
)}
style={{
left: style.left + 2,
width: style.width,
top: compact ? 2 : 4,
bottom: compact ? 2 : 4,
position: 'absolute',
}}
onMouseDown={(e) => {
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 && (
<span className="shrink-0 w-1.5 h-1.5 rounded-full bg-red-500 shadow-sm mr-1 mt-0.5" />
)}
{hourlyMatch && <Clock size={9} className="shrink-0 mr-0.5 opacity-80" />}
<span className="truncate text-xs font-semibold opacity-95 drop-shadow-sm">
{booking.guestName}
</span>
{!compact && hourlyMatch && style.width > 80 && (
<span className="ml-1 opacity-80 text-[10px] shrink-0">
{hourlyMatch[1]}{hourlyMatch[2]}ч
</span>
)}
{!compact && !hourlyMatch && style.width > 90 && guestCount > 0 && (
<span className="ml-1.5 opacity-75 text-[10px] shrink-0">
{guestCount}г
</span>
)}
{!compact && !hourlyMatch && style.width > 130 && (
<span className="ml-1.5 opacity-75 text-[10px] shrink-0">
{nights}н
</span>
)}
</div>
)
})}
{/* Draft overlay */}
{draftStyle && (
<div
className="absolute top-2 bottom-2 bg-brand-400/40 border-2 border-brand-500 border-dashed rounded-md pointer-events-none"
style={{ left: draftStyle.left + 2, width: draftStyle.width }}
/>
)}
{/* Lock overlay — another manager is editing this room */}
{(() => {
const lockStyle = getLockStyle(room.id)
if (!lockStyle) return null
const lock = locks.get(room.id)!
return (
<div
className="absolute top-1 bottom-1 rounded-md pointer-events-none z-10"
style={{
left: lockStyle.left + 2,
width: lockStyle.width,
background: 'repeating-linear-gradient(45deg, rgba(100,116,139,0.2) 0px, rgba(100,116,139,0.2) 4px, rgba(100,116,139,0.05) 4px, rgba(100,116,139,0.05) 10px)',
border: '1.5px dashed rgba(100,116,139,0.55)',
}}
>
{lockStyle.width > 60 && (
<span className="absolute inset-x-1 top-1/2 -translate-y-1/2 text-[9px] text-slate-500 dark:text-slate-400 font-medium truncate text-center leading-tight select-none">
{lock.lockedBy}
</span>
)}
</div>
)
})()}
</div>
</div>
)
})
return rows
})()}
{/* ── Rental section ── */}
{rentalObjects && rentalObjects.length > 0 && (
<>
{/* Separator */}
<div className="flex sticky left-0 bg-slate-100 dark:bg-slate-700/60 border-b border-t border-slate-200 dark:border-slate-600"
style={{ height: 32 }}>
<div
className="shrink-0 sticky left-0 z-10 bg-slate-100 dark:bg-slate-700/60 border-r border-slate-200 dark:border-slate-600 flex items-center px-4"
style={{ width: labelWidth }}
>
<span className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
Аренда объектов
</span>
</div>
{dates.map((_, i) => (
<div key={i} className="shrink-0 border-r border-slate-200 dark:border-slate-600"
style={{ width: CELL_WIDTH }} />
))}
</div>
{/* Rental object rows */}
{rentalObjects.map(obj => {
const totalSpan = obj.closeHour - obj.openHour
const rentalRowH = Math.max(rowHeight, ROW_HEIGHT)
return (
<div key={obj.id}
className="flex border-b border-slate-200 dark:border-slate-700"
style={{ height: rentalRowH }}>
{/* Label */}
<div
className="shrink-0 sticky left-0 z-10 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 flex items-center gap-2 px-3"
style={{ width: labelWidth }}
>
<span className="text-base leading-none shrink-0">{obj.icon}</span>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate leading-tight">{obj.name}</p>
<p className="text-[10px] text-slate-400 whitespace-nowrap leading-tight mt-0.5">
{obj.pricePerHour.toLocaleString('ru-RU')} /ч · {obj.pricePerDay.toLocaleString('ru-RU')} /д
</p>
</div>
</div>
{/* 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 as string).slice(0, 10) === dateStr && b.status !== 'cancelled',
)
const hasFullDay = dayBookings.some(b => b.isFullDay)
return (
<div
key={i}
onClick={() => 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 */
<div className={cn(
'absolute inset-1.5 rounded flex items-center justify-center text-white text-[10px] font-semibold',
obj.color,
)}>
Весь день
</div>
) : dayBookings.length > 0 ? (
/* Hourly bookings — squares + times */
<div className="absolute inset-x-1 top-1.5 flex flex-col gap-0.5">
{/* One colored dot per booking + its time */}
{dayBookings.map(b => {
const fmtT = (min: number) => {
const h = Math.floor(min / 60), m = min % 60
return m === 0 ? `${h}` : `${h}:${String(m).padStart(2,'0')}`
}
return (
<div key={b.id} className="flex items-center gap-0.5">
<div className={cn('w-2.5 h-2.5 rounded-[3px] shrink-0', obj.color)} />
<span className="text-[9px] text-slate-700 dark:text-slate-200 font-medium leading-none truncate">
{fmtT(b.startHour)}{fmtT(b.endHour)}
</span>
</div>
)
})}
</div>
) : (
/* Empty — show "+" hint on hover */
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
<span className="text-xs text-slate-400">+</span>
</div>
)}
</div>
)
})}
</div>
)
})}
</>
)}
</div>
</div>
{/* Booking create modal */}
{bookingModalDraft && (
<BookingModal
open={true}
draft={bookingModalDraft}
rooms={rooms}
bookings={bookings}
slug={slug}
rentalObjects={rentalObjects}
rentalBookings={rentalBookings as unknown as import('../../data/rentalData').RentalBooking[]}
onRentalSave={rb => { onRentalBookingCreate?.(rb); setBookingModalDraft(null) }}
onClose={() => {
if (bookingModalDraft) onDraftCancel?.(bookingModalDraft.roomId)
setBookingModalDraft(null)
}}
onSave={(data) => {
onBookingCreate(data)
setBookingModalDraft(null)
}}
/>
)}
{/* Booking detail panel */}
{selectedBooking && (
<BookingDetailPanel
booking={selectedBooking}
room={rooms.find(r => 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 && (
<div
className={cn(
'fixed z-[9999] pointer-events-none px-3 py-1.5 rounded-lg text-white text-xs font-semibold shadow-xl border-2 border-white/40',
BOOKING_STATUS_COLORS[movingBooking.status],
)}
style={{ left: ghostPos.x + 14, top: ghostPos.y - 16 }}
>
{movingBooking.guestName}
</div>
)}
{/* Rental booking modal */}
{rentalModal && (
<RentalBookingModal
obj={rentalModal.obj}
date={rentalModal.date}
existingBookings={(rentalBookings ?? []).filter(
b => b.objectId === rentalModal.obj.id && (b.date as string).slice(0, 10) === rentalModal.date,
)}
bookings={bookings}
rooms={rooms}
slug={slug}
onClose={() => setRentalModal(null)}
onSave={(b) => {
onRentalBookingCreate?.(b)
setRentalModal(null)
}}
/>
)}
</div>
)
}