From 04642592737568ba50fe85a7e90ed03c85310604 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Fri, 20 Mar 2026 23:45:11 +0300 Subject: [PATCH] =?UTF-8?q?BookingModal:=20redesign=20=E2=80=94=20dates=20?= =?UTF-8?q?first,=20category=20cards,=20specific=20room=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/bookings/BookingModal.tsx | 532 ++++++++++++----------- 1 file changed, 287 insertions(+), 245 deletions(-) diff --git a/src/components/bookings/BookingModal.tsx b/src/components/bookings/BookingModal.tsx index 7f3e447..9fdd762 100644 --- a/src/components/bookings/BookingModal.tsx +++ b/src/components/bookings/BookingModal.tsx @@ -51,6 +51,17 @@ function availableRooms(rooms: Room[], bookings: Booking[], checkIn: string, che 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 }: BookingModalProps) { const { statuses } = useModules() const { user } = useAuth() @@ -80,8 +91,16 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav } 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: existing?.roomId ?? draft.roomId, + roomId: _initRoomId, lastName: _nameParts[0] ?? '', firstName: _nameParts[1] ?? '', middleName: _nameParts.slice(2).join(' '), @@ -115,6 +134,34 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav 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 @@ -214,263 +261,266 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
{/* ── LEFT COLUMN ── */} -
- {/* Room */} +
+ + {/* 1. Даты заезда / выезда */}
-
- - -
- - {/* Conflict warning */} - {!isHourly && form.roomId && form.checkIn && form.checkOut && form.checkIn < form.checkOut && - isConflict(bookings, form.roomId, form.checkIn, form.checkOut, existing?.id) && (() => { - const free = availableRooms(rooms, bookings, form.checkIn, form.checkOut, existing?.id) - return ( -
-
- - Номер занят на эти даты. -
- {free.length > 0 ? ( -
- Свободны: - {free.map(r => ( - - ))} -
- ) : ( -

Нет свободных номеров на эти даты.

- )} -
- ) - })() - } -
- - {/* ФИО гостя */} -
-
- - set('lastName', e.target.value)} - /> -
-
- - set('firstName', e.target.value)} - /> -
-
- - set('middleName', e.target.value)} - /> -
-
- {/* Телефон + Email */} -
-
- - set('phone', e.target.value)} - /> -
-
- - set('guestEmail', e.target.value)} - /> -
-
- - {/* Hourly / Daily toggle */} - {showHourlyTab && ( -
- - -
- )} - - {/* Dates */} - {isHourly && room?.allowHourly ? ( -
-
- + + {isHourly && room?.allowHourly ? ( +
setHourlyDate(e.target.value)} /> +
+
+ + +
+
+ + +
+
+ ) : (
- - + + set('checkIn', e.target.value)} + />
- - + + set('checkOut', e.target.value)} + />
-
- ) : ( -
-
- - set('checkIn', e.target.value)} - /> -
-
- - set('checkOut', e.target.value)} - /> -
-
- )} + )} +
- {/* Adults + Children + Status */} + {/* 2. Категория номера */} +
+
+ + +
+
+ {categories.map(cat => { + const isSelected = selectedCategory === cat.type + const hasAvailable = cat.free > 0 + const datesSet = !!(form.checkIn && form.checkOut && form.checkIn < form.checkOut) + return ( + + ) + })} +
+ + {/* Переключатель конкретного номера */} + {selectedCategory && ( +
+ +
+ )} + + {/* Выпадающий список конкретных номеров */} + {pickSpecificRoom && selectedCategory && ( +
+ {categoryAvailableRooms.length > 0 ? ( + + ) : ( +
+ + Нет свободных номеров в этой категории на выбранные даты +
+ )} +
+ )} + + {/* Почасово / посуточно — только если у выбранного номера есть почасовая ставка */} + {showHourlyTab && ( +
+ + +
+ )} +
+ + {/* 3. ФИО гостя */} +
+ +
+
+ + set('lastName', e.target.value)} + /> +
+
+ + set('firstName', e.target.value)} + /> +
+
+ + set('middleName', e.target.value)} + /> +
+
+
+
+ + set('phone', e.target.value)} + /> +
+
+ + set('guestEmail', e.target.value)} + /> +
+
+
+ + {/* 4. Взрослые / Дети / Статус */}
- + set('adults', parseInt(e.target.value) || 1)} />
- + set('children', parseInt(e.target.value) || 0)} />
- +