RentalBookingModal: guest search autocomplete + link to room booking

- Guest name field shows dropdown with matching guests from all bookings
- Checked-in guests (🏠) shown first, then confirmed (📅)
- Selecting a guest auto-fills name and phone
- New 'Привязать к номеру' dropdown to link rental to a room booking
- Selecting a linked room auto-fills guest name if empty
- Pass bookings + rooms props from BookingCalendar to modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 15:56:13 +03:00
parent b48e50e62b
commit 68c9caf46f
2 changed files with 139 additions and 29 deletions

View File

@@ -793,6 +793,8 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
existingBookings={(rentalBookings ?? []).filter( existingBookings={(rentalBookings ?? []).filter(
b => b.objectId === rentalModal.obj.id && b.date === rentalModal.date, b => b.objectId === rentalModal.obj.id && b.date === rentalModal.date,
)} )}
bookings={bookings}
rooms={rooms}
onClose={() => setRentalModal(null)} onClose={() => setRentalModal(null)}
onSave={(b) => { onSave={(b) => {
onRentalBookingCreate?.(b) onRentalBookingCreate?.(b)

View File

@@ -1,13 +1,16 @@
import { useState } from 'react' import { useState, useRef, useEffect } from 'react'
import { X, Clock, CalendarDays, User, Phone, AlertCircle, Banknote, CreditCard, Building2 } from 'lucide-react' import { X, Clock, CalendarDays, User, Phone, AlertCircle, Banknote, CreditCard, Building2, Link2 } from 'lucide-react'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import type { RentalObject, RentalBooking } from '../../data/rentalData' import type { RentalObject, RentalBooking } from '../../data/rentalData'
import type { Booking, Room } from '../../types'
interface RentalBookingModalProps { interface RentalBookingModalProps {
obj: RentalObject obj: RentalObject
date: string // 'yyyy-MM-dd' date: string
existingBookings: RentalBooking[] existingBookings: RentalBooking[]
editBooking?: RentalBooking // pass to edit an existing booking editBooking?: RentalBooking
bookings?: Booking[] // all room bookings (for guest search + link)
rooms?: Room[] // room list (for displaying room numbers)
onClose: () => void onClose: () => void
onSave: (b: RentalBooking) => void onSave: (b: RentalBooking) => void
} }
@@ -20,7 +23,9 @@ function formatHour(h: number): string {
return `${h}:00` return `${h}:00`
} }
export function RentalBookingModal({ obj, date, existingBookings, editBooking, onClose, onSave }: RentalBookingModalProps) { export function RentalBookingModal({
obj, date, existingBookings, editBooking, bookings = [], rooms = [], onClose, onSave,
}: RentalBookingModalProps) {
const isEdit = !!editBooking const isEdit = !!editBooking
const [isFullDay, setIsFullDay] = useState(editBooking?.isFullDay ?? false) const [isFullDay, setIsFullDay] = useState(editBooking?.isFullDay ?? false)
const [startHour, setStartHour] = useState(editBooking?.startHour ?? obj.openHour) const [startHour, setStartHour] = useState(editBooking?.startHour ?? obj.openHour)
@@ -30,15 +35,52 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
const [notes, setNotes] = useState(editBooking?.notes ?? '') const [notes, setNotes] = useState(editBooking?.notes ?? '')
const [paidAmount, setPaidAmount] = useState<string>(editBooking?.paidAmount ? String(editBooking.paidAmount) : '') const [paidAmount, setPaidAmount] = useState<string>(editBooking?.paidAmount ? String(editBooking.paidAmount) : '')
const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash') const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
const [linkedBookingId, setLinkedBookingId] = useState<string>(editBooking?.linkedRoomId ?? '')
// Guest search
const [showSuggestions, setShowSuggestions] = useState(false)
const nameRef = useRef<HTMLDivElement>(null)
// 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)
}
const hours = isFullDay const hours = isFullDay
? obj.closeHour - obj.openHour ? obj.closeHour - obj.openHour
: Math.max(0, endHour - startHour) : Math.max(0, endHour - startHour)
const totalAmount = isFullDay const totalAmount = isFullDay ? obj.pricePerDay : hours * obj.pricePerHour
? obj.pricePerDay
: hours * obj.pricePerHour
const maxHoursOk = !obj.maxHoursPerSlot || hours <= obj.maxHoursPerSlot const maxHoursOk = !obj.maxHoursPerSlot || hours <= obj.maxHoursPerSlot
const otherBookings = existingBookings.filter(b => b.id !== editBooking?.id) const otherBookings = existingBookings.filter(b => b.id !== editBooking?.id)
@@ -52,6 +94,7 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
const handleSave = () => { const handleSave = () => {
if (!canSave) return if (!canSave) return
const linked = bookings.find(b => b.id === linkedBookingId)
onSave({ onSave({
id: editBooking?.id ?? `rb-${Date.now()}`, id: editBooking?.id ?? `rb-${Date.now()}`,
objectId: obj.id, objectId: obj.id,
@@ -61,6 +104,7 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
endHour: isFullDay ? obj.closeHour : endHour, endHour: isFullDay ? obj.closeHour : endHour,
guestName: guestName.trim(), guestName: guestName.trim(),
guestPhone: guestPhone.trim(), guestPhone: guestPhone.trim(),
linkedRoomId: (linked?.roomId ?? linkedBookingId) || undefined,
notes: notes.trim() || undefined, notes: notes.trim() || undefined,
totalAmount, totalAmount,
paidAmount: parseFloat(paidAmount) || 0, paidAmount: parseFloat(paidAmount) || 0,
@@ -68,7 +112,12 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
}) })
} }
const displayDate = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', weekday: 'short' }).format(new Date(date)) 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 ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
@@ -80,9 +129,7 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
<p className="font-semibold text-slate-900 dark:text-slate-100">{obj.name}</p> <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> <p className="text-xs text-slate-500 dark:text-slate-400 capitalize">{displayDate}</p>
</div> </div>
<button onClick={onClose} className="btn-ghost p-1.5"> <button onClick={onClose} className="btn-ghost p-1.5"><X size={16} /></button>
<X size={16} />
</button>
</div> </div>
<div className="overflow-y-auto flex-1 p-5 space-y-4"> <div className="overflow-y-auto flex-1 p-5 space-y-4">
@@ -116,7 +163,7 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
<button <button
onClick={() => setIsFullDay(v => !v)} onClick={() => setIsFullDay(v => !v)}
className={cn( className={cn(
'relative w-10 h-5.5 rounded-full transition-colors shrink-0', 'relative rounded-full transition-colors shrink-0',
isFullDay ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600', isFullDay ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600',
)} )}
style={{ height: 22, width: 40 }} style={{ height: 22, width: 40 }}
@@ -165,37 +212,64 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
</select> </select>
</div> </div>
</div> </div>
{obj.maxHoursPerSlot && !maxHoursOk && ( {obj.maxHoursPerSlot && !maxHoursOk && (
<p className="mt-1.5 text-xs text-red-500"> <p className="mt-1.5 text-xs text-red-500">Максимум: {obj.maxHoursPerSlot} ч</p>
Максимальное время бронирования: {obj.maxHoursPerSlot} ч
</p>
)} )}
{isTimeConflict && ( {isTimeConflict && (
<p className="mt-1.5 text-xs text-red-500"> <p className="mt-1.5 text-xs text-red-500">Время пересекается с существующей бронью</p>
Выбранное время пересекается с существующей бронью
</p>
)} )}
</div> </div>
)} )}
{/* Guest info */} {/* Guest search */}
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5"> <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
Гость * Гость *
</label> </label>
<div className="relative"> <div className="relative" ref={nameRef}>
<User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> <User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 z-10" />
<input <input
type="text" type="text"
className="input pl-8 text-sm" className="input pl-8 text-sm"
placeholder="Имя и фамилия" placeholder="Имя гостя"
value={guestName} value={guestName}
onChange={e => setGuestName(e.target.value)} onChange={e => { setGuestName(e.target.value); setShowSuggestions(true) }}
onFocus={() => setShowSuggestions(true)}
autoComplete="off"
/> />
{/* Suggestions dropdown */}
{showSuggestions && filtered.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-52 overflow-y-auto">
{filtered.slice(0, 10).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">
{isCheckedIn ? '🏠 Проживает' : '📅 Бронь'}{rLabel ? ` · ${rLabel}` : ''}
{b.guestPhone ? ` · ${b.guestPhone}` : ''}
</p>
</div>
</button>
)
})}
</div>
)}
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5"> <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
Телефон Телефон
@@ -211,6 +285,41 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
/> />
</div> </div>
</div> </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> <div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5"> <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
Примечание Примечание
@@ -230,8 +339,7 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
<span className="text-sm text-slate-600 dark:text-slate-300"> <span className="text-sm text-slate-600 dark:text-slate-300">
{isFullDay {isFullDay
? `Весь день (${obj.openHour}:00 ${obj.closeHour}:00)` ? `Весь день (${obj.openHour}:00 ${obj.closeHour}:00)`
: `${hours} ч × ${obj.pricePerHour.toLocaleString('ru-RU')}` : `${hours} ч × ${obj.pricePerHour.toLocaleString('ru-RU')}`}
}
</span> </span>
<span className="text-lg font-bold text-slate-900 dark:text-slate-100"> <span className="text-lg font-bold text-slate-900 dark:text-slate-100">
{totalAmount.toLocaleString('ru-RU')} {totalAmount.toLocaleString('ru-RU')}
@@ -265,7 +373,7 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
<input <input
type="number" type="number"
className="input text-sm" className="input text-sm"
placeholder={`Сумма оплаты (макс. ${totalAmount.toLocaleString('ru-RU')} ₽)`} placeholder={`Сумма (макс. ${totalAmount.toLocaleString('ru-RU')} ₽)`}
value={paidAmount} value={paidAmount}
onChange={e => setPaidAmount(e.target.value)} onChange={e => setPaidAmount(e.target.value)}
min="0" min="0"