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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user