Files
hotelsync/src/components/calendar/BookingCalendar.tsx
HotelSync 57c22be60d Add floor map, rental module wiring, VIP tags and payment tracking in BookingModal
- New: Поэтажный план (/floor-map) — room status visualization per floor with color coding
  (occupied/arriving/departing/available/dirty/maintenance), click free room to create booking
- New: FloorMapModal — embeddable in BookingModal via "Поэтажный план" button near room selector
- New: RentalBookingModal — full-day toggle, hour start/end selects, conflict detection, price summary
- CalendarPage: rental objects/bookings shown in шахматка when rental module is active
- BookingsPage: "Аренда" tab with rental bookings table when rental module is active
- BookingModal: guest tags (VIP, Постоянный гость, etc.), payment method (cash/terminal),
  paidAmount input, debt/задолженность display, floor map quick-access button
- Sidebar: "План этажей" nav link added under Управление

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 17:08:10 +03:00

545 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { addDays, format, startOfDay, differenceInDays, parseISO, isToday } from 'date-fns'
import { ru } from 'date-fns/locale'
import { ChevronLeft, ChevronRight, Plus, CalendarDays, ChevronDown } 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 LABEL_WIDTH = 160
const DAYS_VISIBLE = 30
interface BookingCalendarProps {
rooms: Room[]
bookings: Booking[]
onBookingCreate: (b: Partial<Booking>) => void
onBookingUpdate: (id: string, b: Partial<Booking>) => void
fadingBookingIds?: Set<string>
rentalObjects?: RentalObject[]
rentalBookings?: RentalBooking[]
onRentalBookingCreate?: (b: RentalBooking) => void
}
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, onBookingCreate, onBookingUpdate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate }: 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)
// 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))
const shiftDays = (n: number) => setStartDate(d => addDays(d, n))
const getBlockStyle = (booking: Booking) => {
const start = parseISO(booking.checkIn)
const end = parseISO(booking.checkOut)
const colStart = Math.max(0, differenceInDays(start, startDate))
const colEnd = Math.min(visibleDays, differenceInDays(end, startDate))
if (colStart >= visibleDays || colEnd <= 0) return null
return {
left: colStart * CELL_WIDTH,
width: (colEnd - colStart) * CELL_WIDTH - 4,
}
}
const handleCellMouseDown = useCallback((roomId: string, dayIdx: number, e: React.MouseEvent) => {
if (e.button !== 0) 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 })
setDragStart(null)
setDragEnd(null)
setDraft(null)
}, [dragStart, dragEnd, startDate])
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>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 capitalize">
{format(startDate, 'LLLL yyyy', { locale: ru })}
</span>
<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', 'cancelled'] 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>
<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="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: LABEL_WIDTH + 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: LABEL_WIDTH }}
>
<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, 'EEE', { locale: ru }).slice(0, 2)}
</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 */}
{rooms.map(room => {
const roomBookings = bookings.filter(b => b.roomId === room.id)
const draftStyle = getDraftStyle(room.id)
return (
<div
key={room.id}
className="flex border-b border-slate-200 dark:border-slate-700 group hover:bg-slate-50/50 dark:hover:bg-slate-800/30"
style={{ height: ROW_HEIGHT }}
>
{/* Room 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 group-hover:bg-slate-50 dark:group-hover:bg-slate-750"
style={{ width: LABEL_WIDTH }}
>
<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 && <span className="text-xs text-slate-500 dark:text-slate-400">{room.name}</span>}
</div>
<span className={cn('text-xs px-1.5 py-0.5 rounded-md font-medium', getRoomTypeColor(room.type))}>
{room.type}
</span>
</div>
<div className="ml-auto text-xs text-slate-400 dark:text-slate-500">
{room.baseRate.toLocaleString('ru-RU')}
</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)
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',
)}
style={{
left: style.left + 2,
width: style.width,
top: 4,
bottom: 4,
position: 'absolute',
}}
onClick={(e) => { e.stopPropagation(); setSelectedBooking(booking) }}
title={`${booking.guestName}${booking.checkIn} ${booking.checkOut}`}
>
<span className="truncate text-xs font-semibold opacity-95 drop-shadow-sm">
{booking.guestName}
</span>
{style.width > 80 && (
<span className="ml-2 opacity-75 text-[10px] shrink-0">
{nights}н {SOURCE_LABELS[booking.source]}
</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 }}
/>
)}
</div>
</div>
)
})}
{/* ── 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: LABEL_WIDTH }}
>
<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
return (
<div key={obj.id}
className="flex border-b border-slate-200 dark:border-slate-700"
style={{ height: ROW_HEIGHT }}>
{/* 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: LABEL_WIDTH }}
>
<span className="text-lg">{obj.icon}</span>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{obj.name}</p>
<p className="text-xs text-slate-400">
{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 === 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: ROW_HEIGHT }}
>
{hasFullDay ? (
/* Full day booking */
<div className={cn(
'absolute inset-1.5 rounded flex items-center justify-center text-white text-[10px] font-medium',
obj.color,
)}>
Весь день
</div>
) : dayBookings.length > 0 ? (
/* Hourly booking bars */
<div className="absolute inset-x-1 bottom-1" style={{ top: 6 }}>
{/* Time scale bar (background) */}
<div className="w-full h-2.5 rounded-sm bg-slate-100 dark:bg-slate-600 relative overflow-hidden">
{dayBookings.map(b => {
const leftPct = ((b.startHour - obj.openHour) / totalSpan) * 100
const widthPct = ((b.endHour - b.startHour) / totalSpan) * 100
return (
<div
key={b.id}
className={cn('absolute h-full rounded-sm', obj.color)}
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
title={`${b.guestName} · ${b.startHour}:00${b.endHour}:00`}
/>
)
})}
</div>
{/* Hour labels */}
<div className="flex justify-between mt-0.5 px-0.5">
<span className="text-[9px] text-slate-400">{obj.openHour}:00</span>
<span className="text-[9px] text-slate-400">{obj.closeHour}:00</span>
</div>
{/* Booking count */}
<div className="flex flex-wrap gap-0.5 mt-1">
{dayBookings.map(b => (
<span key={b.id} className="text-[9px] text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-700 px-1 rounded truncate max-w-full">
{b.startHour}{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}
onClose={() => setBookingModalDraft(null)}
onSave={(data) => {
onBookingCreate(data)
setBookingModalDraft(null)
}}
/>
)}
{/* Booking detail panel */}
{selectedBooking && (
<BookingDetailPanel
booking={selectedBooking}
room={rooms.find(r => r.id === selectedBooking.roomId)}
onClose={() => setSelectedBooking(null)}
onUpdate={(id, data) => {
onBookingUpdate(id, data)
setSelectedBooking(null)
}}
/>
)}
{/* Rental booking modal */}
{rentalModal && (
<RentalBookingModal
obj={rentalModal.obj}
date={rentalModal.date}
existingBookings={(rentalBookings ?? []).filter(
b => b.objectId === rentalModal.obj.id && b.date === rentalModal.date,
)}
onClose={() => setRentalModal(null)}
onSave={(b) => {
onRentalBookingCreate?.(b)
setRentalModal(null)
}}
/>
)}
</div>
)
}