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:
2026-03-10 20:38:32 +03:00
commit 420d55d57e
45 changed files with 7274 additions and 0 deletions

View 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>
)
}