import { useState, useLayoutEffect, useRef, useEffect } from 'react' import { format, addDays } from 'date-fns' import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, Tag, Minus, BedDouble, Tv2, Send, Loader2, CheckCircle2, Clock, CalendarDays, User, Phone } from 'lucide-react' import { MOCK_DISCOUNTS } from '../../pages/DiscountsPage' import type { Discount } from '../../pages/DiscountsPage' import { Modal } from '../ui/Modal' import { cn, BOOKING_STATUS_LABELS } from '../../lib/utils' import type { Booking, DraftBooking, Room, BookingStatus } from '../../types' import type { RentalObject, RentalBooking } from '../../data/rentalData' import { FloorMapModal } from '../floormap/FloorMapModal' import { useModules } from '../../contexts/ModulesContext' import { useAuth } from '../../contexts/AuthContext' import { api } from '../../lib/api' const GUEST_TAGS = ['VIP', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания'] const ADDITIONAL_SERVICES = [ { id: 'breakfast', label: 'Завтрак', price: 800 }, { id: 'transfer', label: 'Трансфер', price: 2500 }, { id: 'parking', label: 'Парковка', price: 500 }, { id: 'laundry', label: 'Стирка', price: 600 }, { id: 'minibar', label: 'Мини-бар', price: 1500 }, { id: 'excursion', label: 'Экскурсия', price: 3000 }, ] interface AddedService { id: string; label: string; price: number; qty: number } const HOURS = Array.from({ length: 24 }, (_, i) => i) interface BookingModalProps { open: boolean draft: DraftBooking rooms: Room[] bookings?: Booking[] onClose: () => void onSave: (data: Partial) => void existing?: Booking // Rental tab (only shown when these are provided AND not editing existing booking) rentalObjects?: RentalObject[] rentalBookings?: RentalBooking[] onRentalSave?: (b: RentalBooking) => void slug?: string } // ── Rental time helpers ──────────────────────────────────────────────────────── function rentalTimeOptions(openHour: number, closeHour: number): number[] { const result: number[] = [] for (let m = openHour * 60; m < closeHour * 60; m += 15) result.push(m) return result } function rentalEndOptions(fromMin: number, toMin: number): number[] { const result: number[] = [] for (let m = fromMin + 15; m <= toMin; m += 15) result.push(m) return result } function fmtTime(min: number) { const h = Math.floor(min / 60) const m = min % 60 return m === 0 ? `${h}:00` : `${h}:${String(m).padStart(2, '0')}` } function isConflict(bookings: Booking[], roomId: string, checkIn: string, checkOut: string, excludeId?: string) { if (!checkIn || !checkOut || checkIn >= checkOut) return false return bookings.some(b => b.roomId === roomId && b.id !== excludeId && b.status !== 'cancelled' && b.status !== 'no_show' && b.status !== 'checked_out' && b.checkIn < checkOut && b.checkOut > checkIn, ) } function availableRooms(rooms: Room[], bookings: Booking[], checkIn: string, checkOut: string, excludeId?: string) { return rooms.filter(r => !isConflict(bookings, r.id, checkIn, checkOut, excludeId)) } // Возвращает уникальные категории (типы) номеров с количеством свободных function getRoomCategories(rooms: Room[], bookings: Booking[], checkIn: string, checkOut: string, excludeId?: string) { const types = Array.from(new Set(rooms.map(r => r.type))) return types.map(type => { const roomsOfType = rooms.filter(r => r.type === type) const freeRooms = roomsOfType.filter(r => !isConflict(bookings, r.id, checkIn, checkOut, excludeId)) const minRate = Math.min(...roomsOfType.map(r => r.baseRate)) return { type, total: roomsOfType.length, free: freeRooms.length, minRate, rooms: freeRooms, allRooms: roomsOfType } }) } export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSave, existing, rentalObjects, rentalBookings = [], onRentalSave, slug }: BookingModalProps) { const { statuses } = useModules() const { user } = useAuth() const tvEnabled = statuses['tv-welcome'] === 'active' const showRentalTab = !existing && !!rentalObjects && rentalObjects.length > 0 // ── Rental tab state ──────────────────────────────────────────────────────── const [bookingType, setBookingType] = useState<'room' | 'rental'>('room') const [rentalObjId, setRentalObjId] = useState(rentalObjects?.[0]?.id ?? '') const [rentalDate, setRentalDate] = useState(draft.checkIn) const [rentalIsFullDay, setRentalIsFullDay] = useState(false) const [rentalStartM, setRentalStartM] = useState(0) // minutes since midnight const [rentalEndM, setRentalEndM] = useState(60) const [rentalGuest, setRentalGuest] = useState('') const [rentalPhone, setRentalPhone] = useState('') const [rentalNotes, setRentalNotes] = useState('') const [rentalSaving, setRentalSaving] = useState(false) // Rental guest autocomplete const [rentalShowSugg, setRentalShowSugg] = useState(false) const [rentalDbResults, setRentalDbResults] = useState<{ id: string; firstName: string; lastName: string; phone: string | null }[]>([]) const rentalSearchTimer = useRef | null>(null) const rentalNameRef = useRef(null) const rentalObj = rentalObjects?.find(o => o.id === rentalObjId) ?? rentalObjects?.[0] const rentalDayBookings = rentalBookings.filter(b => b.objectId === rentalObjId && (b.date as string).slice(0, 10) === rentalDate && b.status !== 'cancelled', ) const rentalBufMin = rentalObj?.bufferMinutes ?? 0 const rentalAvailStarts = rentalObj ? rentalTimeOptions(rentalObj.openHour, rentalObj.closeHour).filter(m => !rentalDayBookings.some(b => !b.isFullDay && m >= b.startHour && m < b.endHour + rentalBufMin), ) : [] const rentalGetMaxEnd = (start: number) => { const next = rentalDayBookings.filter(b => !b.isFullDay && b.startHour >= start + 15) .sort((a, b) => a.startHour - b.startHour)[0] return next ? next.startHour - rentalBufMin : (rentalObj?.closeHour ?? 22) * 60 } const rentalEffStart = rentalAvailStarts.includes(rentalStartM) ? rentalStartM : (rentalAvailStarts[0] ?? (rentalObj?.openHour ?? 8) * 60) const rentalAvailEnds = rentalEndOptions(rentalEffStart, rentalGetMaxEnd(rentalEffStart)) // Sync rentalEndM when rentalEffStart shifts past it useLayoutEffect(() => { if (!rentalIsFullDay && rentalEndM <= rentalEffStart) { setRentalEndM(rentalEffStart + 60) } }, [rentalEffStart]) // Close rental guest suggestions on outside click useEffect(() => { const handler = (e: MouseEvent) => { if (rentalNameRef.current && !rentalNameRef.current.contains(e.target as Node)) { setRentalShowSugg(false) } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, []) // Rental guest suggestions from current bookings const rentalGuestSugg = [ ...(bookings ?? []).filter(b => b.status === 'checked_in'), ...(bookings ?? []).filter(b => b.status === 'confirmed'), ] const rentalFiltered = rentalGuest.trim().length > 0 ? rentalGuestSugg.filter(b => b.guestName.toLowerCase().includes(rentalGuest.toLowerCase())) : rentalGuestSugg const rentalHasFullDay = rentalDayBookings.some(b => b.isFullDay) const rentalHasTimed = rentalDayBookings.some(b => !b.isFullDay) const rentalNoSlots = !rentalHasFullDay && rentalAvailStarts.length === 0 const rentalHours = rentalIsFullDay ? (rentalObj ? rentalObj.closeHour - rentalObj.openHour : 0) : Math.max(0, (rentalEndM - rentalEffStart) / 60) const rentalTotal = rentalObj ? (rentalIsFullDay ? rentalObj.pricePerDay : rentalHours * rentalObj.pricePerHour) : 0 const handleRentalSave = async () => { if (!rentalObj || !rentalGuest.trim() || rentalSaving) return setRentalSaving(true) try { if (slug) { const parts = rentalGuest.trim().split(' ') await api.guests.create(slug, { last_name: parts[0] ?? '', first_name: parts[1] ?? '', phone: rentalPhone.trim() || undefined, }).catch(() => { /* ignore duplicate */ }) } onRentalSave?.({ id: `rb-${Date.now()}`, objectId: rentalObj.id, date: rentalDate, isFullDay: rentalIsFullDay, startHour: rentalIsFullDay ? rentalObj.openHour * 60 : rentalEffStart, endHour: rentalIsFullDay ? rentalObj.closeHour * 60 : rentalEndM, guestName: rentalGuest.trim(), guestPhone: rentalPhone.trim(), notes: rentalNotes.trim() || undefined, totalAmount: rentalTotal, paidAmount: 0, status: 'confirmed', }) onClose() } finally { setRentalSaving(false) } } // TV message state const [tvMsg, setTvMsg] = useState('') const [tvSending, setTvSending] = useState(false) const [tvSent, setTvSent] = useState(false) const [tvError, setTvError] = useState('') const handleSendTv = async () => { if (!tvMsg.trim() || !existing?.roomId || !user?.hotelSlug) return setTvSending(true) setTvError('') setTvSent(false) try { await api.netup.sendMessage(user.hotelSlug, existing.roomId, tvMsg, existing.guestName) setTvSent(true) setTvMsg('') setTimeout(() => setTvSent(false), 3000) } catch (err) { setTvError(err instanceof Error ? err.message : 'Ошибка отправки') } finally { setTvSending(false) } } const _nameParts = (existing?.guestName ?? '').split(' ') // Определяем начальную категорию из существующего бронирования или первого доступного номера const _initRoomId = existing?.roomId ?? draft.roomId const _initCategory = rooms.find(r => r.id === _initRoomId)?.type ?? rooms[0]?.type ?? '' const [selectedCategory, setSelectedCategory] = useState(_initCategory) const [pickSpecificRoom, setPickSpecificRoom] = useState(!!existing?.roomId) const [form, setForm] = useState({ roomId: _initRoomId, lastName: _nameParts[0] ?? '', firstName: _nameParts[1] ?? '', middleName: _nameParts.slice(2).join(' '), phone: existing?.guestPhone ?? '', guestEmail: existing?.guestEmail ?? '', checkIn: existing?.checkIn ?? draft.checkIn, checkOut: existing?.checkOut ?? draft.checkOut, adults: existing?.adults ?? 2, children: existing?.children ?? 0, status: (existing?.status ?? 'confirmed') as BookingStatus, notes: existing?.notes ?? '', }) const [guestTags, setGuestTags] = useState([]) const [paymentMethod, setPaymentMethod] = useState<'cash' | 'terminal' | null>(null) const [paidAmount, setPaidAmount] = useState(existing?.paidAmount ?? 0) const [showFloorMap, setShowFloorMap] = useState(false) const [addedServices, setAddedServices] = useState([]) const [selectedServiceId, setSelectedServiceId] = useState(ADDITIONAL_SERVICES[0].id) const [selectedDiscountId, setSelectedDiscountId] = useState('') const activeDiscounts = MOCK_DISCOUNTS.filter(d => d.isActive) const selectedDiscount: Discount | undefined = activeDiscounts.find(d => d.id === selectedDiscountId) // Hourly booking state const [isHourly, setIsHourly] = useState(false) const [hourlyDate, setHourlyDate] = useState(draft.checkIn) const [startHour, setStartHour] = useState(10) const [endHour, setEndHour] = useState(12) // Extra beds const [extraBeds, setExtraBeds] = useState(0) const EXTRA_BED_PRICE = 1500 const room = rooms.find(r => r.id === form.roomId) // Категории и доступность const categories = getRoomCategories(rooms, bookings, form.checkIn, form.checkOut, existing?.id) const currentCategory = categories.find(c => c.type === selectedCategory) const categoryAvailableRooms = currentCategory ? (pickSpecificRoom ? currentCategory.rooms : currentCategory.allRooms) : [] // При смене категории или дат (если не выбран конкретный номер) — авто-выбираем первый свободный const handleCategorySelect = (type: string) => { setSelectedCategory(type) if (!pickSpecificRoom) { const cat = getRoomCategories(rooms, bookings, form.checkIn, form.checkOut, existing?.id).find(c => c.type === type) const first = cat?.rooms[0] ?? cat?.allRooms[0] if (first) set('roomId', first.id) } } const handlePickSpecificToggle = (on: boolean) => { setPickSpecificRoom(on) if (!on) { // Возвращаемся к первому свободному в категории const cat = getRoomCategories(rooms, bookings, form.checkIn, form.checkOut, existing?.id).find(c => c.type === selectedCategory) const first = cat?.rooms[0] ?? cat?.allRooms[0] if (first) set('roomId', first.id) } } const nights = form.checkIn && form.checkOut ? Math.max(0, (new Date(form.checkOut).getTime() - new Date(form.checkIn).getTime()) / 86400000) : 0 const hourlyHours = Math.max(0, endHour - startHour) const roomBaseTotal = isHourly && room?.allowHourly ? (room.hourlyRate ?? 0) * hourlyHours : (room?.baseRate ?? 0) * nights const discountAmount = selectedDiscount ? selectedDiscount.valueType === 'percent' ? Math.round(roomBaseTotal * Math.min(100, selectedDiscount.value) / 100) : Math.min(roomBaseTotal, selectedDiscount.value) : 0 const roomTotal = Math.max(0, roomBaseTotal - discountAmount) const servicesTotal = addedServices.reduce((s, sv) => s + sv.price * sv.qty, 0) const extraBedsTotal = extraBeds * EXTRA_BED_PRICE * (isHourly ? 1 : Math.max(1, nights)) const total = roomTotal + servicesTotal + extraBedsTotal const debt = Math.max(0, total - paidAmount) const addService = () => { const svc = ADDITIONAL_SERVICES.find(s => s.id === selectedServiceId) if (!svc) return setAddedServices(prev => { const ex = prev.find(s => s.id === svc.id) if (ex) return prev.map(s => s.id === svc.id ? { ...s, qty: s.qty + 1 } : s) return [...prev, { ...svc, qty: 1 }] }) } const removeService = (id: string) => setAddedServices(prev => prev.filter(s => s.id !== id)) const isFutureBooking = form.checkIn > format(new Date(), 'yyyy-MM-dd') const set = (k: K, v: typeof form[K]) => setForm(prev => ({ ...prev, [k]: v })) const toggleTag = (tag: string) => setGuestTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]) const handleSave = () => { if (!form.lastName || !form.firstName) return if (isHourly && room?.allowHourly) { if (!hourlyDate) return } else { if (!form.checkIn || !form.checkOut) return } const guestName = [form.lastName, form.firstName, form.middleName].filter(Boolean).join(' ') const hourlyPrefix = isHourly && room?.allowHourly ? `[Почасово: ${String(startHour).padStart(2, '0')}:00–${String(endHour).padStart(2, '0')}:00] ` : '' const extraBedsPrefix = extraBeds > 0 ? `[Доп.места: ${extraBeds}] ` : '' const checkIn = isHourly && room?.allowHourly ? hourlyDate : form.checkIn const checkOut = isHourly && room?.allowHourly ? hourlyDate : form.checkOut onSave({ roomId: form.roomId, guestName, guestEmail: form.guestEmail, guestPhone: form.phone, checkIn, checkOut, adults: form.adults, children: form.children, status: form.status, notes: hourlyPrefix + extraBedsPrefix + form.notes, totalAmount: total, paidAmount, id: existing?.id ?? `b-${Date.now()}`, hotelId: 'hotel-1', guestId: existing?.guestId ?? `g-${Date.now()}`, createdAt: existing?.createdAt ?? format(new Date(), 'yyyy-MM-dd'), source: 'direct', }) } const showHourlyTab = room?.allowHourly === true return ( <> ) : ( <> ) } >
{/* Rental / Room tabs */} {showRentalTab && (
)} {/* ── RENTAL FORM ── */} {bookingType === 'rental' && rentalObj && (
{/* Object picker */} {rentalObjects && rentalObjects.length > 1 && (
{rentalObjects.map(o => ( ))}
)} {rentalObjects?.length === 1 && (
{rentalObj.icon}

{rentalObj.name}

{rentalObj.pricePerHour} ₽/ч · {rentalObj.pricePerDay} ₽/день

)} {/* Date */}
setRentalDate(e.target.value)} />
{/* Full day / time */} {rentalHasFullDay ? (
Объект уже забронирован на весь день
) : ( <>
Весь день {rentalHasTimed && Есть частичные брони}
{!rentalIsFullDay && ( rentalNoSlots ? (
Все слоты на этот день заняты
) : (
) )} )} {/* Guest */}
setRentalShowSugg(true)} onChange={e => { const val = e.target.value setRentalGuest(val) setRentalShowSugg(true) if (rentalSearchTimer.current) clearTimeout(rentalSearchTimer.current) if (val.trim().length >= 2 && slug) { rentalSearchTimer.current = setTimeout(async () => { try { const results = await api.guests.list(slug, val.trim()) setRentalDbResults(results.slice(0, 8)) } catch { setRentalDbResults([]) } }, 300) } else { setRentalDbResults([]) } }} /> {rentalShowSugg && (rentalFiltered.length > 0 || rentalDbResults.length > 0) && (
{rentalFiltered.slice(0, 5).map(b => ( ))} {rentalDbResults .filter(g => !rentalFiltered.some(b => b.guestName === `${g.lastName} ${g.firstName}`)) .map(g => ( ))}
)}
setRentalPhone(e.target.value)} />