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:
@@ -793,6 +793,8 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
||||
existingBookings={(rentalBookings ?? []).filter(
|
||||
b => b.objectId === rentalModal.obj.id && b.date === rentalModal.date,
|
||||
)}
|
||||
bookings={bookings}
|
||||
rooms={rooms}
|
||||
onClose={() => setRentalModal(null)}
|
||||
onSave={(b) => {
|
||||
onRentalBookingCreate?.(b)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Clock, CalendarDays, User, Phone, AlertCircle, Banknote, CreditCard, Building2 } from 'lucide-react'
|
||||
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 type { RentalObject, RentalBooking } from '../../data/rentalData'
|
||||
import type { Booking, Room } from '../../types'
|
||||
|
||||
interface RentalBookingModalProps {
|
||||
obj: RentalObject
|
||||
date: string // 'yyyy-MM-dd'
|
||||
date: string
|
||||
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
|
||||
onSave: (b: RentalBooking) => void
|
||||
}
|
||||
@@ -20,7 +23,9 @@ function formatHour(h: number): string {
|
||||
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 [isFullDay, setIsFullDay] = useState(editBooking?.isFullDay ?? false)
|
||||
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 [paidAmount, setPaidAmount] = useState<string>(editBooking?.paidAmount ? String(editBooking.paidAmount) : '')
|
||||
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
|
||||
? obj.closeHour - obj.openHour
|
||||
: Math.max(0, endHour - startHour)
|
||||
|
||||
const totalAmount = isFullDay
|
||||
? obj.pricePerDay
|
||||
: hours * obj.pricePerHour
|
||||
|
||||
const totalAmount = isFullDay ? obj.pricePerDay : hours * obj.pricePerHour
|
||||
const maxHoursOk = !obj.maxHoursPerSlot || hours <= obj.maxHoursPerSlot
|
||||
|
||||
const otherBookings = existingBookings.filter(b => b.id !== editBooking?.id)
|
||||
@@ -52,6 +94,7 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
|
||||
|
||||
const handleSave = () => {
|
||||
if (!canSave) return
|
||||
const linked = bookings.find(b => b.id === linkedBookingId)
|
||||
onSave({
|
||||
id: editBooking?.id ?? `rb-${Date.now()}`,
|
||||
objectId: obj.id,
|
||||
@@ -61,6 +104,7 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
|
||||
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,
|
||||
@@ -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 (
|
||||
<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="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>
|
||||
<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">
|
||||
@@ -116,7 +163,7 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
|
||||
<button
|
||||
onClick={() => setIsFullDay(v => !v)}
|
||||
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',
|
||||
)}
|
||||
style={{ height: 22, width: 40 }}
|
||||
@@ -165,37 +212,64 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{obj.maxHoursPerSlot && !maxHoursOk && (
|
||||
<p className="mt-1.5 text-xs text-red-500">
|
||||
Максимальное время бронирования: {obj.maxHoursPerSlot} ч
|
||||
</p>
|
||||
<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>
|
||||
<p className="mt-1.5 text-xs text-red-500">Время пересекается с существующей бронью</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guest info */}
|
||||
{/* 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">
|
||||
<User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<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="Имя и фамилия"
|
||||
placeholder="Имя гостя"
|
||||
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>
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
Примечание
|
||||
@@ -230,8 +339,7 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{isFullDay
|
||||
? `Весь день (${obj.openHour}:00 – ${obj.closeHour}:00)`
|
||||
: `${hours} ч × ${obj.pricePerHour.toLocaleString('ru-RU')} ₽`
|
||||
}
|
||||
: `${hours} ч × ${obj.pricePerHour.toLocaleString('ru-RU')} ₽`}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{totalAmount.toLocaleString('ru-RU')} ₽
|
||||
@@ -265,7 +373,7 @@ export function RentalBookingModal({ obj, date, existingBookings, editBooking, o
|
||||
<input
|
||||
type="number"
|
||||
className="input text-sm"
|
||||
placeholder={`Сумма оплаты (макс. ${totalAmount.toLocaleString('ru-RU')} ₽)`}
|
||||
placeholder={`Сумма (макс. ${totalAmount.toLocaleString('ru-RU')} ₽)`}
|
||||
value={paidAmount}
|
||||
onChange={e => setPaidAmount(e.target.value)}
|
||||
min="0"
|
||||
|
||||
Reference in New Issue
Block a user