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

@@ -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>
)}