Add multi-guest management: per-booking roster, docs, guest autocomplete

- Migration 010: booking_guests table (roster per booking with passport data)
- Backend: /bookings/:id/guests CRUD — auto-links/creates guest profiles by passport
- Backend: /hotel-settings GET/PATCH for key-value settings (require_guest_docs)
- BookingDetailPanel: Гости tab with multi-guest list, inline add/edit form,
  guest autocomplete (debounced search in guests table), child/main badges,
  passport fields for adults, scan stub, require-docs warning
- SettingsPage: toggle "Обязательное заполнение документов гостей"
- Pass slug prop through CalendarPage → BookingCalendar → BookingDetailPanel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 20:53:07 +03:00
parent de4fb2126b
commit 873a9a4fc4
10 changed files with 803 additions and 11 deletions

View File

@@ -1,14 +1,16 @@
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import {
X, Mail, Calendar, Users, CreditCard, Tag, CheckCircle, XCircle,
Printer, ScanLine, Banknote, Building2, Plus, Pencil,
FileText, FileCheck, Receipt, IdCard, AlertTriangle,
Trash2, Loader2, UserCheck, Baby, Search,
} from 'lucide-react'
import {
cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS,
SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount,
} from '../../lib/utils'
import type { Booking, Room } from '../../types'
import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType } from '../../lib/api'
const fmtDate = (iso: string) =>
new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(iso))
@@ -32,7 +34,7 @@ type Tab = 'booking' | 'guest' | 'payment' | 'docs'
const TABS: { id: Tab; label: string }[] = [
{ id: 'booking', label: 'Бронь' },
{ id: 'guest', label: 'Гость' },
{ id: 'guest', label: 'Гости' },
{ id: 'payment', label: 'Оплата' },
{ id: 'docs', label: 'Документы' },
]
@@ -57,15 +59,157 @@ interface BookingDetailPanelProps {
room?: Room
rooms?: Room[]
allBookings?: Booking[]
slug?: string
onClose: () => void
onUpdate: (id: string, data: Partial<Booking>) => void
onBulkUpdate?: (updates: Array<{ id: string; data: Partial<Booking> }>) => void
}
export function BookingDetailPanel({ booking, room, rooms, allBookings, onClose, onUpdate, onBulkUpdate }: BookingDetailPanelProps) {
export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, onClose, onUpdate, onBulkUpdate }: BookingDetailPanelProps) {
const [tab, setTab] = useState<Tab>('booking')
// Date editing
// ── Multi-guest state ────────────────────────────────────────────────────
const [bgGuests, setBgGuests] = useState<BookingGuest[]>([])
const [bgLoading, setBgLoading] = useState(false)
const [bgLoaded, setBgLoaded] = useState(false)
const [requireDocs, setRequireDocs] = useState(false)
const [showGuestForm, setShowGuestForm] = useState(false)
const [editingBg, setEditingBg] = useState<BookingGuest | null>(null)
const [bgForm, setBgForm] = useState<BookingGuestPayload>({})
const [bgSaving, setBgSaving] = useState(false)
// Autocomplete
const [acQuery, setAcQuery] = useState('')
const [acResults, setAcResults] = useState<GuestApiType[]>([])
const [acLoading, setAcLoading] = useState(false)
const acTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const acWrapRef = useRef<HTMLDivElement | null>(null)
// Load booking guests + settings when tab is activated
useEffect(() => {
if (tab !== 'guest' || !slug || bgLoaded) return
setBgLoading(true)
Promise.all([
api.bookingGuests.list(slug, booking.id),
api.hotelSettings.get(slug).catch(() => ({})),
])
.then(([guests, settings]) => {
setBgGuests(guests)
setRequireDocs(Boolean((settings as { require_guest_docs?: boolean }).require_guest_docs))
setBgLoaded(true)
})
.catch(console.error)
.finally(() => setBgLoading(false))
}, [tab, slug, booking.id, bgLoaded])
// Reset when booking changes
useEffect(() => {
setBgLoaded(false)
setBgGuests([])
setShowGuestForm(false)
setEditingBg(null)
setBgForm({})
}, [booking.id])
// Debounced guest autocomplete
const handleAcInput = (val: string) => {
setBgForm(f => ({ ...f, last_name: val }))
setAcQuery(val)
if (acTimerRef.current) clearTimeout(acTimerRef.current)
if (val.length < 2 || !slug) { setAcResults([]); return }
acTimerRef.current = setTimeout(() => {
setAcLoading(true)
api.guests.list(slug, val)
.then(setAcResults)
.catch(() => setAcResults([]))
.finally(() => setAcLoading(false))
}, 300)
}
const selectAcGuest = (g: GuestApiType) => {
setBgForm(f => ({
...f,
first_name: g.firstName,
last_name: g.lastName,
birth_date: g.birthDate ?? undefined,
passport_series: g.passportSeries ?? undefined,
passport_number: g.passportNumber ?? undefined,
nationality: g.nationality ?? undefined,
}))
setAcQuery('')
setAcResults([])
}
const openAddForm = () => {
setEditingBg(null)
setBgForm({ is_main: bgGuests.length === 0 })
setAcQuery('')
setAcResults([])
setShowGuestForm(true)
}
const openEditForm = (bg: BookingGuest) => {
setEditingBg(bg)
setBgForm({
first_name: bg.firstName,
last_name: bg.lastName,
middle_name: bg.middleName ?? undefined,
birth_date: bg.birthDate?.slice(0, 10) ?? undefined,
is_child: bg.isChild,
is_main: bg.isMain,
passport_series: bg.passportSeries ?? undefined,
passport_number: bg.passportNumber ?? undefined,
passport_issued_by: bg.passportIssuedBy ?? undefined,
passport_issue_date: bg.passportIssueDate?.slice(0, 10) ?? undefined,
nationality: bg.nationality ?? undefined,
})
setAcQuery('')
setAcResults([])
setShowGuestForm(true)
}
const cancelGuestForm = () => {
setShowGuestForm(false)
setEditingBg(null)
setBgForm({})
setAcResults([])
setAcQuery('')
}
const saveGuest = async () => {
if (!slug || !bgForm.first_name?.trim() || !bgForm.last_name?.trim()) {
showToast('Введите имя и фамилию')
return
}
setBgSaving(true)
try {
if (editingBg) {
const updated = await api.bookingGuests.update(slug, booking.id, editingBg.id, bgForm)
setBgGuests(prev => prev.map(g => g.id === editingBg.id ? updated : g))
} else {
const added = await api.bookingGuests.add(slug, booking.id, bgForm)
setBgGuests(prev => [...prev, added])
}
cancelGuestForm()
} catch {
showToast('Ошибка при сохранении гостя')
} finally {
setBgSaving(false)
}
}
const removeGuest = async (id: string) => {
if (!slug) return
try {
await api.bookingGuests.remove(slug, booking.id, id)
setBgGuests(prev => prev.filter(g => g.id !== id))
} catch {
showToast('Ошибка при удалении гостя')
}
}
const adultsWithoutDocs = bgGuests.filter(g => !g.isChild && (!g.passportSeries || !g.passportNumber))
// ── Date editing ─────────────────────────────────────────────────────────
const [editDates, setEditDates] = useState(false)
const [newCheckIn, setNewCheckIn] = useState(booking.checkIn)
const [newCheckOut, setNewCheckOut] = useState(booking.checkOut)
@@ -399,8 +543,217 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, onClose,
</div>
)}
{/* ── Гость ────────────────────────────────────────────────────────── */}
{tab === 'guest' && (
{/* ── Гости ────────────────────────────────────────────────────────── */}
{tab === 'guest' && slug && (
<div className="space-y-3">
{/* Require-docs warning */}
{requireDocs && adultsWithoutDocs.length > 0 && (
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 p-2.5 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangle size={13} className="shrink-0 mt-0.5" />
<span>У {adultsWithoutDocs.length} взрослого(-ых) не заполнены паспортные данные</span>
</div>
)}
{/* Loading */}
{bgLoading && (
<div className="flex items-center justify-center py-6">
<Loader2 size={20} className="animate-spin text-slate-400" />
</div>
)}
{/* Guest list */}
{!bgLoading && bgGuests.map(bg => (
<div key={bg.id} className="rounded-xl border border-slate-200 dark:border-slate-600 p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{bg.lastName} {bg.firstName}{bg.middleName ? ` ${bg.middleName}` : ''}
</span>
{bg.isMain && (
<span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-xs bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400">
<UserCheck size={10} /> Главный
</span>
)}
{bg.isChild && (
<span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-xs bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
<Baby size={10} /> Ребёнок
</span>
)}
</div>
{bg.passportSeries && bg.passportNumber ? (
<p className="text-xs text-slate-400 font-mono mt-0.5">
{bg.passportSeries} {bg.passportNumber}
</p>
) : !bg.isChild && requireDocs ? (
<p className="text-xs text-amber-500 mt-0.5">Паспорт не заполнен</p>
) : null}
{bg.birthDate && (
<p className="text-xs text-slate-400 mt-0.5">
{new Date(bg.birthDate).toLocaleDateString('ru-RU')}
</p>
)}
</div>
<div className="flex gap-1 shrink-0">
<button
onClick={() => openEditForm(bg)}
className="p-1.5 rounded-lg text-slate-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<Pencil size={13} />
</button>
<button
onClick={() => removeGuest(bg.id)}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Trash2 size={13} />
</button>
</div>
</div>
</div>
))}
{/* Empty state */}
{!bgLoading && bgGuests.length === 0 && !showGuestForm && (
<p className="text-xs text-center text-slate-400 py-4">Гости не добавлены</p>
)}
{/* Add guest form */}
{showGuestForm && (
<div className="rounded-xl border border-brand-200 dark:border-brand-700 bg-brand-50/30 dark:bg-brand-900/10 p-3 space-y-3">
<p className="text-xs font-semibold text-brand-700 dark:text-brand-400">
{editingBg ? 'Редактировать гостя' : 'Добавить гостя'}
</p>
{/* Autocomplete last name */}
<div ref={acWrapRef} className="relative">
<label className={lbl}>Фамилия *</label>
<div className="relative">
<input
className={fld}
value={bgForm.last_name ?? ''}
onChange={e => handleAcInput(e.target.value)}
placeholder="Иванов"
autoComplete="off"
/>
{acLoading && (
<Loader2 size={12} className="absolute right-2.5 top-1/2 -translate-y-1/2 animate-spin text-slate-400" />
)}
</div>
{acResults.length > 0 && (
<div className="absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-xl shadow-lg overflow-hidden max-h-48 overflow-y-auto">
{acResults.map(g => (
<button
key={g.id}
type="button"
onClick={() => selectAcGuest(g)}
className="w-full text-left px-3 py-2 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<div className="flex items-center gap-2">
<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}
</p>
{(g.passportSeries || g.passportNumber) && (
<p className="text-xs text-slate-400 font-mono">
{[g.passportSeries, g.passportNumber].filter(Boolean).join(' ')}
</p>
)}
</div>
</div>
</button>
))}
</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 */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className={lbl}>Дата рождения</label>
<input type="date" className={fld} value={bgForm.birth_date ?? ''} onChange={e => setBgForm(f => ({ ...f, birth_date: e.target.value }))} />
</div>
<div className="flex flex-col gap-2 pt-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={bgForm.is_child ?? false} onChange={e => setBgForm(f => ({ ...f, is_child: e.target.checked }))} className="rounded" />
<span className="text-xs text-slate-600 dark:text-slate-400">Ребёнок (до 16 лет)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={bgForm.is_main ?? false} onChange={e => setBgForm(f => ({ ...f, is_main: e.target.checked }))} className="rounded" />
<span className="text-xs text-slate-600 dark:text-slate-400">Главный гость</span>
</label>
</div>
</div>
{/* Passport (adults only) */}
{!bgForm.is_child && (
<div className="space-y-2 pt-2 border-t border-brand-100 dark:border-brand-800">
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400">Паспорт</p>
<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} />
</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} />
</div>
</div>
<div>
<label className={lbl}>Кем выдан</label>
<input className={fld} value={bgForm.passport_issued_by ?? ''} onChange={e => setBgForm(f => ({ ...f, passport_issued_by: e.target.value }))} placeholder="УФМС России по г. Москве" />
</div>
<div>
<label className={lbl}>Дата выдачи</label>
<input type="date" className={fld} value={bgForm.passport_issue_date ?? ''} onChange={e => setBgForm(f => ({ ...f, passport_issue_date: e.target.value }))} />
</div>
</div>
)}
{/* Scan + save/cancel */}
<button
onClick={() => showToast('Для сканирования подключите RFID/OCR сканер')}
className="w-full flex items-center justify-center gap-2 py-2 rounded-xl border border-dashed border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400 text-xs font-medium hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<ScanLine size={13} /> Сканировать паспорт
</button>
<div className="flex gap-2">
<button onClick={cancelGuestForm} className="btn-secondary flex-1 justify-center text-sm py-1.5">Отмена</button>
<button onClick={saveGuest} disabled={bgSaving} className="btn-primary flex-1 justify-center text-sm py-1.5">
{bgSaving ? <Loader2 size={13} className="animate-spin" /> : null}
{editingBg ? 'Сохранить' : 'Добавить'}
</button>
</div>
</div>
)}
{/* Add button */}
{!showGuestForm && (
<button
onClick={openAddForm}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 text-sm font-medium hover:border-brand-300 dark:hover:border-brand-700 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<Plus size={15} /> Добавить гостя
</button>
)}
</div>
)}
{/* Fallback Гость tab (no slug — old static form) */}
{tab === 'guest' && !slug && (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3">
<div>
@@ -422,7 +775,6 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, onClose,
<input type="date" className={fld} value={passport.dob} onChange={e => setP('dob', e.target.value)} />
</div>
</div>
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<p className="text-xs font-semibold text-slate-600 dark:text-slate-400 mb-3">Паспорт</p>
<div className="space-y-3">
@@ -450,14 +802,12 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, onClose,
</div>
</div>
</div>
<button
onClick={() => showToast('Для сканирования подключите RFID/OCR сканер')}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border-2 border-dashed border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400 text-sm font-medium hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<ScanLine size={16} /> Сканировать паспорт
</button>
<button
onClick={() => showToast('✓ Данные гостя сохранены')}
className="w-full btn-primary justify-center"