import { useState, useMemo, useEffect } from 'react' import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown, Clock, Pencil, Loader2 } from 'lucide-react' import { useModules } from '../contexts/ModulesContext' import { useAuth } from '../contexts/AuthContext' import { api } from '../lib/api' import type { RentalObjectApi, RentalBookingApi } from '../lib/api' import type { Booking, BookingStatus, Room } from '../types' import type { RentalObject, RentalBooking } from '../data/rentalData' import { RentalBookingModal } from '../components/rental/RentalBookingModal' import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../lib/utils' import { Badge } from '../components/ui/Badge' import { BookingModal } from '../components/bookings/BookingModal' import { BookingDetailPanel } from '../components/bookings/BookingDetailPanel' import { format, addDays } from 'date-fns' const STATUS_FILTERS: { label: string; value: BookingStatus | 'all' }[] = [ { label: 'Все', value: 'all' }, { label: 'Подтверждённые', value: 'confirmed' }, { label: 'Заселены', value: 'checked_in' }, { label: 'Запросы', value: 'inquiry' }, { label: 'Выехали', value: 'checked_out' }, { label: 'Отменены', value: 'cancelled' }, ] type SortKey = 'guestName' | 'checkIn' | 'checkOut' | 'status' | 'source' | 'totalAmount' const COLUMNS: { key: SortKey | null; label: string }[] = [ { key: 'guestName', label: 'Гость' }, { key: null, label: 'Номер' }, { key: 'checkIn', label: 'Заезд' }, { key: 'checkOut', label: 'Выезд' }, { key: 'status', label: 'Статус' }, { key: 'source', label: 'Источник' }, { key: 'totalAmount', label: 'Сумма' }, { key: null, label: '' }, ] type Tab = 'rooms' | 'rental' function fromObjApi(o: RentalObjectApi): RentalObject { return { id: o.id, name: o.name, icon: o.icon, color: o.color, textColor: o.textColor, pricePerHour: o.pricePerHour, pricePerDay: o.pricePerDay, openHour: o.openHour, closeHour: o.closeHour, maxHoursPerSlot: o.maxHoursPerSlot ?? undefined, bufferMinutes: o.bufferMinutes ?? undefined, } } function fromBookingApi(b: RentalBookingApi): RentalBooking { return { id: b.id, objectId: b.objectId, date: (b.date as string).slice(0, 10), isFullDay: b.isFullDay, startHour: b.startHour, endHour: b.endHour, guestName: b.guestName, guestPhone: b.guestPhone ?? '', linkedRoomId: b.linkedRoomId ?? undefined, totalAmount: b.totalAmount, paidAmount: b.paidAmount, status: b.status, notes: b.notes ?? undefined, } } function fmtMin(m: number) { return `${Math.floor(m / 60).toString().padStart(2, '0')}:${(m % 60).toString().padStart(2, '0')}` } export function BookingsPage() { const { user } = useAuth() const slug = user?.hotelSlug ?? '' const { statuses } = useModules() const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial' const [tab, setTab] = useState('rooms') const [rooms, setRooms] = useState([]) const [bookings, setBookings] = useState([]) const [loading, setLoading] = useState(true) const [rentalObjects, setRentalObjects] = useState([]) const [rentalBookings, setRentalBookings] = useState([]) const [newRentalStep, setNewRentalStep] = useState<'idle' | 'pick'>('idle') const [rentalPickObj, setRentalPickObj] = useState('') const [rentalPickDate, setRentalPickDate] = useState(format(new Date(), 'yyyy-MM-dd')) const [showRentalModal, setShowRentalModal] = useState<{ obj: RentalObject; date: string; editBooking?: RentalBooking } | null>(null) const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState('all') const [selected, setSelected] = useState(null) const [showCreateModal, setShowCreateModal] = useState(false) const [sortKey, setSortKey] = useState(null) const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc') useEffect(() => { if (!slug) return Promise.all([ api.rooms.list(slug), api.bookings.list(slug), api.rental.listObjects(slug), api.rental.listBookings(slug), ]) .then(([r, b, objs, rbks]) => { setRooms(r) setBookings(b) const convertedObjs = objs.map(fromObjApi) setRentalObjects(convertedObjs) setRentalBookings(rbks.map(fromBookingApi)) setRentalPickObj(convertedObjs[0]?.id ?? '') }) .catch(console.error) .finally(() => setLoading(false)) }, [slug]) const handleSort = (key: SortKey) => { if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc') else { setSortKey(key); setSortDir('asc') } } const filtered = bookings.filter(b => { const matchSearch = search === '' || b.guestName.toLowerCase().includes(search.toLowerCase()) || b.guestEmail.toLowerCase().includes(search.toLowerCase()) || b.id.includes(search) const matchStatus = statusFilter === 'all' || b.status === statusFilter return matchSearch && matchStatus }) const sorted = useMemo(() => { if (!sortKey) return filtered return [...filtered].sort((a, b) => { const av = a[sortKey] const bv = b[sortKey] let cmp = 0 if (typeof av === 'string' && typeof bv === 'string') { cmp = av.localeCompare(bv, 'ru') } else { cmp = (av as number) - (bv as number) } return sortDir === 'asc' ? cmp : -cmp }) }, [filtered, sortKey, sortDir]) const filteredRental = rentalBookings.filter(b => { if (search === '') return true return b.guestName.toLowerCase().includes(search.toLowerCase()) || b.guestPhone.includes(search) }) const room = (id: string) => rooms.find(r => r.id === id) const rentalObj = (id: string) => rentalObjects.find(o => o.id === id) const handleCreateBooking = async (data: Partial) => { try { const created = await api.bookings.create(slug, { roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail, guestPhone: data.guestPhone, checkIn: data.checkIn, checkOut: data.checkOut, adults: data.adults, children: data.children, status: data.status, source: data.source, totalAmount: data.totalAmount, notes: data.notes, }) setBookings(prev => [...prev, created]) setShowCreateModal(false) } catch (err) { console.error('Failed to create booking:', err) } } const handleUpdateBooking = async (id: string, data: Partial) => { try { const updated = await api.bookings.update(slug, id, { guestName: data.guestName, guestEmail: data.guestEmail, guestPhone: data.guestPhone, checkIn: data.checkIn, checkOut: data.checkOut, adults: data.adults, children: data.children, status: data.status, source: data.source, totalAmount: data.totalAmount, paidAmount: data.paidAmount, notes: data.notes, }) setBookings(prev => prev.map(b => b.id === id ? updated : b)) setSelected(null) } catch (err) { console.error('Failed to update booking:', err) } } const handleBulkUpdate = async (updates: Array<{ id: string; data: Partial }>) => { try { const results = await Promise.all( updates.map(u => api.bookings.update(slug, u.id, { status: u.data.status })), ) setBookings(prev => { let next = [...prev] results.forEach(r => { next = next.map(b => b.id === r.id ? r : b) }) return next }) setSelected(null) } catch (err) { console.error('Failed to bulk update bookings:', err) } } if (loading) { return (
) } // Step-1 modal: occupied slots for selected object + date const pickObj = rentalObjects.find(o => o.id === rentalPickObj) const pickBookings = rentalBookings.filter( b => b.objectId === rentalPickObj && b.date === rentalPickDate && b.status === 'confirmed', ) const pickOpenMin = (pickObj?.openHour ?? 8) * 60 const pickCloseMin = (pickObj?.closeHour ?? 22) * 60 const pickTotalMin = pickCloseMin - pickOpenMin const pickHasFullDay = pickBookings.some(b => b.isFullDay) const fmtPickMin = (m: number) => `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}` return (
{/* Header */}

