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:
28
backend/migrations/010_booking_guests.sql
Normal file
28
backend/migrations/010_booking_guests.sql
Normal file
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
244
backend/src/routes/booking-guests.ts
Normal file
244
backend/src/routes/booking-guests.ts
Normal file
@@ -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<string | null> => {
|
||||
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<Params>(
|
||||
'/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<Params & { Body: GuestBody & { first_name: string; last_name: string } }>(
|
||||
'/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<ParamsWithId & { Body: GuestBody }>(
|
||||
'/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<ParamsWithId>(
|
||||
'/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
|
||||
66
backend/src/routes/hotel-settings.ts
Normal file
66
backend/src/routes/hotel-settings.ts
Normal file
@@ -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<string | null> => {
|
||||
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<SlugParam>(
|
||||
'/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<string, unknown> = {}
|
||||
for (const row of rows) {
|
||||
out[row.key] = row.value
|
||||
}
|
||||
return out
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/hotel-settings ───────────────────────────────
|
||||
fastify.patch<SlugParam & { Body: Record<string, unknown> }>(
|
||||
'/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
|
||||
@@ -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"
|
||||
|
||||
@@ -21,6 +21,7 @@ export type BookingLock = { checkIn: string; checkOut: string; lockedBy: string
|
||||
interface BookingCalendarProps {
|
||||
rooms: Room[]
|
||||
bookings: Booking[]
|
||||
slug?: string
|
||||
onBookingCreate: (b: Partial<Booking>) => void
|
||||
onBookingUpdate: (id: string, b: Partial<Booking>) => void
|
||||
onBookingBulkUpdate?: (updates: Array<{ id: string; data: Partial<Booking> }>) => void
|
||||
@@ -55,7 +56,7 @@ function getRoomTypeColor(type: string): string {
|
||||
return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
|
||||
}
|
||||
|
||||
export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected }: BookingCalendarProps) {
|
||||
export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected }: BookingCalendarProps) {
|
||||
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
|
||||
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
|
||||
|
||||
@@ -727,6 +728,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
||||
room={rooms.find(r => r.id === selectedBooking.roomId)}
|
||||
rooms={rooms}
|
||||
allBookings={bookings}
|
||||
slug={slug}
|
||||
onClose={() => setSelectedBooking(null)}
|
||||
onUpdate={(id, data) => {
|
||||
onBookingUpdate(id, data)
|
||||
|
||||
@@ -266,6 +266,30 @@ export const api = {
|
||||
req<Hotel>('PATCH', `/api/hotels/${slug}`, toHotelPayload(data)),
|
||||
},
|
||||
|
||||
// ── Booking Guests ────────────────────────────────────────────────────────
|
||||
bookingGuests: {
|
||||
list: (slug: string, bookingId: string) =>
|
||||
req<BookingGuest[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/guests`),
|
||||
|
||||
add: (slug: string, bookingId: string, data: BookingGuestPayload) =>
|
||||
req<BookingGuest>('POST', `/api/hotels/${slug}/bookings/${bookingId}/guests`, data),
|
||||
|
||||
update: (slug: string, bookingId: string, id: string, data: BookingGuestPayload) =>
|
||||
req<BookingGuest>('PATCH', `/api/hotels/${slug}/bookings/${bookingId}/guests/${id}`, data),
|
||||
|
||||
remove: (slug: string, bookingId: string, id: string) =>
|
||||
req<void>('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/guests/${id}`),
|
||||
},
|
||||
|
||||
// ── Hotel Settings ────────────────────────────────────────────────────────
|
||||
hotelSettings: {
|
||||
get: (slug: string) =>
|
||||
req<HotelSettings>('GET', `/api/hotels/${slug}/hotel-settings`),
|
||||
|
||||
update: (slug: string, data: Partial<HotelSettings>) =>
|
||||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/hotel-settings`, data),
|
||||
},
|
||||
|
||||
// ── Guests ────────────────────────────────────────────────────────────────
|
||||
guests: {
|
||||
list: (slug: string, q?: string) =>
|
||||
@@ -391,6 +415,45 @@ export interface HotelPayload {
|
||||
checkInTime?: string; checkOutTime?: string
|
||||
}
|
||||
|
||||
export interface BookingGuest {
|
||||
id: string
|
||||
bookingId: string
|
||||
hotelId: string
|
||||
guestId: string | null
|
||||
firstName: string
|
||||
lastName: string
|
||||
middleName: string | null
|
||||
birthDate: string | null
|
||||
isChild: boolean
|
||||
isMain: boolean
|
||||
passportSeries: string | null
|
||||
passportNumber: string | null
|
||||
passportIssuedBy: string | null
|
||||
passportIssueDate: string | null
|
||||
nationality: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface BookingGuestPayload {
|
||||
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
|
||||
}
|
||||
|
||||
export interface HotelSettings {
|
||||
require_guest_docs?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface GuestApiType {
|
||||
id: string
|
||||
hotelId: string
|
||||
|
||||
@@ -444,6 +444,7 @@ export function BookingsPage() {
|
||||
room={room(selected.roomId)}
|
||||
rooms={rooms}
|
||||
allBookings={bookings}
|
||||
slug={slug}
|
||||
onClose={() => setSelected(null)}
|
||||
onUpdate={handleUpdateBooking}
|
||||
onBulkUpdate={handleBulkUpdate}
|
||||
|
||||
@@ -133,6 +133,7 @@ export function CalendarPage() {
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<BookingCalendar
|
||||
slug={slug}
|
||||
rooms={rooms}
|
||||
bookings={bookings}
|
||||
onBookingCreate={handleCreate}
|
||||
|
||||
@@ -75,6 +75,28 @@ export function SettingsPage() {
|
||||
// Booking / assignment settings
|
||||
const [assignmentStrategy, setAssignmentStrategy] = useState<'spread' | 'together' | 'sequential' | 'manual'>('spread')
|
||||
const [showBookingSource, setShowBookingSource] = useState(false)
|
||||
const [requireGuestDocs, setRequireGuestDocs] = useState(false)
|
||||
const [hotelSettingsLoaded, setHotelSettingsLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug || hotelSettingsLoaded) return
|
||||
api.hotelSettings.get(slug)
|
||||
.then(s => {
|
||||
setRequireGuestDocs(Boolean(s.require_guest_docs))
|
||||
setHotelSettingsLoaded(true)
|
||||
})
|
||||
.catch(console.error)
|
||||
}, [slug, hotelSettingsLoaded])
|
||||
|
||||
const toggleRequireGuestDocs = async () => {
|
||||
const next = !requireGuestDocs
|
||||
setRequireGuestDocs(next)
|
||||
try {
|
||||
await api.hotelSettings.update(slug, { require_guest_docs: next })
|
||||
} catch {
|
||||
setRequireGuestDocs(!next)
|
||||
}
|
||||
}
|
||||
|
||||
// Guest settings
|
||||
const [guestTags, setGuestTags] = useState([
|
||||
@@ -266,6 +288,17 @@ export function SettingsPage() {
|
||||
<Toggle on={showBookingSource} onChange={() => setShowBookingSource(p => !p)} />
|
||||
</div>
|
||||
|
||||
{/* Require guest docs toggle */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Обязательное заполнение документов гостей при заселении</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Показывает предупреждение, если у совершеннолетних гостей не заполнены паспортные данные
|
||||
</p>
|
||||
</div>
|
||||
<Toggle on={requireGuestDocs} onChange={toggleRequireGuestDocs} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Стратегия автоматического расселения
|
||||
|
||||
Reference in New Issue
Block a user