Files
hotelsync/src/components/bookings/BookingDetailPanel.tsx
HotelSync 8282155237 Add AES-256-GCM encryption for sensitive personal data fields
- New backend/src/lib/crypto.ts: encrypt/decrypt (AES-256-GCM) + passportToken (HMAC-SHA256)
- Migration 012: birth_date/passport_issue_date → TEXT, add passport_search_token indexes
- guests.ts: encrypt passport_series/number/birth_date on write, decrypt on read,
  passport search via HMAC token (?passport= param) instead of plaintext LIKE
- booking-guests.ts: encrypt all sensitive fields, search token for passport lookup
- api.ts: guests.list() supports passport= param for exact passport lookup
- BookingDetailPanel: use passport param for passport-based autocomplete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 17:19:27 +03:00

1044 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
import { Badge } from '../ui/Badge'
import { MOCK_DISCOUNTS } from '../../pages/DiscountsPage'
// ─── Local types ──────────────────────────────────────────────────────────────
interface PassportData {
lastName: string; firstName: string; middleName: string
dob: string; series: string; number: string
issuedBy: string; issueDate: string; regAddress: string
}
interface Payment {
id: string; date: string; amount: number
method: 'cash' | 'card' | 'transfer'; note: string
}
type Tab = 'booking' | 'guest' | 'payment' | 'docs'
const TABS: { id: Tab; label: string }[] = [
{ id: 'booking', label: 'Бронь' },
{ id: 'guest', label: 'Гости' },
{ id: 'payment', label: 'Оплата' },
{ id: 'docs', label: 'Документы' },
]
const METHODS: { id: 'cash' | 'card' | 'transfer'; label: string; icon: React.ElementType }[] = [
{ id: 'cash', label: 'Наличные', icon: Banknote },
{ id: 'card', label: 'Карта', icon: CreditCard },
{ id: 'transfer', label: 'Перевод', icon: Building2 },
]
const DOCUMENTS = [
{ id: 'reg', label: 'Регистрационная карта', desc: 'Персональные данные гостя', icon: IdCard, always: true },
{ id: 'contract', label: 'Договор на проживание', desc: 'Договор между гостем и отелем', icon: FileText, always: true },
{ id: 'invoice', label: 'Счёт на оплату', desc: 'Счёт для оплаты проживания', icon: Receipt, always: true },
{ id: 'act', label: 'Акт об оказании услуг', desc: 'Закрывающий документ при выезде', icon: FileCheck, always: false },
]
// ─── Component ────────────────────────────────────────────────────────────────
interface BookingDetailPanelProps {
booking: Booking
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, slug, onClose, onUpdate, onBulkUpdate }: BookingDetailPanelProps) {
const [tab, setTab] = useState<Tab>('booking')
// ── 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)
// 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))
}, [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,
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(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, undefined, query)
.then(results => {
if (results.length === 1) selectAcGuest(results[0])
})
.catch(() => {/* ignore */})
}, 400)
}
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)
const [newRoomId, setNewRoomId] = useState(booking.roomId)
const [conflictBooking, setConflictBooking] = useState<Booking | null>(null)
const [conflictResolveRoomId, setConflictResolveRoomId] = useState('')
const canEditDates = booking.status === 'confirmed' || booking.status === 'inquiry'
const findConflict = (ci: string, co: string, rid: string): Booking | null =>
(allBookings ?? []).find(b =>
b.id !== booking.id &&
b.roomId === rid &&
b.status !== 'cancelled' &&
b.status !== 'no_show' &&
b.status !== 'checked_out' &&
b.checkIn < co && b.checkOut > ci,
) ?? null
const handleSaveDates = () => {
if (newCheckIn >= newCheckOut) { showToast('Дата выезда должна быть позже заезда'); return }
const conflict = findConflict(newCheckIn, newCheckOut, newRoomId)
if (conflict) { setConflictBooking(conflict); setConflictResolveRoomId(''); return }
onUpdate(booking.id, { checkIn: newCheckIn, checkOut: newCheckOut, roomId: newRoomId })
}
const handleForceUpdate = () =>
onUpdate(booking.id, { checkIn: newCheckIn, checkOut: newCheckOut, roomId: newRoomId })
const handleResolveWithRoomSwap = () => {
if (!conflictBooking || !onBulkUpdate || !conflictResolveRoomId) return
if (findConflict(conflictBooking.checkIn, conflictBooking.checkOut, conflictResolveRoomId)) {
showToast('Выбранный номер тоже занят на эти даты')
return
}
onBulkUpdate([
{ id: booking.id, data: { checkIn: newCheckIn, checkOut: newCheckOut, roomId: newRoomId } },
{ id: conflictBooking.id, data: { roomId: conflictResolveRoomId } },
])
}
// Passport data
const nameParts = booking.guestName.split(' ')
const [passport, setPassport] = useState<PassportData>({
lastName: nameParts[0] ?? '', firstName: nameParts[1] ?? '', middleName: nameParts[2] ?? '',
dob: '', series: '', number: '', issuedBy: '', issueDate: '', regAddress: '',
})
const setP = <K extends keyof PassportData>(k: K, v: PassportData[K]) =>
setPassport(prev => ({ ...prev, [k]: v }))
// Payments
const [payments, setPayments] = useState<Payment[]>(() =>
booking.paidAmount > 0
? [{ id: 'init', date: booking.createdAt, amount: booking.paidAmount, method: 'card', note: 'Предоплата при бронировании' }]
: []
)
const [showPayForm, setShowPayForm] = useState(false)
const [payAmount, setPayAmount] = useState('')
const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
const [payNote, setPayNote] = useState('')
// Discount
const [discountId, setDiscountId] = useState('')
// Toast
const [toast, setToast] = useState('')
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
// Calculations
const nights = nightsCount(booking.checkIn, booking.checkOut)
const baseTotal = booking.totalAmount
const activeDiscs = MOCK_DISCOUNTS.filter(d => d.isActive)
const selDiscount = activeDiscs.find(d => d.id === discountId)
const discountAmt = selDiscount
? selDiscount.valueType === 'percent'
? Math.round(baseTotal * Math.min(100, selDiscount.value) / 100)
: Math.min(baseTotal, selDiscount.value)
: 0
const finalTotal = baseTotal - discountAmt
const totalPaid = payments.reduce((s, p) => s + p.amount, 0)
const balance = finalTotal - totalPaid
const setStatus = (status: typeof booking.status) => onUpdate(booking.id, { status })
const addPayment = () => {
const amt = parseFloat(payAmount)
if (!amt || amt <= 0) return
setPayments(prev => [...prev, {
id: `p-${Date.now()}`,
date: new Date().toLocaleDateString('ru-RU'),
amount: amt, method: payMethod, note: payNote,
}])
setPayAmount(''); setPayNote('')
setShowPayForm(false)
showToast(`✓ Оплата ${formatCurrency(amt)} принята`)
}
const printDoc = (label: string) => showToast(`🖨 «${label}» отправлен на печать`)
const fld = 'input text-sm'
const lbl = 'block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1'
return (
<>
<div className="fixed inset-0 z-40" onClick={onClose} />
<div className="fixed right-0 top-0 bottom-0 z-50 w-full sm:w-[400px] bg-white dark:bg-slate-800 border-l border-slate-200 dark:border-slate-700 shadow-2xl flex flex-col">
{/* Toast notification */}
{toast && (
<div className="absolute top-3 left-4 right-4 z-20 bg-slate-900 dark:bg-slate-700 text-white text-sm rounded-xl px-4 py-2.5 text-center shadow-lg pointer-events-none">
{toast}
</div>
)}
{/* Header */}
<div className="px-5 pt-4 pb-3 border-b border-slate-200 dark:border-slate-700 shrink-0">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">
{booking.guestName}
</h3>
<div className="flex items-center gap-2 mt-1">
<Badge className={BOOKING_STATUS_BADGE[booking.status]}>
{BOOKING_STATUS_LABELS[booking.status]}
</Badge>
<Badge className={SOURCE_COLORS[booking.source]}>
{SOURCE_LABELS[booking.source]}
</Badge>
</div>
</div>
<button onClick={onClose} className="btn-ghost p-1.5 mt-0.5">
<X size={18} />
</button>
</div>
{/* Tab bar */}
<div className="flex gap-0.5 mt-3">
{TABS.map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
'flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors',
tab === t.id
? 'bg-brand-600 text-white'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700',
)}
>
{t.label}
</button>
))}
</div>
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{/* ── Бронь ────────────────────────────────────────────────────────── */}
{tab === 'booking' && (
<div className="space-y-4">
{room && (
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/40 p-3">
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-1">Номер</p>
<p className="font-semibold text-slate-900 dark:text-slate-100">
{room.number} {room.type}
</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
Этаж {room.floor} · {room.bedType} bed
</p>
</div>
)}
{/* Dates — read or edit mode */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1">
<Calendar size={12} /> Даты проживания
</p>
{!editDates && canEditDates && (
<button
onClick={() => { setEditDates(true); setConflictBooking(null) }}
className="flex items-center gap-1 text-xs text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300 font-medium"
>
<Pencil size={11} /> Изменить
</button>
)}
</div>
{editDates ? (
<div className="space-y-3 p-3 rounded-xl border border-brand-200 dark:border-brand-700 bg-brand-50/30 dark:bg-brand-900/10">
<div className="grid grid-cols-2 gap-3">
<div>
<label className={lbl}>Заезд</label>
<input type="date" className="input text-sm" value={newCheckIn}
onChange={e => { setNewCheckIn(e.target.value); setConflictBooking(null) }} />
</div>
<div>
<label className={lbl}>Выезд</label>
<input type="date" className="input text-sm" value={newCheckOut} min={newCheckIn}
onChange={e => { setNewCheckOut(e.target.value); setConflictBooking(null) }} />
</div>
</div>
{rooms && rooms.length > 1 && (
<div>
<label className={lbl}>Номер</label>
<select className="input text-sm" value={newRoomId}
onChange={e => { setNewRoomId(e.target.value); setConflictBooking(null) }}>
{rooms.map(r => (
<option key={r.id} value={r.id}>{r.number} {r.type}</option>
))}
</select>
</div>
)}
{/* Conflict warning */}
{conflictBooking && (
<div className="rounded-xl border border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/20 p-3 space-y-2">
<div className="flex items-start gap-2 text-red-700 dark:text-red-400 text-xs font-medium">
<AlertTriangle size={13} className="shrink-0 mt-0.5" />
<span>
Номер занят: {conflictBooking.guestName},&nbsp;
{fmtDate(conflictBooking.checkIn)} {fmtDate(conflictBooking.checkOut)}
</span>
</div>
{conflictBooking.status !== 'checked_in' && onBulkUpdate && rooms && (
<div className="space-y-1.5">
<p className="text-xs text-red-600 dark:text-red-400">
Переместить {conflictBooking.guestName} в другой номер:
</p>
<select className="input text-sm" value={conflictResolveRoomId}
onChange={e => setConflictResolveRoomId(e.target.value)}>
<option value=""> выберите номер </option>
{rooms.filter(r => r.id !== newRoomId).map(r => (
<option key={r.id} value={r.id}>{r.number} {r.type}</option>
))}
</select>
<button
disabled={!conflictResolveRoomId}
onClick={handleResolveWithRoomSwap}
className="w-full btn-primary text-xs py-1.5 justify-center disabled:opacity-50 disabled:cursor-not-allowed"
>
Переместить и сохранить
</button>
</div>
)}
<button onClick={handleForceUpdate}
className="w-full btn-secondary text-xs py-1.5 justify-center text-red-600 dark:text-red-400 border-red-200 dark:border-red-700">
Сохранить принудительно
</button>
</div>
)}
<div className="flex gap-2">
<button
onClick={() => { setEditDates(false); setConflictBooking(null) }}
className="btn-secondary flex-1 justify-center text-sm py-1.5"
>
Отмена
</button>
{!conflictBooking && (
<button onClick={handleSaveDates}
className="btn-primary flex-1 justify-center text-sm py-1.5">
Сохранить
</button>
)}
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-1">Заезд</p>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{fmtDate(booking.checkIn)}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-1">Выезд</p>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{fmtDate(booking.checkOut)}
</p>
</div>
</div>
)}
{!editDates && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}
</p>
)}
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1 mb-1">
<Users size={12} /> Гости
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{booking.adults} взр.{booking.children > 0 ? ` · ${booking.children} дет.` : ''}
</p>
</div>
{booking.guestEmail && (
<div className="space-y-1.5">
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium">Контакты</p>
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<Mail size={13} className="text-slate-400" />
{booking.guestEmail}
</div>
</div>
)}
{booking.notes && (
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1 mb-1">
<Tag size={12} /> Примечания
</p>
<p className="text-sm text-slate-700 dark:text-slate-300 bg-slate-50 dark:bg-slate-700/40 rounded-lg p-2.5">
{booking.notes}
</p>
</div>
)}
{booking.channelBookingId && (
<div>
<p className="text-xs text-slate-500 mb-0.5">ID канала</p>
<code className="text-xs text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20 px-2 py-1 rounded">
{booking.channelBookingId}
</code>
</div>
)}
</div>
)}
{/* ── Гости ────────────────────────────────────────────────────────── */}
{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 => 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 => handlePassportInput('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>
<label className={lbl}>Фамилия</label>
<input className={fld} value={passport.lastName} onChange={e => setP('lastName', e.target.value)} placeholder="Иванов" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={lbl}>Имя</label>
<input className={fld} value={passport.firstName} onChange={e => setP('firstName', e.target.value)} placeholder="Иван" />
</div>
<div>
<label className={lbl}>Отчество</label>
<input className={fld} value={passport.middleName} onChange={e => setP('middleName', e.target.value)} placeholder="Иванович" />
</div>
</div>
<div>
<label className={lbl}>Дата рождения</label>
<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">
<div className="grid grid-cols-2 gap-3">
<div>
<label className={lbl}>Серия</label>
<input className={fld} value={passport.series} onChange={e => setP('series', e.target.value)} placeholder="4510" maxLength={4} />
</div>
<div>
<label className={lbl}>Номер</label>
<input className={fld} value={passport.number} onChange={e => setP('number', e.target.value)} placeholder="123456" maxLength={6} />
</div>
</div>
<div>
<label className={lbl}>Кем выдан</label>
<input className={fld} value={passport.issuedBy} onChange={e => setP('issuedBy', e.target.value)} placeholder="УФМС России по г. Москве" />
</div>
<div>
<label className={lbl}>Дата выдачи</label>
<input type="date" className={fld} value={passport.issueDate} onChange={e => setP('issueDate', e.target.value)} />
</div>
<div>
<label className={lbl}>Адрес регистрации</label>
<input className={fld} value={passport.regAddress} onChange={e => setP('regAddress', e.target.value)} placeholder="г. Москва, ул. Примерная, д. 1" />
</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"
>
Сохранить данные
</button>
</div>
)}
{/* ── Оплата ───────────────────────────────────────────────────────── */}
{tab === 'payment' && (
<div className="space-y-4">
{/* Discount selector */}
<div>
<label className={lbl}>Скидка</label>
<select className="input text-sm" value={discountId} onChange={e => setDiscountId(e.target.value)}>
<option value=""> Без скидки </option>
{activeDiscs.map(d => (
<option key={d.id} value={d.id}>
{d.name} ({d.valueType === 'percent' ? `${d.value}%` : formatCurrency(d.value)})
</option>
))}
</select>
</div>
{/* Breakdown */}
<div className="rounded-xl border border-slate-200 dark:border-slate-600 divide-y divide-slate-200 dark:divide-slate-600 text-sm">
<div className="flex justify-between px-3 py-2">
<span className="text-slate-500 dark:text-slate-400">Стоимость</span>
<span className="font-medium text-slate-900 dark:text-slate-100">{formatCurrency(baseTotal)}</span>
</div>
{discountAmt > 0 && (
<div className="flex justify-between px-3 py-2">
<span className="text-slate-500 dark:text-slate-400">Скидка «{selDiscount?.name}»</span>
<span className="font-medium text-emerald-600 dark:text-emerald-400">{formatCurrency(discountAmt)}</span>
</div>
)}
<div className="flex justify-between px-3 py-2 font-semibold">
<span className="text-slate-700 dark:text-slate-300">Итого</span>
<span className="text-slate-900 dark:text-slate-100">{formatCurrency(finalTotal)}</span>
</div>
<div className="flex justify-between px-3 py-2">
<span className="text-slate-500 dark:text-slate-400">Оплачено</span>
<span className="font-medium text-emerald-600 dark:text-emerald-400">{formatCurrency(totalPaid)}</span>
</div>
<div className={cn(
'flex justify-between px-3 py-2 font-bold',
balance > 0 ? 'text-red-600 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400',
)}>
<span>{balance > 0 ? 'Остаток' : 'Переплата'}</span>
<span>{balance > 0 ? formatCurrency(balance) : `+${formatCurrency(-balance)}`}</span>
</div>
</div>
{/* Accept payment */}
{!showPayForm ? (
<button
onClick={() => { setPayAmount(balance > 0 ? String(balance) : ''); setShowPayForm(true) }}
className="w-full btn-primary justify-center"
disabled={balance <= 0}
>
<Plus size={15} /> Принять оплату
</button>
) : (
<div className="rounded-xl border border-brand-200 dark:border-brand-700 p-3 space-y-3 bg-brand-50/40 dark:bg-brand-900/10">
<p className="text-xs font-semibold text-brand-700 dark:text-brand-400">Новый платёж</p>
<div>
<label className={lbl}>Сумма, </label>
<input type="number" className="input text-sm" value={payAmount}
onChange={e => setPayAmount(e.target.value)} placeholder="0" min="0" />
</div>
<div>
<label className={lbl}>Способ оплаты</label>
<div className="grid grid-cols-3 gap-1.5">
{METHODS.map(m => (
<button
key={m.id} type="button" onClick={() => setPayMethod(m.id)}
className={cn(
'flex flex-col items-center gap-1 py-2 rounded-lg border text-xs font-medium transition-colors',
payMethod === m.id
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
<m.icon size={14} /> {m.label}
</button>
))}
</div>
</div>
<div>
<label className={lbl}>Комментарий</label>
<input className="input text-sm" value={payNote}
onChange={e => setPayNote(e.target.value)} placeholder="Необязательно" />
</div>
<div className="flex gap-2">
<button onClick={() => setShowPayForm(false)} className="btn-secondary flex-1 justify-center text-sm py-2">Отмена</button>
<button onClick={addPayment} disabled={!payAmount || parseFloat(payAmount) <= 0}
className="btn-primary flex-1 justify-center text-sm py-2">Принять</button>
</div>
</div>
)}
{/* Payment history */}
{payments.length > 0 && (
<div>
<p className={lbl + ' mt-1'}>История платежей</p>
<div className="space-y-1.5">
{payments.map(p => {
const M = METHODS.find(m => m.id === p.method)!
return (
<div key={p.id} className="flex items-center gap-2 text-sm px-2.5 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/40">
<M.icon size={13} className="text-slate-400 shrink-0" />
<span className="text-slate-400 text-xs shrink-0">{p.date}</span>
<span className="flex-1 text-slate-600 dark:text-slate-300 text-xs truncate">{p.note || M.label}</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">{formatCurrency(p.amount)}</span>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{/* ── Документы ────────────────────────────────────────────────────── */}
{tab === 'docs' && (
<div className="space-y-2">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-3">
Распечатайте документы для гостя
</p>
{DOCUMENTS
.filter(d => d.always || booking.status === 'checked_in' || booking.status === 'checked_out')
.map(doc => (
<div key={doc.id} className="flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-600 hover:border-brand-300 dark:hover:border-brand-600 transition-colors">
<div className="w-9 h-9 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center shrink-0">
<doc.icon size={17} className="text-slate-500 dark:text-slate-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{doc.label}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{doc.desc}</p>
</div>
<button
onClick={() => printDoc(doc.label)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-xs font-medium hover:bg-brand-700 transition-colors shrink-0"
>
<Printer size={12} /> Печать
</button>
</div>
))
}
<div className="pt-3 mt-1 border-t border-slate-100 dark:border-slate-700">
<button
onClick={() => showToast('🖨 Комплект документов отправлен на печать')}
className="w-full btn-primary justify-center"
>
<Printer size={15} /> Напечатать комплект
</button>
<p className="text-xs text-center text-slate-400 dark:text-slate-500 mt-1.5">
Состав комплекта настраивается в разделе «Документы»
</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="shrink-0 px-5 py-4 border-t border-slate-200 dark:border-slate-700 space-y-2">
{balance > 0 && booking.status === 'confirmed' && (
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700">
<AlertTriangle size={14} className="text-amber-500 shrink-0" />
<p className="text-xs text-amber-700 dark:text-amber-400">
Долг {formatCurrency(balance)} получите оплату перед заселением
</p>
</div>
)}
{booking.status === 'confirmed' && (
<button onClick={() => setStatus('checked_in')} className="w-full btn-primary justify-center">
<CheckCircle size={15} /> Заселить
</button>
)}
{booking.status === 'checked_in' && (
<button onClick={() => setStatus('checked_out')} className="w-full btn-primary justify-center bg-emerald-600 hover:bg-emerald-700">
<CheckCircle size={15} /> Выселить
</button>
)}
{(booking.status === 'confirmed' || booking.status === 'inquiry') && (
<button
onClick={() => setStatus('cancelled')}
className="w-full btn-secondary justify-center text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<XCircle size={15} /> Отменить
</button>
)}
<p className="text-xs text-center text-slate-400 dark:text-slate-500">
ID: {booking.id} · Создано: {booking.createdAt}
</p>
</div>
</div>
</>
)
}