Бронирования

{tab === 'rooms' ? `${sorted.length} из ${bookings.length}` : `${filteredRental.length} аренд`}

{tab === 'rooms' && ( )} {tab === 'rental' && ( )}
{/* Tabs */} {isRentalActive && (
)} {/* Search */}
setSearch(e.target.value)} />
{tab === 'rooms' && (
{STATUS_FILTERS.map(f => ( ))}
)}
{/* Room bookings table */} {tab === 'rooms' && (
{COLUMNS.map(col => ( ))} {sorted.map(b => { const r = room(b.roomId) const nights = nightsCount(b.checkIn, b.checkOut) return ( setSelected(b)} > ) })}
{col.key ? ( ) : col.label}

{b.guestName}

{b.guestEmail}

{r ? `№${r.number}` : '—'}

{r?.type}

{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkIn))} · {nights}н {new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkOut))} {nights}н {BOOKING_STATUS_LABELS[b.status]} {SOURCE_LABELS[b.source]}

{formatCurrency(b.totalAmount)}

{b.paidAmount < b.totalAmount && (

-{formatCurrency(b.totalAmount - b.paidAmount)}

)}
Открыть →
{sorted.length === 0 && (
Бронирования не найдены
)}
)} {/* Rental bookings table */} {tab === 'rental' && (
{['Гость', 'Объект', 'Дата', 'Время', 'Сумма', 'Статус', ''].map((h, i) => ( ))} {filteredRental.map(b => { const obj = rentalObj(b.objectId) return ( obj && setShowRentalModal({ obj, date: b.date, editBooking: b })} className="border-b border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors cursor-pointer"> ) })}
{h}

{b.guestName}

{b.guestPhone}

{obj && (
{obj.icon} {obj.name}
)}
{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short', weekday: 'short' }).format(new Date(b.date))} {b.isFullDay ? Весь день : `${fmtMin(b.startHour)} – ${fmtMin(b.endHour)}` } {formatCurrency(b.totalAmount)} {b.status === 'confirmed' ? 'Подтверждено' : 'Отменено'} {obj && ( )}
{filteredRental.length === 0 && (
Записи об аренде не найдены
)}
)} {/* Create booking modal */} {showCreateModal && rooms.length > 0 && ( setShowCreateModal(false)} onSave={handleCreateBooking} /> )} {/* Detail panel */} {selected && ( setSelected(null)} onUpdate={handleUpdateBooking} onBulkUpdate={handleBulkUpdate} /> )} {/* New rental — step 1 */} {newRentalStep === 'pick' && (

Новая аренда

setRentalPickDate(e.target.value)} />
{/* Occupied slots preview */} {pickObj && (

Занятость · {new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(rentalPickDate))}

{pickHasFullDay ? (
Весь день занят
) : pickBookings.length === 0 ? (
Свободно весь день
) : (
{/* Timeline bar */}
{pickBookings.filter(b => !b.isFullDay).map(b => { const left = ((b.startHour - pickOpenMin) / pickTotalMin) * 100 const width = ((b.endHour - b.startHour) / pickTotalMin) * 100 return (
) })}
{/* Hour labels */}
{fmtPickMin(pickOpenMin)} {fmtPickMin(Math.round((pickOpenMin + pickCloseMin) / 2))} {fmtPickMin(pickCloseMin)}
{/* Booking list */}
{pickBookings.filter(b => !b.isFullDay).map(b => (
{fmtPickMin(b.startHour)}–{fmtPickMin(b.endHour)} {b.guestName}
))}
)}
)}
)} {/* New rental — step 2 */} {showRentalModal && ( b.objectId === showRentalModal.obj.id && b.date === showRentalModal.date)} editBooking={showRentalModal.editBooking} onClose={() => setShowRentalModal(null)} onSave={async (b) => { if (!slug) return const payload = { object_id: b.objectId, date: b.date, is_full_day: b.isFullDay, start_hour: b.startHour, end_hour: b.endHour, guest_name: b.guestName, guest_phone: b.guestPhone || undefined, linked_room_id: b.linkedRoomId || undefined, total_amount: b.totalAmount, paid_amount: b.paidAmount ?? 0, status: b.status, notes: b.notes || undefined, } const isNew = !rentalBookings.find(x => x.id === b.id) const saved = isNew ? await api.rental.createBooking(slug, payload) : await api.rental.updateBooking(slug, b.id, payload) const converted = fromBookingApi(saved) setRentalBookings(prev => isNew ? [...prev, converted] : prev.map(x => x.id === b.id ? converted : x)) setShowRentalModal(null) }} /> )}
) }