Files
hotelsync/src/components/rental/RentalBookingModal.tsx
HotelSync 31858ca5cc 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>
2026-03-21 22:07:40 +03:00

527 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useEffect } from 'react'
import { X, Clock, CalendarDays, User, Phone, AlertCircle, Banknote, CreditCard, Building2, Link2 } from 'lucide-react'
import { cn } from '../../lib/utils'
import { api } from '../../lib/api'
import type { RentalObject, RentalBooking } from '../../data/rentalData'
import type { Booking, Room } from '../../types'
interface RentalBookingModalProps {
obj: RentalObject
date: string
existingBookings: RentalBooking[]
editBooking?: RentalBooking
bookings?: Booking[] // all room bookings (for guest search + link)
rooms?: Room[] // room list (for displaying room numbers)
slug?: string
onClose: () => void
onSave: (b: RentalBooking) => void
}
function hourOptions(from: number, to: number): number[] {
return Array.from({ length: to - from + 1 }, (_, i) => from + i)
}
function formatHour(h: number): string {
return `${h}:00`
}
export function RentalBookingModal({
obj, date, existingBookings, editBooking, bookings = [], rooms = [], slug, onClose, onSave,
}: RentalBookingModalProps) {
const isEdit = !!editBooking
const [isFullDay, setIsFullDay] = useState(editBooking?.isFullDay ?? false)
const [startHour, setStartHour] = useState(editBooking?.startHour ?? obj.openHour)
const [endHour, setEndHour] = useState(editBooking?.endHour ?? Math.min(obj.openHour + 2, obj.closeHour))
const [guestName, setGuestName] = useState(editBooking?.guestName ?? '')
const [guestPhone, setGuestPhone] = useState(editBooking?.guestPhone ?? '')
const [notes, setNotes] = useState(editBooking?.notes ?? '')
const [paidAmount, setPaidAmount] = useState<string>(editBooking?.paidAmount ? String(editBooking.paidAmount) : '')
const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
const [linkedBookingId, setLinkedBookingId] = useState<string>(editBooking?.linkedRoomId ?? '')
// true = selected from dropdown (existing guest), false = typed manually (new guest)
const [guestIsKnown, setGuestIsKnown] = useState(isEdit)
// Guest search
const [showSuggestions, setShowSuggestions] = useState(false)
const nameRef = useRef<HTMLDivElement>(null)
// DB guest search
const [dbResults, setDbResults] = useState<{ id: string; firstName: string; lastName: string; phone: string | null }[]>([])
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [saving, setSaving] = useState(false)
// Bookings with guest info — checked_in first, then confirmed
const checkedIn = bookings.filter(b => b.status === 'checked_in')
const confirmed = bookings.filter(b => b.status === 'confirmed')
const guestSuggestions = [...checkedIn, ...confirmed]
const filtered = guestName.trim().length > 0
? guestSuggestions.filter(b =>
b.guestName.toLowerCase().includes(guestName.toLowerCase()),
)
: guestSuggestions
// Room label helper
const roomLabel = (b: Booking) => {
const r = rooms.find(r => r.id === b.roomId)
return r ? `${r.number}` : ''
}
// Close suggestions on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (nameRef.current && !nameRef.current.contains(e.target as Node)) {
setShowSuggestions(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const selectGuest = (b: Booking) => {
setGuestName(b.guestName)
setGuestPhone(b.guestPhone ?? '')
setLinkedBookingId(b.id)
setShowSuggestions(false)
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 - effectiveStartHour)
const totalAmount = isFullDay ? obj.pricePerDay : hours * obj.pricePerHour
const maxHoursOk = !obj.maxHoursPerSlot || hours <= obj.maxHoursPerSlot
const isTimeConflict = !isFullDay && timedBookings.some(b =>
b.startHour < endHour && b.endHour > effectiveStartHour,
)
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 && !noSlots &&
(!isFullDay || (!hasFullDayConflict && !hasTimedConflict))
const handleSave = async () => {
if (!canSave || saving) return
setSaving(true)
try {
// If guest is new (typed manually), create them in the guests DB
if (!guestIsKnown && slug) {
const nameParts = guestName.trim().split(' ')
await api.guests.create(slug, {
last_name: nameParts[0] ?? '',
first_name: nameParts[1] ?? '',
middle_name: nameParts.slice(2).join(' ') || undefined,
phone: guestPhone.trim() || undefined,
})
}
const linked = bookings.find(b => b.id === linkedBookingId)
onSave({
id: editBooking?.id ?? `rb-${Date.now()}`,
objectId: obj.id,
date,
isFullDay,
startHour: isFullDay ? obj.openHour : startHour,
endHour: isFullDay ? obj.closeHour : endHour,
guestName: guestName.trim(),
guestPhone: guestPhone.trim(),
linkedRoomId: (linked?.roomId ?? linkedBookingId) || undefined,
notes: notes.trim() || undefined,
totalAmount,
paidAmount: parseFloat(paidAmount) || 0,
status: 'confirmed',
})
} finally {
setSaving(false)
}
}
const displayDate = new Intl.DateTimeFormat('ru-RU', {
day: 'numeric', month: 'long', weekday: 'short',
}).format(new Date(date))
// For link dropdown — show checked-in first, label includes room number
const linkableBookings = [...checkedIn, ...confirmed.filter(b => !checkedIn.includes(b))]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-md flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
<span className="text-2xl">{obj.icon}</span>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 dark:text-slate-100">{obj.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 capitalize">{displayDate}</p>
</div>
<button onClick={onClose} className="btn-ghost p-1.5"><X size={16} /></button>
</div>
<div className="overflow-y-auto flex-1 p-5 space-y-4">
{/* Existing bookings for the day */}
{existingBookings.filter(b => b.status !== 'cancelled').length > 0 && (
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 p-3 space-y-1.5">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Уже забронировано</p>
{existingBookings.filter(b => b.status !== 'cancelled').map(b => (
<div key={b.id} className="flex items-center gap-2 text-sm">
<div className={cn('w-2 h-2 rounded-full shrink-0', obj.color)} />
<span className="text-slate-700 dark:text-slate-300 font-medium">
{b.isFullDay ? 'Весь день' : `${b.startHour}:00 ${b.endHour}:00`}
</span>
<span className="text-slate-500 dark:text-slate-400 truncate">{b.guestName}</span>
</div>
))}
</div>
)}
{hasFullDayConflict && (
<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>
)}
{/* Full day toggle */}
<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={() => (!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',
)}
style={{ height: 22, width: 40 }}
>
<span className={cn(
'absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform',
isFullDay ? 'translate-x-5' : 'translate-x-0.5',
)} />
</button>
</div>
{/* Time selection */}
{!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>
{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>
) : (
<>
<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>
)}
{/* Guest search */}
<div className="space-y-3">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
Гость *
</label>
<div className="relative" ref={nameRef}>
<User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 z-10" />
<input
type="text"
className="input pl-8 text-sm"
placeholder="Имя гостя"
value={guestName}
onChange={e => {
const val = e.target.value
setGuestName(val)
setGuestIsKnown(false) // manual typing = new guest
setShowSuggestions(true)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
if (val.trim().length >= 2 && slug) {
searchTimerRef.current = setTimeout(async () => {
try {
const results = await api.guests.list(slug, val.trim())
setDbResults(results.slice(0, 8))
} catch { setDbResults([]) }
}, 300)
} else {
setDbResults([])
}
}}
onFocus={() => setShowSuggestions(true)}
autoComplete="off"
/>
{/* Suggestions dropdown */}
{showSuggestions && (filtered.length > 0 || dbResults.length > 0) && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-600 shadow-xl overflow-hidden max-h-60 overflow-y-auto">
{/* Current/upcoming guests from bookings */}
{filtered.slice(0, 5).map(b => {
const rLabel = roomLabel(b)
const isCheckedIn = b.status === 'checked_in'
return (
<button
key={b.id}
type="button"
onMouseDown={() => selectGuest(b)}
className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors text-left"
>
<div className={cn('w-2 h-2 rounded-full shrink-0', isCheckedIn ? 'bg-emerald-500' : 'bg-sky-400')} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{b.guestName}</p>
<p className="text-[11px] text-slate-400 truncate">
{b.guestPhone
? b.guestPhone
: isCheckedIn ? '🏠 Проживает' : '📅 Бронь'
}{rLabel ? ` · ${rLabel}` : ''}
</p>
</div>
</button>
)
})}
{/* DB guest search results */}
{dbResults
.filter(g => !filtered.some(b => b.guestName === `${g.lastName} ${g.firstName}`))
.map(g => (
<button
key={g.id}
type="button"
onMouseDown={() => {
setGuestName(`${g.lastName} ${g.firstName}`)
setGuestPhone(g.phone ?? '')
setShowSuggestions(false)
setDbResults([])
setGuestIsKnown(true)
}}
className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors text-left"
>
<div className="w-2 h-2 rounded-full shrink-0 bg-slate-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{g.lastName} {g.firstName}</p>
<p className="text-[11px] text-slate-400 truncate">
{g.phone ?? 'нет телефона'}
</p>
</div>
</button>
))
}
</div>
)}
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
Телефон {phoneRequired && <span className="text-red-500">*</span>}
</label>
<div className="relative">
<Phone size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="tel"
className={cn('input pl-8 text-sm', phoneRequired && !guestPhone.trim() && 'border-red-400 focus:ring-red-400')}
placeholder={phoneRequired ? '+7 999 000 11 22 (обязательно)' : '+7 999 000 11 22'}
value={guestPhone}
onChange={e => setGuestPhone(e.target.value)}
/>
</div>
{phoneRequired && !guestPhone.trim() && (
<p className="mt-1 text-[11px] text-red-500">Для нового гостя необходим телефон он будет добавлен в базу</p>
)}
</div>
{/* Link to room */}
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5 flex items-center gap-1">
<Link2 size={11} /> Привязать к номеру
</label>
<select
className="input text-sm"
value={linkedBookingId}
onChange={e => {
const id = e.target.value
setLinkedBookingId(id)
if (id) {
const b = bookings.find(b => b.id === id)
if (b && !guestName) {
setGuestName(b.guestName)
setGuestPhone(b.guestPhone ?? '')
}
}
}}
>
<option value=""> Без привязки </option>
{linkableBookings.map(b => {
const r = rooms.find(r => r.id === b.roomId)
const isCI = b.status === 'checked_in'
return (
<option key={b.id} value={b.id}>
{isCI ? '🏠' : '📅'} {r?.number ?? '?'} {b.guestName}
{isCI ? ' (проживает)' : ''}
</option>
)
})}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
Примечание
</label>
<textarea
className="input resize-none text-sm"
rows={2}
placeholder="Дополнительно..."
value={notes}
onChange={e => setNotes(e.target.value)}
/>
</div>
</div>
{/* Price summary */}
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 flex items-center justify-between">
<span className="text-sm text-slate-600 dark:text-slate-300">
{isFullDay
? `Весь день (${obj.openHour}:00 ${obj.closeHour}:00)`
: `${hours} ч × ${obj.pricePerHour.toLocaleString('ru-RU')}`}
</span>
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
{totalAmount.toLocaleString('ru-RU')}
</span>
</div>
{/* Payment */}
<div className="space-y-2">
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide">
Оплата при бронировании
</label>
<div className="grid grid-cols-3 gap-1.5">
{([
{ id: 'cash' as const, label: 'Наличные', icon: Banknote },
{ id: 'card' as const, label: 'Карта', icon: CreditCard },
{ id: 'transfer' as const, label: 'Перевод', icon: Building2 },
]).map(m => (
<button
key={m.id} type="button" onClick={() => setPayMethod(m.id)}
className={cn(
'flex flex-col items-center gap-1 py-2 rounded-lg border text-xs font-medium transition-colors',
payMethod === m.id
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
<m.icon size={14} /> {m.label}
</button>
))}
</div>
<input
type="number"
className="input text-sm"
placeholder={`Сумма (макс. ${totalAmount.toLocaleString('ru-RU')} ₽)`}
value={paidAmount}
onChange={e => setPaidAmount(e.target.value)}
min="0"
max={totalAmount}
/>
{paidAmount && parseFloat(paidAmount) > 0 && parseFloat(paidAmount) < totalAmount && (
<p className="text-xs text-amber-600 dark:text-amber-400">
Остаток: {(totalAmount - parseFloat(paidAmount)).toLocaleString('ru-RU')}
</p>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-slate-200 dark:border-slate-700 shrink-0">
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={() => { void handleSave() }}
disabled={!canSave || saving}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Сохранение...' : isEdit ? 'Сохранить' : 'Забронировать'}
</button>
</div>
</div>
</div>
)
}