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) setEarlyOpen(false)
}, [booking.id]) }, [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 // Debounced guest autocomplete
const handleAcInput = (val: string) => { const handleAcInput = (val: string) => {
setBgForm(f => ({ ...f, last_name: val }))
setAcQuery(val) setAcQuery(val)
setBgForm(f => ({ ...f, ...parseFio(val) }))
if (acTimerRef.current) clearTimeout(acTimerRef.current) if (acTimerRef.current) clearTimeout(acTimerRef.current)
if (val.length < 2 || !slug) { setAcResults([]); return } if (val.length < 2 || !slug) { setAcResults([]); return }
acTimerRef.current = setTimeout(() => { acTimerRef.current = setTimeout(() => {
@@ -162,10 +168,11 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
} }
const selectAcGuest = (g: GuestApiType) => { const selectAcGuest = (g: GuestApiType) => {
const fullName = [g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')
setBgForm(f => ({ setBgForm(f => ({
...f, ...f,
first_name: g.firstName, first_name: g.firstName,
last_name: g.lastName, last_name: g.lastName,
middle_name: g.middleName ?? undefined, middle_name: g.middleName ?? undefined,
birth_date: g.birthDate ?? undefined, birth_date: g.birthDate ?? undefined,
passport_series: g.passportSeries ?? undefined, passport_series: g.passportSeries ?? undefined,
@@ -174,7 +181,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
passport_issue_date: g.passportIssueDate ?? undefined, passport_issue_date: g.passportIssueDate ?? undefined,
nationality: g.nationality ?? undefined, nationality: g.nationality ?? undefined,
})) }))
setAcQuery(g.lastName) setAcQuery(fullName)
setAcResults([]) setAcResults([])
} }
@@ -219,7 +226,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
passport_issue_date: bg.passportIssueDate?.slice(0, 10) ?? undefined, passport_issue_date: bg.passportIssueDate?.slice(0, 10) ?? undefined,
nationality: bg.nationality ?? undefined, nationality: bg.nationality ?? undefined,
}) })
setAcQuery('') setAcQuery([bg.lastName, bg.firstName, bg.middleName].filter(Boolean).join(' '))
setAcResults([]) setAcResults([])
setShowGuestForm(true) setShowGuestForm(true)
} }
@@ -938,15 +945,15 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
{editingBg ? 'Редактировать гостя' : 'Добавить гостя'} {editingBg ? 'Редактировать гостя' : 'Добавить гостя'}
</p> </p>
{/* Autocomplete last name */} {/* ФИО with autocomplete */}
<div ref={acWrapRef} className="relative"> <div ref={acWrapRef} className="relative">
<label className={lbl}>Фамилия *</label> <label className={lbl}>ФИО *</label>
<div className="relative"> <div className="relative">
<input <input
className={fld} className={fld}
value={bgForm.last_name ?? ''} value={acQuery}
onChange={e => handleAcInput(e.target.value)} onChange={e => handleAcInput(e.target.value)}
placeholder="Иванов" placeholder="Иванов Иван Иванович"
autoComplete="off" autoComplete="off"
/> />
{acLoading && ( {acLoading && (
@@ -966,7 +973,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
<Search size={11} className="text-slate-400 shrink-0" /> <Search size={11} className="text-slate-400 shrink-0" />
<div> <div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100"> <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> </p>
{(g.passportSeries || g.passportNumber) && ( {(g.passportSeries || g.passportNumber) && (
<p className="text-xs text-slate-400 font-mono"> <p className="text-xs text-slate-400 font-mono">
@@ -981,18 +988,6 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
)} )}
</div> </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 */} {/* Birth date + flags */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>

View File

@@ -60,6 +60,15 @@ function fmtTime(min: number) {
return m === 0 ? `${h}:00` : `${h}:${String(m).padStart(2, '0')}` 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) { function isConflict(bookings: Booking[], roomId: string, checkIn: string, checkOut: string, excludeId?: string) {
if (!checkIn || !checkOut || checkIn >= checkOut) return false if (!checkIn || !checkOut || checkIn >= checkOut) return false
return bookings.some(b => return bookings.some(b =>
@@ -105,10 +114,17 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
const [rentalSaving, setRentalSaving] = useState(false) const [rentalSaving, setRentalSaving] = useState(false)
// Rental guest autocomplete // Rental guest autocomplete
const [rentalShowSugg, setRentalShowSugg] = useState(false) 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 rentalSearchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const rentalNameRef = useRef<HTMLDivElement>(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 rentalObj = rentalObjects?.find(o => o.id === rentalObjId) ?? rentalObjects?.[0]
const rentalDayBookings = rentalBookings.filter(b => const rentalDayBookings = rentalBookings.filter(b =>
b.objectId === rentalObjId && b.objectId === rentalObjId &&
@@ -136,17 +152,29 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
} }
}, [rentalEffStart]) }, [rentalEffStart])
// Close rental guest suggestions on outside click // Close suggestions on outside click
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (rentalNameRef.current && !rentalNameRef.current.contains(e.target as Node)) { if (rentalNameRef.current && !rentalNameRef.current.contains(e.target as Node)) {
setRentalShowSugg(false) setRentalShowSugg(false)
} }
if (guestNameRef.current && !guestNameRef.current.contains(e.target as Node)) {
setShowGuestSugg(false)
}
} }
document.addEventListener('mousedown', handler) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('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 // Rental guest suggestions from current bookings
const rentalGuestSugg = [ const rentalGuestSugg = [
...(bookings ?? []).filter(b => b.status === 'checked_in'), ...(bookings ?? []).filter(b => b.status === 'checked_in'),
@@ -172,9 +200,10 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
setRentalSaving(true) setRentalSaving(true)
try { try {
if (slug) { if (slug) {
const parts = rentalGuest.trim().split(' ') const { lastName: rLast, firstName: rFirst, middleName: rMid } = parseFullName(rentalGuest)
await api.guests.create(slug, { 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, phone: rentalPhone.trim() || undefined,
}).catch(() => { /* ignore duplicate */ }) }).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}`)) .filter(g => !rentalFiltered.some(b => b.guestName === `${g.lastName} ${g.firstName}`))
.map(g => ( .map(g => (
<button key={g.id} type="button" <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" 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="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 className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
Гость Гость
</label> </label>
<div className="grid grid-cols-3 gap-2"> <div>
<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 <input
type="text" className="input" placeholder="Иванов" type="text" className="input" placeholder="Иванов Иван Иванович" autoComplete="off"
value={form.lastName} value={guestFullName}
onChange={e => set('lastName', e.target.value)} onFocus={() => setShowGuestSugg(true)}
/> onChange={e => {
</div> const val = e.target.value
<div> setGuestFullName(val)
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Имя *</label> const parsed = parseFullName(val)
<input set('lastName', parsed.lastName)
type="text" className="input" placeholder="Иван" set('firstName', parsed.firstName)
value={form.firstName} set('middleName', parsed.middleName)
onChange={e => set('firstName', e.target.value)} setShowGuestSugg(true)
/> if (guestSearchTimer.current) clearTimeout(guestSearchTimer.current)
</div> if (val.trim().length >= 2 && slug) {
<div> guestSearchTimer.current = setTimeout(async () => {
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Отчество</label> try {
<input const results = await api.guests.list(slug, val.trim())
type="text" className="input" placeholder="Иванович" setGuestDbResults(results.slice(0, 8))
value={form.middleName} } catch { setGuestDbResults([]) }
onChange={e => set('middleName', e.target.value)} }, 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>
</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>
</button>
)
})}
</div>
)}
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2 mt-2"> <div className="grid grid-cols-2 gap-2 mt-2">

View File

@@ -73,7 +73,7 @@ export function RentalBookingModal({
const nameRef = useRef<HTMLDivElement>(null) const nameRef = useRef<HTMLDivElement>(null)
// DB guest search // 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 searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -395,13 +395,15 @@ export function RentalBookingModal({
})} })}
{/* DB guest search results */} {/* DB guest search results */}
{dbResults {dbResults
.filter(g => !filtered.some(b => b.guestName === `${g.lastName} ${g.firstName}`)) .filter(g => !filtered.some(b => b.guestName.startsWith(g.lastName)))
.map(g => ( .map(g => {
const fullName = [g.lastName, g.firstName, g.middleName].filter(Boolean).join(' ')
return (
<button <button
key={g.id} key={g.id}
type="button" type="button"
onMouseDown={() => { onMouseDown={() => {
setGuestName(`${g.lastName} ${g.firstName}`) setGuestName(fullName)
setGuestPhone(g.phone ?? '') setGuestPhone(g.phone ?? '')
setShowSuggestions(false) setShowSuggestions(false)
setDbResults([]) setDbResults([])
@@ -411,13 +413,13 @@ export function RentalBookingModal({
> >
<div className="w-2 h-2 rounded-full shrink-0 bg-slate-400" /> <div className="w-2 h-2 rounded-full shrink-0 bg-slate-400" />
<div className="min-w-0 flex-1"> <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"> <p className="text-[11px] text-slate-400 truncate">
{g.phone ?? 'нет телефона'} {g.phone ?? 'нет телефона'}
</p> </p>
</div> </div>
</button> </button>
)) )})
} }
</div> </div>
)} )}