feat: single ФИО field with autocomplete replaces 3 separate name inputs
- BookingModal (room tab): replace Фамилия/Имя/Отчество grid with single 'ФИО *' input; parseFullName() splits by spaces into last/first/middle; DB search via api.guests.list (debounced 300ms); dropdown shows current bookings + DB guests with phone/email autofill - BookingDetailPanel: merge Фамилия (with autocomplete) + Имя + Отчество into single ФИО * input; existing acQuery/acResults infrastructure reused; parseFio() updates all 3 bgForm fields on every keystroke; openEditForm initializes ФИО from full name - RentalBookingModal + BookingModal rental tab: include middleName when displaying DB guest results and when setting guestName on selection; update type to include middleName field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -146,10 +146,16 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
setEarlyOpen(false)
|
||||
}, [booking.id])
|
||||
|
||||
// Parse ФИО string into form fields
|
||||
const parseFio = (val: string) => {
|
||||
const parts = val.trim().split(/\s+/)
|
||||
return { last_name: parts[0] ?? '', first_name: parts[1] ?? '', middle_name: parts.slice(2).join(' ') || undefined }
|
||||
}
|
||||
|
||||
// Debounced guest autocomplete
|
||||
const handleAcInput = (val: string) => {
|
||||
setBgForm(f => ({ ...f, last_name: val }))
|
||||
setAcQuery(val)
|
||||
setBgForm(f => ({ ...f, ...parseFio(val) }))
|
||||
if (acTimerRef.current) clearTimeout(acTimerRef.current)
|
||||
if (val.length < 2 || !slug) { setAcResults([]); return }
|
||||
acTimerRef.current = setTimeout(() => {
|
||||
@@ -162,6 +168,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
}
|
||||
|
||||
const selectAcGuest = (g: GuestApiType) => {
|
||||
const fullName = [g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')
|
||||
setBgForm(f => ({
|
||||
...f,
|
||||
first_name: g.firstName,
|
||||
@@ -174,7 +181,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
passport_issue_date: g.passportIssueDate ?? undefined,
|
||||
nationality: g.nationality ?? undefined,
|
||||
}))
|
||||
setAcQuery(g.lastName)
|
||||
setAcQuery(fullName)
|
||||
setAcResults([])
|
||||
}
|
||||
|
||||
@@ -219,7 +226,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
passport_issue_date: bg.passportIssueDate?.slice(0, 10) ?? undefined,
|
||||
nationality: bg.nationality ?? undefined,
|
||||
})
|
||||
setAcQuery('')
|
||||
setAcQuery([bg.lastName, bg.firstName, bg.middleName].filter(Boolean).join(' '))
|
||||
setAcResults([])
|
||||
setShowGuestForm(true)
|
||||
}
|
||||
@@ -938,15 +945,15 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
{editingBg ? 'Редактировать гостя' : 'Добавить гостя'}
|
||||
</p>
|
||||
|
||||
{/* Autocomplete last name */}
|
||||
{/* ФИО with autocomplete */}
|
||||
<div ref={acWrapRef} className="relative">
|
||||
<label className={lbl}>Фамилия *</label>
|
||||
<label className={lbl}>ФИО *</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className={fld}
|
||||
value={bgForm.last_name ?? ''}
|
||||
value={acQuery}
|
||||
onChange={e => handleAcInput(e.target.value)}
|
||||
placeholder="Иванов"
|
||||
placeholder="Иванов Иван Иванович"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{acLoading && (
|
||||
@@ -966,7 +973,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
<Search size={11} className="text-slate-400 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{g.lastName} {g.firstName}
|
||||
{[g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')}
|
||||
</p>
|
||||
{(g.passportSeries || g.passportNumber) && (
|
||||
<p className="text-xs text-slate-400 font-mono">
|
||||
@@ -981,18 +988,6 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* First name + middle name */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className={lbl}>Имя *</label>
|
||||
<input className={fld} value={bgForm.first_name ?? ''} onChange={e => setBgForm(f => ({ ...f, first_name: e.target.value }))} placeholder="Иван" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Отчество</label>
|
||||
<input className={fld} value={bgForm.middle_name ?? ''} onChange={e => setBgForm(f => ({ ...f, middle_name: e.target.value }))} placeholder="Иванович" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Birth date + flags */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
|
||||
@@ -60,6 +60,15 @@ function fmtTime(min: number) {
|
||||
return m === 0 ? `${h}:00` : `${h}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function parseFullName(full: string) {
|
||||
const parts = full.trim().split(/\s+/)
|
||||
return {
|
||||
lastName: parts[0] ?? '',
|
||||
firstName: parts[1] ?? '',
|
||||
middleName: parts.slice(2).join(' '),
|
||||
}
|
||||
}
|
||||
|
||||
function isConflict(bookings: Booking[], roomId: string, checkIn: string, checkOut: string, excludeId?: string) {
|
||||
if (!checkIn || !checkOut || checkIn >= checkOut) return false
|
||||
return bookings.some(b =>
|
||||
@@ -105,10 +114,17 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
const [rentalSaving, setRentalSaving] = useState(false)
|
||||
// Rental guest autocomplete
|
||||
const [rentalShowSugg, setRentalShowSugg] = useState(false)
|
||||
const [rentalDbResults, setRentalDbResults] = useState<{ id: string; firstName: string; lastName: string; phone: string | null }[]>([])
|
||||
const [rentalDbResults, setRentalDbResults] = useState<{ id: string; firstName: string; lastName: string; middleName: string | null; phone: string | null }[]>([])
|
||||
const rentalSearchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const rentalNameRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Guest autocomplete for room tab
|
||||
const [guestFullName, setGuestFullName] = useState(existing?.guestName ?? '')
|
||||
const [showGuestSugg, setShowGuestSugg] = useState(false)
|
||||
const [guestDbResults, setGuestDbResults] = useState<{ id: string; firstName: string; lastName: string; middleName: string | null; phone: string | null; email: string | null }[]>([])
|
||||
const guestSearchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const guestNameRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const rentalObj = rentalObjects?.find(o => o.id === rentalObjId) ?? rentalObjects?.[0]
|
||||
const rentalDayBookings = rentalBookings.filter(b =>
|
||||
b.objectId === rentalObjId &&
|
||||
@@ -136,17 +152,29 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
}
|
||||
}, [rentalEffStart])
|
||||
|
||||
// Close rental guest suggestions on outside click
|
||||
// Close suggestions on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (rentalNameRef.current && !rentalNameRef.current.contains(e.target as Node)) {
|
||||
setRentalShowSugg(false)
|
||||
}
|
||||
if (guestNameRef.current && !guestNameRef.current.contains(e.target as Node)) {
|
||||
setShowGuestSugg(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
// Guest suggestions (room tab)
|
||||
const guestSugg = [
|
||||
...(bookings ?? []).filter(b => b.status === 'checked_in'),
|
||||
...(bookings ?? []).filter(b => b.status === 'confirmed'),
|
||||
]
|
||||
const guestFiltered = guestFullName.trim().length > 0
|
||||
? guestSugg.filter(b => b.guestName.toLowerCase().includes(guestFullName.toLowerCase()))
|
||||
: guestSugg
|
||||
|
||||
// Rental guest suggestions from current bookings
|
||||
const rentalGuestSugg = [
|
||||
...(bookings ?? []).filter(b => b.status === 'checked_in'),
|
||||
@@ -172,9 +200,10 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
setRentalSaving(true)
|
||||
try {
|
||||
if (slug) {
|
||||
const parts = rentalGuest.trim().split(' ')
|
||||
const { lastName: rLast, firstName: rFirst, middleName: rMid } = parseFullName(rentalGuest)
|
||||
await api.guests.create(slug, {
|
||||
last_name: parts[0] ?? '', first_name: parts[1] ?? '',
|
||||
last_name: rLast, first_name: rFirst,
|
||||
middle_name: rMid || undefined,
|
||||
phone: rentalPhone.trim() || undefined,
|
||||
}).catch(() => { /* ignore duplicate */ })
|
||||
}
|
||||
@@ -590,7 +619,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
.filter(g => !rentalFiltered.some(b => b.guestName === `${g.lastName} ${g.firstName}`))
|
||||
.map(g => (
|
||||
<button key={g.id} type="button"
|
||||
onMouseDown={() => { setRentalGuest(`${g.lastName} ${g.firstName}`); setRentalPhone(g.phone ?? ''); setRentalShowSugg(false); setRentalDbResults([]) }}
|
||||
onMouseDown={() => { setRentalGuest([g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')); setRentalPhone(g.phone ?? ''); setRentalShowSugg(false); setRentalDbResults([]) }}
|
||||
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" />
|
||||
@@ -841,30 +870,80 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
|
||||
Гость
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Фамилия *</label>
|
||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">ФИО *</label>
|
||||
<div className="relative" ref={guestNameRef}>
|
||||
<input
|
||||
type="text" className="input" placeholder="Иванов"
|
||||
value={form.lastName}
|
||||
onChange={e => set('lastName', e.target.value)}
|
||||
type="text" className="input" placeholder="Иванов Иван Иванович" autoComplete="off"
|
||||
value={guestFullName}
|
||||
onFocus={() => setShowGuestSugg(true)}
|
||||
onChange={e => {
|
||||
const val = e.target.value
|
||||
setGuestFullName(val)
|
||||
const parsed = parseFullName(val)
|
||||
set('lastName', parsed.lastName)
|
||||
set('firstName', parsed.firstName)
|
||||
set('middleName', parsed.middleName)
|
||||
setShowGuestSugg(true)
|
||||
if (guestSearchTimer.current) clearTimeout(guestSearchTimer.current)
|
||||
if (val.trim().length >= 2 && slug) {
|
||||
guestSearchTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
const results = await api.guests.list(slug, val.trim())
|
||||
setGuestDbResults(results.slice(0, 8))
|
||||
} catch { setGuestDbResults([]) }
|
||||
}, 300)
|
||||
} else {
|
||||
setGuestDbResults([])
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{showGuestSugg && (guestFiltered.length > 0 || guestDbResults.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">
|
||||
{guestFiltered.slice(0, 5).map(b => (
|
||||
<button key={b.id} type="button"
|
||||
onMouseDown={() => {
|
||||
setGuestFullName(b.guestName)
|
||||
const p = parseFullName(b.guestName)
|
||||
set('lastName', p.lastName); set('firstName', p.firstName); set('middleName', p.middleName)
|
||||
if (b.guestPhone) set('phone', b.guestPhone)
|
||||
if (b.guestEmail) set('guestEmail', b.guestEmail)
|
||||
setShowGuestSugg(false); setGuestDbResults([])
|
||||
}}
|
||||
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', b.status === 'checked_in' ? '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.status === 'checked_in' ? '🏠 Проживает' : '📅 Бронь')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Имя *</label>
|
||||
<input
|
||||
type="text" className="input" placeholder="Иван"
|
||||
value={form.firstName}
|
||||
onChange={e => set('firstName', e.target.value)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{guestDbResults
|
||||
.filter(g => !guestFiltered.some(b => b.guestName.startsWith(g.lastName)))
|
||||
.map(g => {
|
||||
const fullName = [g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')
|
||||
return (
|
||||
<button key={g.id} type="button"
|
||||
onMouseDown={() => {
|
||||
setGuestFullName(fullName)
|
||||
set('lastName', g.lastName); set('firstName', g.firstName); set('middleName', g.middleName ?? '')
|
||||
if (g.phone) set('phone', g.phone)
|
||||
if (g.email) set('guestEmail', g.email)
|
||||
setShowGuestSugg(false); setGuestDbResults([])
|
||||
}}
|
||||
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">{fullName}</p>
|
||||
<p className="text-[11px] text-slate-400 truncate">{g.phone ?? g.email ?? 'нет контактов'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Отчество</label>
|
||||
<input
|
||||
type="text" className="input" placeholder="Иванович"
|
||||
value={form.middleName}
|
||||
onChange={e => set('middleName', e.target.value)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
|
||||
@@ -73,7 +73,7 @@ export function RentalBookingModal({
|
||||
const nameRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// DB guest search
|
||||
const [dbResults, setDbResults] = useState<{ id: string; firstName: string; lastName: string; phone: string | null }[]>([])
|
||||
const [dbResults, setDbResults] = useState<{ id: string; firstName: string; lastName: string; middleName: string | null; phone: string | null }[]>([])
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
@@ -395,13 +395,15 @@ export function RentalBookingModal({
|
||||
})}
|
||||
{/* DB guest search results */}
|
||||
{dbResults
|
||||
.filter(g => !filtered.some(b => b.guestName === `${g.lastName} ${g.firstName}`))
|
||||
.map(g => (
|
||||
.filter(g => !filtered.some(b => b.guestName.startsWith(g.lastName)))
|
||||
.map(g => {
|
||||
const fullName = [g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
onMouseDown={() => {
|
||||
setGuestName(`${g.lastName} ${g.firstName}`)
|
||||
setGuestName(fullName)
|
||||
setGuestPhone(g.phone ?? '')
|
||||
setShowSuggestions(false)
|
||||
setDbResults([])
|
||||
@@ -411,13 +413,13 @@ export function RentalBookingModal({
|
||||
>
|
||||
<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-sm font-medium text-slate-800 dark:text-slate-200 truncate">{fullName}</p>
|
||||
<p className="text-[11px] text-slate-400 truncate">
|
||||
{g.phone ?? 'нет телефона'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)})
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user