From 873a9a4fc40b744ec0ed927f7bb9886020c5c565 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Thu, 19 Mar 2026 20:53:07 +0300 Subject: [PATCH] Add multi-guest management: per-booking roster, docs, guest autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/migrations/010_booking_guests.sql | 28 ++ backend/src/app.ts | 6 +- backend/src/routes/booking-guests.ts | 244 ++++++++++++ backend/src/routes/hotel-settings.ts | 66 ++++ .../bookings/BookingDetailPanel.tsx | 368 +++++++++++++++++- src/components/calendar/BookingCalendar.tsx | 4 +- src/lib/api.ts | 63 +++ src/pages/BookingsPage.tsx | 1 + src/pages/CalendarPage.tsx | 1 + src/pages/SettingsPage.tsx | 33 ++ 10 files changed, 803 insertions(+), 11 deletions(-) create mode 100644 backend/migrations/010_booking_guests.sql create mode 100644 backend/src/routes/booking-guests.ts create mode 100644 backend/src/routes/hotel-settings.ts diff --git a/backend/migrations/010_booking_guests.sql b/backend/migrations/010_booking_guests.sql new file mode 100644 index 0000000..f2753e3 --- /dev/null +++ b/backend/migrations/010_booking_guests.sql @@ -0,0 +1,28 @@ +-- Migration 010 — booking_guests: per-booking guest roster with documents + +CREATE TABLE IF NOT EXISTS booking_guests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + guest_id UUID REFERENCES guests(id) ON DELETE SET NULL, + + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + middle_name VARCHAR(100), + + birth_date DATE, + is_child BOOLEAN NOT NULL DEFAULT false, + is_main BOOLEAN NOT NULL DEFAULT false, + + passport_series VARCHAR(20), + passport_number VARCHAR(20), + passport_issued_by TEXT, + passport_issue_date DATE, + nationality VARCHAR(100), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_booking_guests_booking ON booking_guests(booking_id); +CREATE INDEX IF NOT EXISTS idx_booking_guests_guest ON booking_guests(guest_id); diff --git a/backend/src/app.ts b/backend/src/app.ts index fad638b..a98fa70 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -16,7 +16,9 @@ import housekeepingRoutes from './routes/housekeeping' import channelsRoutes from './routes/channels' import usersRoutes from './routes/users' import netupRoutes from './routes/netup' -import guestsRoutes from './routes/guests' +import guestsRoutes from './routes/guests' +import bookingGuestsRoutes from './routes/booking-guests' +import hotelSettingsRoutes from './routes/hotel-settings' export async function buildApp() { const fastify = Fastify({ @@ -75,6 +77,8 @@ export async function buildApp() { await fastify.register(usersRoutes) await fastify.register(netupRoutes) await fastify.register(guestsRoutes) + await fastify.register(bookingGuestsRoutes) + await fastify.register(hotelSettingsRoutes) return fastify } diff --git a/backend/src/routes/booking-guests.ts b/backend/src/routes/booking-guests.ts new file mode 100644 index 0000000..279eb3b --- /dev/null +++ b/backend/src/routes/booking-guests.ts @@ -0,0 +1,244 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type Params = { Params: { slug: string; bookingId: string } } +type ParamsWithId = { Params: { slug: string; bookingId: string; id: string } } + +type GuestBody = Partial<{ + first_name: string; last_name: string; middle_name: string + birth_date: string; is_child: boolean; is_main: boolean + passport_series: string; passport_number: string + passport_issued_by: string; passport_issue_date: string + nationality: string +}> + +const bookingGuests: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + // ── GET /api/hotels/:slug/bookings/:bookingId/guests ───────────────────── + fastify.get( + '/api/hotels/:slug/bookings/:bookingId/guests', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, bookingId } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + `SELECT bg.* + FROM booking_guests bg + WHERE bg.booking_id = $1 AND bg.hotel_id = $2 + ORDER BY bg.is_main DESC, bg.is_child ASC, bg.created_at ASC`, + [bookingId, hotelId], + ) + return rows + }, + ) + + // ── POST /api/hotels/:slug/bookings/:bookingId/guests ───────────────────── + fastify.post( + '/api/hotels/:slug/bookings/:bookingId/guests', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, bookingId } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + // Verify booking belongs to hotel + const { rows: [bk] } = await db.query( + 'SELECT id FROM bookings WHERE id = $1 AND hotel_id = $2', + [bookingId, hotelId], + ) + if (!bk) return reply.code(404).send({ error: 'Booking not found' }) + + const b = request.body + let guestId: string | null = null + + // Auto-link or create guest profile if passport provided + if (b.passport_series && b.passport_number) { + const series = b.passport_series.trim() + const number = b.passport_number.trim() + + const { rows: existing } = await db.query( + `SELECT id FROM guests WHERE hotel_id = $1 AND passport_series = $2 AND passport_number = $3`, + [hotelId, series, number], + ) + + if (existing.length > 0) { + guestId = existing[0].id + await db.query( + `UPDATE guests SET first_name = $3, last_name = $4, updated_at = NOW() + WHERE id = $1 AND hotel_id = $2`, + [guestId, hotelId, b.first_name, b.last_name], + ) + } else { + const { rows: [ng] } = await db.query( + `INSERT INTO guests + (hotel_id, first_name, last_name, passport_series, passport_number, birth_date, nationality, notes, tags, rating) + VALUES ($1, $2, $3, $4, $5, $6, $7, '', '{}', 3) + RETURNING id`, + [hotelId, b.first_name, b.last_name, series, number, b.birth_date ?? null, b.nationality ?? null], + ) + guestId = ng.id + } + } else if (!b.is_child) { + // Create a minimal guest profile for adults even without passport + const { rows: [ng] } = await db.query( + `INSERT INTO guests (hotel_id, first_name, last_name, birth_date, notes, tags, rating) + VALUES ($1, $2, $3, $4, '', '{}', 3) + RETURNING id`, + [hotelId, b.first_name, b.last_name, b.birth_date ?? null], + ) + guestId = ng.id + } + + // If marking as main, clear previous main flag + if (b.is_main) { + await db.query( + `UPDATE booking_guests SET is_main = false WHERE booking_id = $1 AND hotel_id = $2`, + [bookingId, hotelId], + ) + } + + const { rows: [bg] } = await db.query( + `INSERT INTO booking_guests + (booking_id, hotel_id, guest_id, first_name, last_name, middle_name, + birth_date, is_child, is_main, passport_series, passport_number, + passport_issued_by, passport_issue_date, nationality) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) + RETURNING *`, + [ + bookingId, hotelId, guestId, + b.first_name, b.last_name, b.middle_name ?? null, + b.birth_date ?? null, b.is_child ?? false, b.is_main ?? false, + b.passport_series ?? null, b.passport_number ?? null, + b.passport_issued_by ?? null, b.passport_issue_date ?? null, + b.nationality ?? null, + ], + ) + + // If main guest, link to booking + if (b.is_main && guestId) { + await db.query( + `UPDATE bookings SET guest_id = $1, updated_at = NOW() WHERE id = $2 AND hotel_id = $3`, + [guestId, bookingId, hotelId], + ) + } + + return reply.code(201).send(bg) + }, + ) + + // ── PATCH /api/hotels/:slug/bookings/:bookingId/guests/:id ──────────────── + fastify.patch( + '/api/hotels/:slug/bookings/:bookingId/guests/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, bookingId, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const b = request.body + + // If setting is_main, clear other rows first + if (b.is_main) { + await db.query( + `UPDATE booking_guests SET is_main = false WHERE booking_id = $1 AND hotel_id = $2 AND id != $3`, + [bookingId, hotelId, id], + ) + } + + const sets: string[] = [] + const vals: unknown[] = [id, bookingId, hotelId] + let idx = 4 + + const add = (col: string, val: unknown) => { + if (val !== undefined) { sets.push(`${col} = $${idx++}`); vals.push(val) } + } + add('first_name', b.first_name) + add('last_name', b.last_name) + add('middle_name', b.middle_name) + add('birth_date', b.birth_date || null) + add('is_child', b.is_child) + add('is_main', b.is_main) + add('passport_series', b.passport_series) + add('passport_number', b.passport_number) + add('passport_issued_by', b.passport_issued_by) + add('passport_issue_date', b.passport_issue_date || null) + add('nationality', b.nationality) + + if (sets.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + sets.push('updated_at = NOW()') + + const { rows: [bg] } = await db.query( + `UPDATE booking_guests SET ${sets.join(', ')} + WHERE id = $1 AND booking_id = $2 AND hotel_id = $3 + RETURNING *`, + vals, + ) + if (!bg) return reply.code(404).send({ error: 'Not found' }) + + // Sync subset of fields to guest profile + if (bg.guest_id) { + const gSets: string[] = [] + const gVals: unknown[] = [bg.guest_id, hotelId] + let gi = 3 + const addG = (col: string, val: unknown) => { + if (val !== undefined) { gSets.push(`${col} = $${gi++}`); gVals.push(val) } + } + addG('first_name', b.first_name) + addG('last_name', b.last_name) + addG('passport_series', b.passport_series) + addG('passport_number', b.passport_number) + addG('birth_date', b.birth_date) + addG('nationality', b.nationality) + if (gSets.length > 0) { + gSets.push('updated_at = NOW()') + await db.query( + `UPDATE guests SET ${gSets.join(', ')} WHERE id = $1 AND hotel_id = $2`, + gVals, + ) + } + } + + return bg + }, + ) + + // ── DELETE /api/hotels/:slug/bookings/:bookingId/guests/:id ─────────────── + fastify.delete( + '/api/hotels/:slug/bookings/:bookingId/guests/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, bookingId, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + await db.query( + `DELETE FROM booking_guests WHERE id = $1 AND booking_id = $2 AND hotel_id = $3`, + [id, bookingId, hotelId], + ) + return reply.code(204).send() + }, + ) +} + +export default bookingGuests diff --git a/backend/src/routes/hotel-settings.ts b/backend/src/routes/hotel-settings.ts new file mode 100644 index 0000000..a5b6374 --- /dev/null +++ b/backend/src/routes/hotel-settings.ts @@ -0,0 +1,66 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } + +const hotelSettings: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + // ── GET /api/hotels/:slug/hotel-settings ───────────────────────────────── + fastify.get( + '/api/hotels/:slug/hotel-settings', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + 'SELECT key, value FROM hotel_settings WHERE hotel_id = $1', + [hotelId], + ) + const out: Record = {} + for (const row of rows) { + out[row.key] = row.value + } + return out + }, + ) + + // ── PATCH /api/hotels/:slug/hotel-settings ─────────────────────────────── + fastify.patch }>( + '/api/hotels/:slug/hotel-settings', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const updates = request.body + for (const [key, value] of Object.entries(updates)) { + await db.query( + `INSERT INTO hotel_settings (hotel_id, key, value, updated_at) + VALUES ($1, $2, $3::jsonb, NOW()) + ON CONFLICT (hotel_id, key) DO UPDATE + SET value = EXCLUDED.value, updated_at = NOW()`, + [hotelId, key, JSON.stringify(value)], + ) + } + return { ok: true } + }, + ) +} + +export default hotelSettings diff --git a/src/components/bookings/BookingDetailPanel.tsx b/src/components/bookings/BookingDetailPanel.tsx index c0c37c9..73d96d5 100644 --- a/src/components/bookings/BookingDetailPanel.tsx +++ b/src/components/bookings/BookingDetailPanel.tsx @@ -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) => void onBulkUpdate?: (updates: Array<{ id: string; data: Partial }>) => 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('booking') - // Date editing + // ── Multi-guest state ──────────────────────────────────────────────────── + const [bgGuests, setBgGuests] = useState([]) + const [bgLoading, setBgLoading] = useState(false) + const [bgLoaded, setBgLoaded] = useState(false) + const [requireDocs, setRequireDocs] = useState(false) + const [showGuestForm, setShowGuestForm] = useState(false) + const [editingBg, setEditingBg] = useState(null) + const [bgForm, setBgForm] = useState({}) + const [bgSaving, setBgSaving] = useState(false) + // Autocomplete + const [acQuery, setAcQuery] = useState('') + const [acResults, setAcResults] = useState([]) + const [acLoading, setAcLoading] = useState(false) + const acTimerRef = useRef | null>(null) + const acWrapRef = useRef(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, )} - {/* ── Гость ────────────────────────────────────────────────────────── */} - {tab === 'guest' && ( + {/* ── Гости ────────────────────────────────────────────────────────── */} + {tab === 'guest' && slug && ( +
+ {/* Require-docs warning */} + {requireDocs && adultsWithoutDocs.length > 0 && ( +
+ + У {adultsWithoutDocs.length} взрослого(-ых) не заполнены паспортные данные +
+ )} + + {/* Loading */} + {bgLoading && ( +
+ +
+ )} + + {/* Guest list */} + {!bgLoading && bgGuests.map(bg => ( +
+
+
+
+ + {bg.lastName} {bg.firstName}{bg.middleName ? ` ${bg.middleName}` : ''} + + {bg.isMain && ( + + Главный + + )} + {bg.isChild && ( + + Ребёнок + + )} +
+ {bg.passportSeries && bg.passportNumber ? ( +

+ {bg.passportSeries} {bg.passportNumber} +

+ ) : !bg.isChild && requireDocs ? ( +

Паспорт не заполнен

+ ) : null} + {bg.birthDate && ( +

+ {new Date(bg.birthDate).toLocaleDateString('ru-RU')} +

+ )} +
+
+ + +
+
+
+ ))} + + {/* Empty state */} + {!bgLoading && bgGuests.length === 0 && !showGuestForm && ( +

Гости не добавлены

+ )} + + {/* Add guest form */} + {showGuestForm && ( +
+

+ {editingBg ? 'Редактировать гостя' : 'Добавить гостя'} +

+ + {/* Autocomplete last name */} +
+ +
+ handleAcInput(e.target.value)} + placeholder="Иванов" + autoComplete="off" + /> + {acLoading && ( + + )} +
+ {acResults.length > 0 && ( +
+ {acResults.map(g => ( + + ))} +
+ )} +
+ + {/* First name + middle name */} +
+
+ + setBgForm(f => ({ ...f, first_name: e.target.value }))} placeholder="Иван" /> +
+
+ + setBgForm(f => ({ ...f, middle_name: e.target.value }))} placeholder="Иванович" /> +
+
+ + {/* Birth date + flags */} +
+
+ + setBgForm(f => ({ ...f, birth_date: e.target.value }))} /> +
+
+ + +
+
+ + {/* Passport (adults only) */} + {!bgForm.is_child && ( +
+

Паспорт

+
+
+ + setBgForm(f => ({ ...f, passport_series: e.target.value.toUpperCase() }))} placeholder="4510" maxLength={10} /> +
+
+ + setBgForm(f => ({ ...f, passport_number: e.target.value }))} placeholder="123456" maxLength={10} /> +
+
+
+ + setBgForm(f => ({ ...f, passport_issued_by: e.target.value }))} placeholder="УФМС России по г. Москве" /> +
+
+ + setBgForm(f => ({ ...f, passport_issue_date: e.target.value }))} /> +
+
+ )} + + {/* Scan + save/cancel */} + + +
+ + +
+
+ )} + + {/* Add button */} + {!showGuestForm && ( + + )} +
+ )} + + {/* Fallback Гость tab (no slug — old static form) */} + {tab === 'guest' && !slug && (
@@ -422,7 +775,6 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, onClose, setP('dob', e.target.value)} />
-

Паспорт

@@ -450,14 +802,12 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, onClose,
- -