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>