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:
2026-03-22 01:22:46 +03:00
parent e403689522
commit b1b6854663
3 changed files with 131 additions and 55 deletions

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
)}