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:
@@ -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>
|
||||
<div>
|
||||
<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)}
|
||||
/>
|
||||
</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)}
|
||||
/>
|
||||
</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)}
|
||||
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>
|
||||
</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 className="grid grid-cols-2 gap-2 mt-2">
|
||||
|
||||
Reference in New Issue
Block a user