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