BookingModal: - Add 'Номер' / 'Аренда объекта' tabs when rental objects exist - Rental tab: object picker, date, full-day toggle, conflict-aware time dropdowns, guest/phone fields, total amount RentalBookingModal: - Filter start times: exclude hours inside booked slots + bufferMinutes gap - Filter end times: cap at next booking's start hour - Disable 'Весь день' if any timed bookings exist for the day - Show 'Все слоты заняты' when no start hours are available - Show buffer duration in time section label Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
813 lines
38 KiB
TypeScript
813 lines
38 KiB
TypeScript
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 square per booking */}
|
||
<div className="flex gap-0.5 flex-wrap">
|
||
{dayBookings.map(b => (
|
||
<div
|
||
key={b.id}
|
||
className={cn('w-3 h-3 rounded-[3px]', obj.color)}
|
||
title={`${b.guestName} · ${b.startHour}:00–${b.endHour}:00`}
|
||
/>
|
||
))}
|
||
</div>
|
||
{/* Working hours range */}
|
||
<span className="text-[9px] text-slate-400 dark:text-slate-500 leading-none">
|
||
{obj.openHour}:00–{obj.closeHour}:00
|
||
</span>
|
||
{/* Booked time slots */}
|
||
{dayBookings.map(b => (
|
||
<span key={b.id} className="text-[9px] text-slate-600 dark:text-slate-300 font-medium leading-none">
|
||
{b.startHour}–{b.endHour}
|
||
</span>
|
||
))}
|
||
</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>
|
||
)
|
||
}
|