BookingModal: replace guestName with FIO+phone fields, remove passport tab

- Add lastName/firstName/middleName/phone fields to booking form
- Remove 'Документы гостя' tab entirely from BookingModal
- Auto-parse existing guestName into FIO parts when editing
- Add guestPhone to Booking type and propagate through CalendarPage/BookingsPage API calls
- Add middle_name to guests table (migration 011)
- Update guests backend route to include middle_name in all queries
- Add middleName to GuestApiType in api.ts
- Fix selectAcGuest in BookingDetailPanel to also fill middle_name
- Auto-open guest form pre-filled with booking FIO when no guests exist
- Add passport series+number lookup in BookingDetailPanel guest form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 21:15:37 +03:00
parent 873a9a4fc4
commit 84cfca500c
8 changed files with 127 additions and 259 deletions

View File

@@ -96,6 +96,17 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
setBgGuests(guests)
setRequireDocs(Boolean((settings as { require_guest_docs?: boolean }).require_guest_docs))
setBgLoaded(true)
// Auto-open add form pre-filled with booking FIO when no guests yet
if (guests.length === 0 && booking.status !== 'cancelled') {
const parts = (booking.guestName ?? '').split(' ')
setBgForm({
last_name: parts[0] ?? '',
first_name: parts[1] ?? '',
middle_name: parts.slice(2).join(' ') || undefined,
is_main: true,
})
setShowGuestForm(true)
}
})
.catch(console.error)
.finally(() => setBgLoading(false))
@@ -130,15 +141,34 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
...f,
first_name: g.firstName,
last_name: g.lastName,
middle_name: g.middleName ?? undefined,
birth_date: g.birthDate ?? undefined,
passport_series: g.passportSeries ?? undefined,
passport_number: g.passportNumber ?? undefined,
nationality: g.nationality ?? undefined,
}))
setAcQuery('')
setAcQuery(g.lastName)
setAcResults([])
}
const passportTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handlePassportInput = (field: 'passport_series' | 'passport_number', val: string) => {
setBgForm(f => ({ ...f, [field]: val }))
if (passportTimerRef.current) clearTimeout(passportTimerRef.current)
const series = field === 'passport_series' ? val : (bgForm.passport_series ?? '')
const number = field === 'passport_number' ? val : (bgForm.passport_number ?? '')
const query = (series + number).trim()
if (query.length < 4 || !slug) return
passportTimerRef.current = setTimeout(() => {
api.guests.list(slug, query)
.then(results => {
if (results.length === 1) selectAcGuest(results[0])
})
.catch(() => {/* ignore */})
}, 400)
}
const openAddForm = () => {
setEditingBg(null)
setBgForm({ is_main: bgGuests.length === 0 })
@@ -704,11 +734,11 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
<div className="grid grid-cols-2 gap-2">
<div>
<label className={lbl}>Серия</label>
<input className={`${fld} font-mono uppercase`} value={bgForm.passport_series ?? ''} onChange={e => setBgForm(f => ({ ...f, passport_series: e.target.value.toUpperCase() }))} placeholder="4510" maxLength={10} />
<input className={`${fld} font-mono uppercase`} value={bgForm.passport_series ?? ''} onChange={e => handlePassportInput('passport_series', e.target.value.toUpperCase())} placeholder="4510" maxLength={10} />
</div>
<div>
<label className={lbl}>Номер</label>
<input className={`${fld} font-mono`} value={bgForm.passport_number ?? ''} onChange={e => setBgForm(f => ({ ...f, passport_number: e.target.value }))} placeholder="123456" maxLength={10} />
<input className={`${fld} font-mono`} value={bgForm.passport_number ?? ''} onChange={e => handlePassportInput('passport_number', e.target.value)} placeholder="123456" maxLength={10} />
</div>
</div>
<div>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { format } from 'date-fns'
import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, IdCard, CalendarDays, Tag, ScanLine, Minus, BedDouble, Tv2, Send, Loader2, CheckCircle2 } from 'lucide-react'
import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, Tag, Minus, BedDouble, Tv2, Send, Loader2, CheckCircle2 } from 'lucide-react'
import { MOCK_DISCOUNTS } from '../../pages/DiscountsPage'
import type { Discount } from '../../pages/DiscountsPage'
import { Modal } from '../ui/Modal'
@@ -11,37 +11,6 @@ import { useModules } from '../../contexts/ModulesContext'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
type DocType = 'rf_passport' | 'foreign_passport' | 'other'
interface PassportData {
docType: DocType
lastName: string
firstName: string
patronymic: string
birthDate: string
birthPlace: string
nationality: string
series: string
number: string
issuedBy: string
issuedDate: string
divisionCode: string
registrationAddress: string
}
const EMPTY_PASSPORT: PassportData = {
docType: 'rf_passport',
lastName: '', firstName: '', patronymic: '',
birthDate: '', birthPlace: '', nationality: 'Россия',
series: '', number: '', issuedBy: '', issuedDate: '',
divisionCode: '', registrationAddress: '',
}
const DOC_TYPES: { id: DocType; label: string }[] = [
{ id: 'rf_passport', label: 'Паспорт РФ' },
{ id: 'foreign_passport', label: 'Загранпаспорт' },
{ id: 'other', label: 'Иной документ' },
]
const GUEST_TAGS = ['VIP', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания']
@@ -110,16 +79,20 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
}
}
const _nameParts = (existing?.guestName ?? '').split(' ')
const [form, setForm] = useState({
roomId: existing?.roomId ?? draft.roomId,
guestName: existing?.guestName ?? '',
guestEmail:existing?.guestEmail ?? '',
checkIn: existing?.checkIn ?? draft.checkIn,
checkOut: existing?.checkOut ?? draft.checkOut,
adults: existing?.adults ?? 2,
children: existing?.children ?? 0,
status: (existing?.status ?? 'confirmed') as BookingStatus,
notes: existing?.notes ?? '',
roomId: existing?.roomId ?? draft.roomId,
lastName: _nameParts[0] ?? '',
firstName: _nameParts[1] ?? '',
middleName: _nameParts.slice(2).join(' '),
phone: existing?.guestPhone ?? '',
guestEmail: existing?.guestEmail ?? '',
checkIn: existing?.checkIn ?? draft.checkIn,
checkOut: existing?.checkOut ?? draft.checkOut,
adults: existing?.adults ?? 2,
children: existing?.children ?? 0,
status: (existing?.status ?? 'confirmed') as BookingStatus,
notes: existing?.notes ?? '',
})
const [guestTags, setGuestTags] = useState<string[]>([])
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'terminal' | null>(null)
@@ -127,17 +100,10 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
const [showFloorMap, setShowFloorMap] = useState(false)
const [addedServices, setAddedServices] = useState<AddedService[]>([])
const [selectedServiceId, setSelectedServiceId] = useState(ADDITIONAL_SERVICES[0].id)
const [activeTab, setActiveTab] = useState<'booking' | 'passport'>('booking')
const [passport, setPassport] = useState<PassportData>(EMPTY_PASSPORT)
const [selectedDiscountId, setSelectedDiscountId] = useState<string>('')
const activeDiscounts = MOCK_DISCOUNTS.filter(d => d.isActive)
const selectedDiscount: Discount | undefined = activeDiscounts.find(d => d.id === selectedDiscountId)
const setP = <K extends keyof PassportData>(k: K, v: PassportData[K]) =>
setPassport(prev => ({ ...prev, [k]: v }))
const passportFilled = !!(passport.lastName || passport.number)
// Hourly booking state
const [isHourly, setIsHourly] = useState(false)
const [hourlyDate, setHourlyDate] = useState(draft.checkIn)
@@ -190,13 +156,14 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
setGuestTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])
const handleSave = () => {
if (!form.guestName) return
if (!form.lastName || !form.firstName) return
if (isHourly && room?.allowHourly) {
if (!hourlyDate) return
} else {
if (!form.checkIn || !form.checkOut) return
}
const guestName = [form.lastName, form.firstName, form.middleName].filter(Boolean).join(' ')
const hourlyPrefix = isHourly && room?.allowHourly
? `[Почасово: ${String(startHour).padStart(2, '0')}:00${String(endHour).padStart(2, '0')}:00] `
: ''
@@ -206,17 +173,23 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
const checkOut = isHourly && room?.allowHourly ? hourlyDate : form.checkOut
onSave({
...form,
source: 'direct',
roomId: form.roomId,
guestName,
guestEmail: form.guestEmail,
guestPhone: form.phone,
checkIn,
checkOut,
notes: hourlyPrefix + extraBedsPrefix + form.notes,
adults: form.adults,
children: form.children,
status: form.status,
notes: hourlyPrefix + extraBedsPrefix + form.notes,
totalAmount: total,
paidAmount,
id: existing?.id ?? `b-${Date.now()}`,
hotelId: 'hotel-1',
guestId: existing?.guestId ?? `g-${Date.now()}`,
createdAt: existing?.createdAt ?? format(new Date(), 'yyyy-MM-dd'),
source: 'direct',
})
}
@@ -232,41 +205,12 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
footer={
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button onClick={handleSave} className="btn-primary" disabled={!form.guestName}>
<button onClick={handleSave} className="btn-primary" disabled={!form.lastName || !form.firstName}>
{existing ? 'Сохранить' : 'Создать бронирование'}
</button>
</>
}
>
{/* ── Tabs ── */}
<div className="flex border-b border-slate-200 dark:border-slate-700 mb-4 -mt-1">
{([
{ id: 'booking' as const, label: 'Бронирование', icon: CalendarDays },
{ id: 'passport' as const, label: 'Документы гостя', icon: IdCard,
badge: passportFilled },
]).map(t => (
<button
key={t.id}
type="button"
onClick={() => setActiveTab(t.id)}
className={cn(
'flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === t.id
? 'border-brand-600 text-brand-600 dark:text-brand-400 dark:border-brand-400'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
<t.icon size={14} />
{t.label}
{t.badge && (
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
)}
</button>
))}
</div>
{/* ── BOOKING TAB ── */}
{activeTab === 'booking' && (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
{/* ── LEFT COLUMN ── */}
@@ -330,18 +274,57 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
}
</div>
{/* Guest name + email */}
<div className="grid grid-cols-2 gap-3">
{/* ФИО гостя */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Имя гостя *
Фамилия *
</label>
<input
type="text"
className="input"
placeholder="Иван Иванов"
value={form.guestName}
onChange={e => set('guestName', e.target.value)}
placeholder="Иванов"
value={form.lastName}
onChange={e => set('lastName', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Имя *
</label>
<input
type="text"
className="input"
placeholder="Иван"
value={form.firstName}
onChange={e => set('firstName', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Отчество
</label>
<input
type="text"
className="input"
placeholder="Иванович"
value={form.middleName}
onChange={e => set('middleName', e.target.value)}
/>
</div>
</div>
{/* Телефон + Email */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Телефон
</label>
<input
type="tel"
className="input"
placeholder="+7 (999) 000-00-00"
value={form.phone}
onChange={e => set('phone', e.target.value)}
/>
</div>
<div>
@@ -801,159 +784,6 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
)}
</div>
</div>
)} {/* end booking tab */}
{/* ── PASSPORT TAB ── */}
{activeTab === 'passport' && (
<div className="space-y-4">
{/* Document type + scan */}
<div className="flex items-center justify-between gap-3">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Тип документа</label>
<button
type="button"
onClick={() => alert('Для сканирования подключите RFID/OCR сканер паспорта')}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-dashed border-brand-300 dark:border-brand-600 text-brand-600 dark:text-brand-400 text-xs font-medium hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors shrink-0"
>
<ScanLine size={13} /> Сканировать
</button>
</div>
<div>
<div className="h-0" />
<div className="flex gap-2">
{DOC_TYPES.map(dt => (
<button
key={dt.id}
type="button"
onClick={() => setP('docType', dt.id)}
className={cn(
'flex-1 py-2 rounded-lg text-sm font-medium border transition-colors',
passport.docType === dt.id
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-brand-400',
)}
>
{dt.label}
</button>
))}
</div>
</div>
{/* Name fields */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Фамилия</label>
<input type="text" className="input" placeholder="Иванов"
value={passport.lastName} onChange={e => setP('lastName', e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Имя</label>
<input type="text" className="input" placeholder="Иван"
value={passport.firstName} onChange={e => setP('firstName', e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Отчество</label>
<input type="text" className="input" placeholder="Иванович"
value={passport.patronymic} onChange={e => setP('patronymic', e.target.value)} />
</div>
</div>
{/* Birth + nationality */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Дата рождения</label>
<input type="date" className="input"
value={passport.birthDate} onChange={e => setP('birthDate', e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Место рождения</label>
<input type="text" className="input" placeholder="г. Москва"
value={passport.birthPlace} onChange={e => setP('birthPlace', e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Гражданство</label>
<input type="text" className="input" placeholder="Россия"
value={passport.nationality} onChange={e => setP('nationality', e.target.value)} />
</div>
</div>
{/* Document fields */}
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
<div className="px-4 py-2.5 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600">
<p className="text-xs font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide">Реквизиты документа</p>
</div>
<div className="p-4 space-y-3">
{passport.docType === 'rf_passport' && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Серия</label>
<input
type="text" className="input" placeholder="45 16" maxLength={5}
value={passport.series} onChange={e => setP('series', e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Номер</label>
<input
type="text" className="input" placeholder="123456" maxLength={6}
value={passport.number} onChange={e => setP('number', e.target.value)}
/>
</div>
</div>
)}
{passport.docType !== 'rf_passport' && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Номер документа</label>
<input
type="text" className="input" placeholder="Номер документа"
value={passport.number} onChange={e => setP('number', e.target.value)}
/>
</div>
)}
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Кем выдан</label>
<input
type="text" className="input" placeholder="ОУМВД России по г. Москве"
value={passport.issuedBy} onChange={e => setP('issuedBy', e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Дата выдачи</label>
<input type="date" className="input"
value={passport.issuedDate} onChange={e => setP('issuedDate', e.target.value)} />
</div>
{passport.docType === 'rf_passport' && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Код подразделения</label>
<input
type="text" className="input" placeholder="770-001" maxLength={7}
value={passport.divisionCode} onChange={e => setP('divisionCode', e.target.value)}
/>
</div>
)}
</div>
</div>
</div>
{/* Registration address */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Адрес регистрации</label>
<input
type="text" className="input"
placeholder="г. Москва, ул. Примерная, д. 1, кв. 1"
value={passport.registrationAddress}
onChange={e => setP('registrationAddress', e.target.value)}
/>
</div>
{/* Hint */}
<p className="text-xs text-slate-400 dark:text-slate-500">
Данные используются для формирования регистрационной карты и уведомления УМВД (при необходимости)
</p>
</div>
)} {/* end passport tab */}
</Modal>

View File

@@ -459,6 +459,7 @@ export interface GuestApiType {
hotelId: string
firstName: string
lastName: string
middleName: string | null
email: string | null
phone: string | null
passport: string | null
@@ -493,6 +494,7 @@ export interface GuestApiType {
export interface GuestPayload {
first_name?: string
last_name?: string
middle_name?: string
email?: string
phone?: string
passport?: string

View File

@@ -108,7 +108,7 @@ export function BookingsPage() {
const handleCreateBooking = async (data: Partial<Booking>) => {
try {
const created = await api.bookings.create(slug, {
roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail,
roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail, guestPhone: data.guestPhone,
checkIn: data.checkIn, checkOut: data.checkOut,
adults: data.adults, children: data.children,
status: data.status, source: data.source,
@@ -124,7 +124,7 @@ export function BookingsPage() {
const handleUpdateBooking = async (id: string, data: Partial<Booking>) => {
try {
const updated = await api.bookings.update(slug, id, {
guestName: data.guestName, guestEmail: data.guestEmail,
guestName: data.guestName, guestEmail: data.guestEmail, guestPhone: data.guestPhone,
checkIn: data.checkIn, checkOut: data.checkOut,
adults: data.adults, children: data.children,
status: data.status, source: data.source,

View File

@@ -63,7 +63,7 @@ export function CalendarPage() {
const handleCreate = async (data: Partial<Booking>) => {
try {
const created = await api.bookings.create(slug, {
roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail,
roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail, guestPhone: data.guestPhone,
checkIn: data.checkIn, checkOut: data.checkOut,
adults: data.adults, children: data.children,
status: data.status, source: data.source,

View File

@@ -147,6 +147,7 @@ export interface Booking {
guestId: string
guestName: string
guestEmail: string
guestPhone?: string
checkIn: string // ISO date YYYY-MM-DD
checkOut: string // ISO date YYYY-MM-DD
status: BookingStatus