Initial commit: HotelSync PMS v0.1.0
- React 18 + TypeScript + Vite + Tailwind CSS - Шахматка бронирований (drag-to-book) - Страницы: Calendar, Bookings, Rooms, Housekeeping, Channels, API Docs, Settings - Роли: super_admin, hotel_manager, housekeeper - Светлая/тёмная тема - Docker + Nginx конфигурация - Лендинг hotelsync.ru
This commit is contained in:
332
src/components/calendar/BookingCalendar.tsx
Normal file
332
src/components/calendar/BookingCalendar.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { addDays, format, startOfDay, differenceInDays, parseISO, isToday } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
|
||||
import { cn, BOOKING_STATUS_COLORS, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils'
|
||||
import type { Room, Booking, DraftBooking } from '../../types'
|
||||
import { BookingModal } from '../bookings/BookingModal'
|
||||
import { BookingDetailPanel } from '../bookings/BookingDetailPanel'
|
||||
|
||||
const CELL_WIDTH = 52 // px per day column
|
||||
const ROW_HEIGHT = 56 // px per room row
|
||||
const LABEL_WIDTH = 160 // px for room label column
|
||||
const DAYS_VISIBLE = 30 // default window
|
||||
|
||||
interface BookingCalendarProps {
|
||||
rooms: Room[]
|
||||
bookings: Booking[]
|
||||
onBookingCreate: (b: Partial<Booking>) => void
|
||||
onBookingUpdate: (id: string, b: Partial<Booking>) => 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 }: BookingCalendarProps) {
|
||||
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
|
||||
const [days] = useState(DAYS_VISIBLE)
|
||||
|
||||
// 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 gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Generate date array
|
||||
const dates = Array.from({ length: days }, (_, i) => addDays(startDate, i))
|
||||
|
||||
// Navigate
|
||||
const shiftDays = (n: number) => setStartDate(d => addDays(d, n))
|
||||
|
||||
const jumpToToday = () => setStartDate(startOfDay(new Date()))
|
||||
|
||||
// Compute booking block position
|
||||
const getBlockStyle = (booking: Booking) => {
|
||||
const start = parseISO(booking.checkIn)
|
||||
const end = parseISO(booking.checkOut)
|
||||
const windowEnd = addDays(startDate, days)
|
||||
|
||||
const colStart = Math.max(0, differenceInDays(start, startDate))
|
||||
const colEnd = Math.min(days, differenceInDays(end, startDate))
|
||||
if (colStart >= days || colEnd <= 0) return null
|
||||
|
||||
return {
|
||||
left: colStart * CELL_WIDTH,
|
||||
width: (colEnd - colStart) * CELL_WIDTH - 4,
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse handlers for drag-to-book
|
||||
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])
|
||||
|
||||
// Draft overlay bounds
|
||||
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>
|
||||
<button onClick={jumpToToday} className="btn-secondary px-3 py-1.5 text-xs">Сегодня</button>
|
||||
<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 + days * 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 }}
|
||||
>
|
||||
{/* Room label header */}
|
||||
<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>
|
||||
{/* Date cells */}
|
||||
{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">
|
||||
{/* Grid cells */}
|
||||
<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))
|
||||
return (
|
||||
<div
|
||||
key={booking.id}
|
||||
className={cn(
|
||||
'booking-block border-l-4',
|
||||
BOOKING_STATUS_COLORS[booking.status],
|
||||
)}
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user