From 31858ca5cc9a3de62312f1b2b52d373bb04c2121 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Sat, 21 Mar 2026 22:07:40 +0300 Subject: [PATCH] feat: room/rental tabs in booking modal + smart time slot filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BookingModal: - Add 'Номер' / 'Аренда объекта' tabs when rental objects exist - Rental tab: object picker, date, full-day toggle, conflict-aware time dropdowns, guest/phone fields, total amount RentalBookingModal: - Filter start times: exclude hours inside booked slots + bufferMinutes gap - Filter end times: cap at next booking's start hour - Disable 'Весь день' if any timed bookings exist for the day - Show 'Все слоты заняты' when no start hours are available - Show buffer duration in time section label Co-Authored-By: Claude Sonnet 4.6 --- src/components/bookings/BookingModal.tsx | 299 ++++++++++++++++++- src/components/calendar/BookingCalendar.tsx | 4 + src/components/rental/RentalBookingModal.tsx | 138 ++++++--- 3 files changed, 388 insertions(+), 53 deletions(-) diff --git a/src/components/bookings/BookingModal.tsx b/src/components/bookings/BookingModal.tsx index e6b9fa8..753db0c 100644 --- a/src/components/bookings/BookingModal.tsx +++ b/src/components/bookings/BookingModal.tsx @@ -1,11 +1,12 @@ import { useState } from 'react' import { format, addDays } from 'date-fns' -import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, Tag, Minus, BedDouble, Tv2, Send, Loader2, CheckCircle2 } from 'lucide-react' +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' @@ -35,8 +36,19 @@ interface BookingModalProps { 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 rentalHourOptions(from: number, to: number) { + return Array.from({ length: Math.max(0, to - from + 1) }, (_, i) => from + i) +} +function fmtH(h: number) { return `${h}:00` } + function isConflict(bookings: Booking[], roomId: string, checkIn: string, checkOut: string, excludeId?: string) { if (!checkIn || !checkOut || checkIn >= checkOut) return false return bookings.some(b => @@ -62,11 +74,86 @@ function getRoomCategories(rooms: Room[], bookings: Booking[], checkIn: string, }) } -export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSave, existing }: BookingModalProps) { +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 [rentalStartH, setRentalStartH] = useState(0) + const [rentalEndH, setRentalEndH] = useState(2) + const [rentalGuest, setRentalGuest] = useState('') + const [rentalPhone, setRentalPhone] = useState('') + const [rentalNotes, setRentalNotes] = useState('') + const [rentalSaving, setRentalSaving] = useState(false) + + 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 rentalBreakH = Math.ceil((rentalObj?.bufferMinutes ?? 0) / 60) + const rentalAvailStarts = rentalObj + ? rentalHourOptions(rentalObj.openHour, rentalObj.closeHour - 1).filter(h => + !rentalDayBookings.some(b => !b.isFullDay && h >= b.startHour && h < b.endHour + rentalBreakH), + ) + : [] + const rentalGetMaxEnd = (start: number) => { + const next = rentalDayBookings.filter(b => !b.isFullDay && b.startHour >= start + 1) + .sort((a, b) => a.startHour - b.startHour)[0] + return next ? next.startHour : (rentalObj?.closeHour ?? 22) + } + const rentalEffStart = rentalAvailStarts.includes(rentalStartH) ? rentalStartH : (rentalAvailStarts[0] ?? rentalObj?.openHour ?? 8) + const rentalAvailEnds = rentalHourOptions(rentalEffStart + 1, rentalGetMaxEnd(rentalEffStart)) + 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, rentalEndH - rentalEffStart) + 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 : rentalEffStart, + endHour: rentalIsFullDay ? rentalObj.closeHour : rentalEndH, + 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) @@ -250,15 +337,208 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav title={existing ? 'Редактировать бронирование' : 'Новое бронирование'} size="2xl" footer={ - <> - - - + bookingType === 'rental' ? ( + <> + + + + ) : ( + <> + + + + ) } >
+ + {/* 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 */} +
+
+ + setRentalGuest(e.target.value)} /> +
+
+ + setRentalPhone(e.target.value)} /> +
+
+ +
+ +