feat: room/rental tabs in booking modal + smart time slot filtering

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 22:07:40 +03:00
parent 5cab0ce8da
commit 31858ca5cc
3 changed files with 388 additions and 53 deletions

View File

@@ -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<Booking>) => 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={
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button onClick={handleSave} className="btn-primary" disabled={!form.lastName || !form.firstName}>
{existing ? 'Сохранить' : 'Создать бронирование'}
</button>
</>
bookingType === 'rental' ? (
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={handleRentalSave}
className="btn-primary"
disabled={!rentalGuest.trim() || rentalSaving || rentalHasFullDay || rentalNoSlots || (!rentalIsFullDay && rentalHours <= 0)}
>
{rentalSaving ? <Loader2 size={15} className="animate-spin" /> : null}
Забронировать аренду
</button>
</>
) : (
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button onClick={handleSave} className="btn-primary" disabled={!form.lastName || !form.firstName}>
{existing ? 'Сохранить' : 'Создать бронирование'}
</button>
</>
)
}
>
<div className="space-y-4">
{/* Rental / Room tabs */}
{showRentalTab && (
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-700/50 rounded-xl">
<button
onClick={() => setBookingType('room')}
className={cn(
'flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors',
bookingType === 'room'
? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
Номер
</button>
<button
onClick={() => setBookingType('rental')}
className={cn(
'flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors',
bookingType === 'rental'
? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
Аренда объекта
</button>
</div>
)}
{/* ── RENTAL FORM ── */}
{bookingType === 'rental' && rentalObj && (
<div className="space-y-4">
{/* Object picker */}
{rentalObjects && rentalObjects.length > 1 && (
<div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Объект аренды</label>
<div className="grid grid-cols-2 gap-2">
{rentalObjects.map(o => (
<button
key={o.id}
onClick={() => { setRentalObjId(o.id); setRentalStartH(o.openHour); setRentalEndH(Math.min(o.openHour + 2, o.closeHour)) }}
className={cn(
'flex items-center gap-2 p-2.5 rounded-xl border text-sm font-medium transition-colors text-left',
rentalObjId === o.id
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-brand-300 dark:hover:border-brand-600',
)}
>
<span className="text-lg">{o.icon}</span>
<div className="min-w-0">
<p className="truncate">{o.name}</p>
<p className="text-xs text-slate-400">{o.pricePerHour} /ч</p>
</div>
</button>
))}
</div>
</div>
)}
{rentalObjects?.length === 1 && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40">
<span className="text-2xl">{rentalObj.icon}</span>
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">{rentalObj.name}</p>
<p className="text-xs text-slate-500">{rentalObj.pricePerHour} /ч · {rentalObj.pricePerDay} /день</p>
</div>
</div>
)}
{/* Date */}
<div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">Дата</label>
<input
type="date"
className="input"
value={rentalDate}
onChange={e => setRentalDate(e.target.value)}
/>
</div>
{/* Full day / time */}
{rentalHasFullDay ? (
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
<AlertCircle size={15} className="shrink-0" />
<span>Объект уже забронирован на весь день</span>
</div>
) : (
<>
<div className={cn('flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-600', rentalHasTimed && 'opacity-50 pointer-events-none')}>
<CalendarDays size={16} className="text-slate-400 shrink-0" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 flex-1">Весь день</span>
{rentalHasTimed && <span className="text-xs text-slate-400">Есть частичные брони</span>}
<button
onClick={() => !rentalHasTimed && setRentalIsFullDay(v => !v)}
className={cn('relative rounded-full transition-colors shrink-0', rentalIsFullDay ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
style={{ height: 22, width: 40 }}
>
<span className={cn('absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform', rentalIsFullDay ? 'translate-x-5' : 'translate-x-0.5')} />
</button>
</div>
{!rentalIsFullDay && (
rentalNoSlots ? (
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
<AlertCircle size={15} className="shrink-0" />
<span>Все слоты на этот день заняты</span>
</div>
) : (
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Время ({rentalObj.openHour}:00 {rentalObj.closeHour}:00)
{rentalBreakH > 0 && <span className="ml-1 font-normal normal-case">(перерыв {rentalObj.bufferMinutes} мин)</span>}
</label>
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="block text-xs text-slate-500 mb-1">Начало</label>
<select className="input text-sm" value={rentalEffStart}
onChange={e => {
const v = parseInt(e.target.value)
setRentalStartH(v)
const maxE = rentalGetMaxEnd(v)
if (rentalEndH <= v || rentalEndH > maxE) setRentalEndH(Math.min(v + 1, maxE))
}}>
{rentalAvailStarts.map(h => <option key={h} value={h}>{fmtH(h)}</option>)}
</select>
</div>
<Clock size={14} className="text-slate-400 mt-4 shrink-0" />
<div className="flex-1">
<label className="block text-xs text-slate-500 mb-1">Конец</label>
<select className="input text-sm"
value={rentalAvailEnds.includes(rentalEndH) ? rentalEndH : (rentalAvailEnds[0] ?? rentalEndH)}
onChange={e => setRentalEndH(parseInt(e.target.value))}>
{rentalAvailEnds.map(h => <option key={h} value={h}>{fmtH(h)}</option>)}
</select>
</div>
</div>
</div>
)
)}
</>
)}
{/* Guest */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
<User size={13} className="inline mr-1" />Гость *
</label>
<input type="text" className="input" placeholder="Имя Фамилия"
value={rentalGuest} onChange={e => setRentalGuest(e.target.value)} />
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
<Phone size={13} className="inline mr-1" />Телефон
</label>
<input type="tel" className="input" placeholder="+7 (999) 000-00-00"
value={rentalPhone} onChange={e => setRentalPhone(e.target.value)} />
</div>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1.5">Примечания</label>
<textarea className="input resize-none h-16 text-sm" placeholder="Доп. пожелания..."
value={rentalNotes} onChange={e => setRentalNotes(e.target.value)} />
</div>
{rentalHours > 0 && (
<div className="rounded-xl bg-brand-50 dark:bg-brand-900/20 border border-brand-200 dark:border-brand-700 p-3">
<p className="text-sm font-semibold text-brand-700 dark:text-brand-300">
Итого: {rentalTotal.toLocaleString('ru-RU')}
<span className="ml-2 text-xs font-normal text-brand-500">
({rentalIsFullDay ? 'весь день' : `${rentalHours} ч × ${rentalObj.pricePerHour}`})
</span>
</p>
</div>
)}
</div>
)}
{/* ── ROOM FORM ── */}
{bookingType === 'room' && (<>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
{/* ── LEFT COLUMN ── */}
<div className="flex-1 space-y-4 min-w-0">
@@ -777,6 +1057,9 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
</div>
</div>
</div>
{/* end room form */}
</>)}
</div>
</Modal>

View File

@@ -741,6 +741,10 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
draft={bookingModalDraft}
rooms={rooms}
bookings={bookings}
slug={slug}
rentalObjects={rentalObjects}
rentalBookings={rentalBookings as unknown as import('../../data/rentalData').RentalBooking[]}
onRentalSave={rb => { onRentalBookingCreate?.(rb); setBookingModalDraft(null) }}
onClose={() => {
if (bookingModalDraft) onDraftCancel?.(bookingModalDraft.roomId)
setBookingModalDraft(null)

View File

@@ -86,25 +86,54 @@ export function RentalBookingModal({
setGuestIsKnown(true)
}
const otherBookings = existingBookings.filter(b => b.id !== editBooking?.id && b.status !== 'cancelled')
const timedBookings = otherBookings.filter(b => !b.isFullDay)
const hasFullDayConflict = otherBookings.some(b => b.isFullDay)
// If any timed slot is booked, "весь день" is unavailable
const hasTimedConflict = timedBookings.length > 0
// Break between sessions (ceil to whole hours)
const breakHours = Math.ceil((obj.bufferMinutes ?? 0) / 60)
// Available start hours: exclude hours inside any booked slot + its buffer
const availableStartHours = hourOptions(obj.openHour, obj.closeHour - 1).filter(h =>
!timedBookings.some(b => h >= b.startHour && h < b.endHour + breakHours),
)
// Max end hour for a given start: limited by the next booking that starts after `start`
const getMaxEndHour = (start: number) => {
const next = timedBookings
.filter(b => b.startHour >= start + 1)
.sort((a, b) => a.startHour - b.startHour)[0]
return next ? next.startHour : obj.closeHour
}
const availableEndHours = hourOptions(startHour + 1, getMaxEndHour(startHour))
// If current startHour is blocked, snap to first available
const effectiveStartHour = availableStartHours.includes(startHour)
? startHour
: (availableStartHours[0] ?? obj.openHour)
const hours = isFullDay
? obj.closeHour - obj.openHour
: Math.max(0, endHour - startHour)
: Math.max(0, endHour - effectiveStartHour)
const totalAmount = isFullDay ? obj.pricePerDay : hours * obj.pricePerHour
const maxHoursOk = !obj.maxHoursPerSlot || hours <= obj.maxHoursPerSlot
const otherBookings = existingBookings.filter(b => b.id !== editBooking?.id)
const isTimeConflict = !isFullDay && otherBookings.some(b =>
!b.isFullDay && b.status !== 'cancelled' &&
b.startHour < endHour && b.endHour > startHour,
const isTimeConflict = !isFullDay && timedBookings.some(b =>
b.startHour < endHour && b.endHour > effectiveStartHour,
)
const hasFullDayConflict = otherBookings.some(b => b.isFullDay && b.status !== 'cancelled')
const noSlots = !hasFullDayConflict && availableStartHours.length === 0
// For new (unknown) guests, phone is required
const phoneRequired = !guestIsKnown
const canSave = guestName.trim() !== '' &&
(!phoneRequired || guestPhone.trim() !== '') &&
hours > 0 && maxHoursOk && !isTimeConflict && !hasFullDayConflict
hours > 0 && maxHoursOk && !isTimeConflict && !hasFullDayConflict && !noSlots &&
(!isFullDay || (!hasFullDayConflict && !hasTimedConflict))
const handleSave = async () => {
if (!canSave || saving) return
@@ -186,11 +215,19 @@ export function RentalBookingModal({
)}
{/* Full day toggle */}
<div className="flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-600">
<div className={cn(
'flex items-center gap-3 p-3 rounded-xl border',
(hasTimedConflict || hasFullDayConflict)
? 'border-slate-200 dark:border-slate-600 opacity-50 pointer-events-none'
: 'border-slate-200 dark:border-slate-600',
)}>
<CalendarDays size={16} className="text-slate-400 shrink-0" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 flex-1">Весь день</span>
{hasTimedConflict && (
<span className="text-xs text-slate-400">Есть частичные брони</span>
)}
<button
onClick={() => setIsFullDay(v => !v)}
onClick={() => (!hasTimedConflict && !hasFullDayConflict) && setIsFullDay(v => !v)}
className={cn(
'relative rounded-full transition-colors shrink-0',
isFullDay ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600',
@@ -205,47 +242,58 @@ export function RentalBookingModal({
</div>
{/* Time selection */}
{!isFullDay && (
{!isFullDay && !hasFullDayConflict && (
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Время аренды ({obj.openHour}:00 {obj.closeHour}:00)
{breakHours > 0 && <span className="ml-1 font-normal normal-case">(перерыв {obj.bufferMinutes} мин)</span>}
</label>
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="block text-xs text-slate-500 mb-1">Начало</label>
<select
className="input text-sm"
value={startHour}
onChange={e => {
const v = parseInt(e.target.value)
setStartHour(v)
if (endHour <= v) setEndHour(Math.min(v + 1, obj.closeHour))
}}
>
{hourOptions(obj.openHour, obj.closeHour - 1).map(h => (
<option key={h} value={h}>{formatHour(h)}</option>
))}
</select>
{noSlots ? (
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
<AlertCircle size={15} className="shrink-0" />
<span>Все слоты на этот день заняты</span>
</div>
<Clock size={14} className="text-slate-400 mt-4 shrink-0" />
<div className="flex-1">
<label className="block text-xs text-slate-500 mb-1">Конец</label>
<select
className="input text-sm"
value={endHour}
onChange={e => setEndHour(parseInt(e.target.value))}
>
{hourOptions(startHour + 1, obj.closeHour).map(h => (
<option key={h} value={h}>{formatHour(h)}</option>
))}
</select>
</div>
</div>
{obj.maxHoursPerSlot && !maxHoursOk && (
<p className="mt-1.5 text-xs text-red-500">Максимум: {obj.maxHoursPerSlot} ч</p>
)}
{isTimeConflict && (
<p className="mt-1.5 text-xs text-red-500">Время пересекается с существующей бронью</p>
) : (
<>
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="block text-xs text-slate-500 mb-1">Начало</label>
<select
className="input text-sm"
value={effectiveStartHour}
onChange={e => {
const v = parseInt(e.target.value)
setStartHour(v)
const maxEnd = getMaxEndHour(v)
if (endHour <= v || endHour > maxEnd) setEndHour(Math.min(v + 1, maxEnd))
}}
>
{availableStartHours.map(h => (
<option key={h} value={h}>{formatHour(h)}</option>
))}
</select>
</div>
<Clock size={14} className="text-slate-400 mt-4 shrink-0" />
<div className="flex-1">
<label className="block text-xs text-slate-500 mb-1">Конец</label>
<select
className="input text-sm"
value={availableEndHours.includes(endHour) ? endHour : (availableEndHours[0] ?? endHour)}
onChange={e => setEndHour(parseInt(e.target.value))}
>
{availableEndHours.map(h => (
<option key={h} value={h}>{formatHour(h)}</option>
))}
</select>
</div>
</div>
{obj.maxHoursPerSlot && !maxHoursOk && (
<p className="mt-1.5 text-xs text-red-500">Максимум: {obj.maxHoursPerSlot} ч</p>
)}
{isTimeConflict && (
<p className="mt-1.5 text-xs text-red-500">Время пересекается с существующей бронью</p>
)}
</>
)}
</div>
)}