From 84cfca500cd0de40bf0892cddc7b6a8f65d6963a Mon Sep 17 00:00:00 2001 From: HotelSync Date: Thu, 19 Mar 2026 21:15:37 +0300 Subject: [PATCH] BookingModal: replace guestName with FIO+phone fields, remove passport tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add lastName/firstName/middleName/phone fields to booking form - Remove 'Документы гостя' tab entirely from BookingModal - Auto-parse existing guestName into FIO parts when editing - Add guestPhone to Booking type and propagate through CalendarPage/BookingsPage API calls - Add middle_name to guests table (migration 011) - Update guests backend route to include middle_name in all queries - Add middleName to GuestApiType in api.ts - Fix selectAcGuest in BookingDetailPanel to also fill middle_name - Auto-open guest form pre-filled with booking FIO when no guests exist - Add passport series+number lookup in BookingDetailPanel guest form Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/011_guests_middle_name.sql | 3 + backend/src/routes/guests.ts | 26 +- .../bookings/BookingDetailPanel.tsx | 36 +- src/components/bookings/BookingModal.tsx | 312 ++++-------------- src/lib/api.ts | 2 + src/pages/BookingsPage.tsx | 4 +- src/pages/CalendarPage.tsx | 2 +- src/types/index.ts | 1 + 8 files changed, 127 insertions(+), 259 deletions(-) create mode 100644 backend/migrations/011_guests_middle_name.sql diff --git a/backend/migrations/011_guests_middle_name.sql b/backend/migrations/011_guests_middle_name.sql new file mode 100644 index 0000000..068e23e --- /dev/null +++ b/backend/migrations/011_guests_middle_name.sql @@ -0,0 +1,3 @@ +-- Add middle_name to guests table for autocomplete support +ALTER TABLE guests + ADD COLUMN IF NOT EXISTS middle_name VARCHAR(100); diff --git a/backend/src/routes/guests.ts b/backend/src/routes/guests.ts index c92c964..21904c3 100644 --- a/backend/src/routes/guests.ts +++ b/backend/src/routes/guests.ts @@ -5,7 +5,7 @@ type SlugParam = { Params: { slug: string } } type SlugIdParam = { Params: { slug: string; id: string } } const GUEST_FIELDS = ` - g.id, g.hotel_id, g.first_name, g.last_name, g.email, g.phone, + g.id, g.hotel_id, g.first_name, g.last_name, g.middle_name, g.email, g.phone, g.passport, g.passport_series, g.passport_number, g.birth_date, g.nationality, g.gender, g.city, g.notes, g.tags, g.loyalty_tier, g.loyalty_points, g.rating, @@ -111,7 +111,7 @@ const guests: FastifyPluginAsync = async (fastify) => { // ── POST /api/hotels/:slug/guests ──────────────────────────────────────────── // Creates a new guest or returns existing one if passport matches fastify.post { if (existing.length > 0) { const { rows: [updated] } = await db.query( `UPDATE guests SET - first_name = COALESCE($3, first_name), - last_name = COALESCE($4, last_name), - email = COALESCE($5, email), - phone = COALESCE($6, phone), - updated_at = NOW() + first_name = COALESCE($3, first_name), + last_name = COALESCE($4, last_name), + middle_name = COALESCE($5, middle_name), + email = COALESCE($6, email), + phone = COALESCE($7, phone), + updated_at = NOW() WHERE id = $1 AND hotel_id = $2 RETURNING *`, - [existing[0].id, hotelId, b.first_name || null, b.last_name || null, b.email || null, b.phone || null], + [existing[0].id, hotelId, b.first_name || null, b.last_name || null, b.middle_name || null, b.email || null, b.phone || null], ) return reply.code(200).send(updated) } @@ -153,13 +154,13 @@ const guests: FastifyPluginAsync = async (fastify) => { const { rows: [guest] } = await db.query( `INSERT INTO guests - (hotel_id, first_name, last_name, email, phone, + (hotel_id, first_name, last_name, middle_name, email, phone, passport, passport_series, passport_number, birth_date, nationality, gender, city, notes, tags, rating) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) RETURNING *`, [ - hotelId, b.first_name, b.last_name, + hotelId, b.first_name, b.last_name, b.middle_name ?? null, b.email ?? null, b.phone ?? null, b.passport ?? null, b.passport_series ?? null, b.passport_number ?? null, b.birth_date ?? null, b.nationality ?? null, b.gender ?? null, b.city ?? null, @@ -172,7 +173,7 @@ const guests: FastifyPluginAsync = async (fastify) => { // ── PATCH /api/hotels/:slug/guests/:id ────────────────────────────────────── fastify.patch { } add('first_name', b.first_name) add('last_name', b.last_name) + add('middle_name', b.middle_name) add('email', b.email) add('phone', b.phone) add('passport', b.passport) diff --git a/src/components/bookings/BookingDetailPanel.tsx b/src/components/bookings/BookingDetailPanel.tsx index 73d96d5..0425338 100644 --- a/src/components/bookings/BookingDetailPanel.tsx +++ b/src/components/bookings/BookingDetailPanel.tsx @@ -96,6 +96,17 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on setBgGuests(guests) setRequireDocs(Boolean((settings as { require_guest_docs?: boolean }).require_guest_docs)) setBgLoaded(true) + // Auto-open add form pre-filled with booking FIO when no guests yet + if (guests.length === 0 && booking.status !== 'cancelled') { + const parts = (booking.guestName ?? '').split(' ') + setBgForm({ + last_name: parts[0] ?? '', + first_name: parts[1] ?? '', + middle_name: parts.slice(2).join(' ') || undefined, + is_main: true, + }) + setShowGuestForm(true) + } }) .catch(console.error) .finally(() => setBgLoading(false)) @@ -130,15 +141,34 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on ...f, first_name: g.firstName, last_name: g.lastName, + middle_name: g.middleName ?? undefined, birth_date: g.birthDate ?? undefined, passport_series: g.passportSeries ?? undefined, passport_number: g.passportNumber ?? undefined, nationality: g.nationality ?? undefined, })) - setAcQuery('') + setAcQuery(g.lastName) setAcResults([]) } + const passportTimerRef = useRef | null>(null) + + const handlePassportInput = (field: 'passport_series' | 'passport_number', val: string) => { + setBgForm(f => ({ ...f, [field]: val })) + if (passportTimerRef.current) clearTimeout(passportTimerRef.current) + const series = field === 'passport_series' ? val : (bgForm.passport_series ?? '') + const number = field === 'passport_number' ? val : (bgForm.passport_number ?? '') + const query = (series + number).trim() + if (query.length < 4 || !slug) return + passportTimerRef.current = setTimeout(() => { + api.guests.list(slug, query) + .then(results => { + if (results.length === 1) selectAcGuest(results[0]) + }) + .catch(() => {/* ignore */}) + }, 400) + } + const openAddForm = () => { setEditingBg(null) setBgForm({ is_main: bgGuests.length === 0 }) @@ -704,11 +734,11 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
- setBgForm(f => ({ ...f, passport_series: e.target.value.toUpperCase() }))} placeholder="4510" maxLength={10} /> + handlePassportInput('passport_series', e.target.value.toUpperCase())} placeholder="4510" maxLength={10} />
- setBgForm(f => ({ ...f, passport_number: e.target.value }))} placeholder="123456" maxLength={10} /> + handlePassportInput('passport_number', e.target.value)} placeholder="123456" maxLength={10} />
diff --git a/src/components/bookings/BookingModal.tsx b/src/components/bookings/BookingModal.tsx index 3d45ede..7f3e447 100644 --- a/src/components/bookings/BookingModal.tsx +++ b/src/components/bookings/BookingModal.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { format } from 'date-fns' -import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, IdCard, CalendarDays, Tag, ScanLine, Minus, BedDouble, Tv2, Send, Loader2, CheckCircle2 } from 'lucide-react' +import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, Tag, Minus, BedDouble, Tv2, Send, Loader2, CheckCircle2 } from 'lucide-react' import { MOCK_DISCOUNTS } from '../../pages/DiscountsPage' import type { Discount } from '../../pages/DiscountsPage' import { Modal } from '../ui/Modal' @@ -11,37 +11,6 @@ import { useModules } from '../../contexts/ModulesContext' import { useAuth } from '../../contexts/AuthContext' import { api } from '../../lib/api' -type DocType = 'rf_passport' | 'foreign_passport' | 'other' - -interface PassportData { - docType: DocType - lastName: string - firstName: string - patronymic: string - birthDate: string - birthPlace: string - nationality: string - series: string - number: string - issuedBy: string - issuedDate: string - divisionCode: string - registrationAddress: string -} - -const EMPTY_PASSPORT: PassportData = { - docType: 'rf_passport', - lastName: '', firstName: '', patronymic: '', - birthDate: '', birthPlace: '', nationality: 'Россия', - series: '', number: '', issuedBy: '', issuedDate: '', - divisionCode: '', registrationAddress: '', -} - -const DOC_TYPES: { id: DocType; label: string }[] = [ - { id: 'rf_passport', label: 'Паспорт РФ' }, - { id: 'foreign_passport', label: 'Загранпаспорт' }, - { id: 'other', label: 'Иной документ' }, -] const GUEST_TAGS = ['VIP', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания'] @@ -110,16 +79,20 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav } } + const _nameParts = (existing?.guestName ?? '').split(' ') const [form, setForm] = useState({ - roomId: existing?.roomId ?? draft.roomId, - guestName: existing?.guestName ?? '', - guestEmail:existing?.guestEmail ?? '', - checkIn: existing?.checkIn ?? draft.checkIn, - checkOut: existing?.checkOut ?? draft.checkOut, - adults: existing?.adults ?? 2, - children: existing?.children ?? 0, - status: (existing?.status ?? 'confirmed') as BookingStatus, - notes: existing?.notes ?? '', + roomId: existing?.roomId ?? draft.roomId, + lastName: _nameParts[0] ?? '', + firstName: _nameParts[1] ?? '', + middleName: _nameParts.slice(2).join(' '), + phone: existing?.guestPhone ?? '', + guestEmail: existing?.guestEmail ?? '', + checkIn: existing?.checkIn ?? draft.checkIn, + checkOut: existing?.checkOut ?? draft.checkOut, + adults: existing?.adults ?? 2, + children: existing?.children ?? 0, + status: (existing?.status ?? 'confirmed') as BookingStatus, + notes: existing?.notes ?? '', }) const [guestTags, setGuestTags] = useState([]) const [paymentMethod, setPaymentMethod] = useState<'cash' | 'terminal' | null>(null) @@ -127,17 +100,10 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav const [showFloorMap, setShowFloorMap] = useState(false) const [addedServices, setAddedServices] = useState([]) const [selectedServiceId, setSelectedServiceId] = useState(ADDITIONAL_SERVICES[0].id) - const [activeTab, setActiveTab] = useState<'booking' | 'passport'>('booking') - const [passport, setPassport] = useState(EMPTY_PASSPORT) const [selectedDiscountId, setSelectedDiscountId] = useState('') const activeDiscounts = MOCK_DISCOUNTS.filter(d => d.isActive) const selectedDiscount: Discount | undefined = activeDiscounts.find(d => d.id === selectedDiscountId) - const setP = (k: K, v: PassportData[K]) => - setPassport(prev => ({ ...prev, [k]: v })) - - const passportFilled = !!(passport.lastName || passport.number) - // Hourly booking state const [isHourly, setIsHourly] = useState(false) const [hourlyDate, setHourlyDate] = useState(draft.checkIn) @@ -190,13 +156,14 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav setGuestTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]) const handleSave = () => { - if (!form.guestName) return + if (!form.lastName || !form.firstName) return if (isHourly && room?.allowHourly) { if (!hourlyDate) return } else { if (!form.checkIn || !form.checkOut) return } + const guestName = [form.lastName, form.firstName, form.middleName].filter(Boolean).join(' ') const hourlyPrefix = isHourly && room?.allowHourly ? `[Почасово: ${String(startHour).padStart(2, '0')}:00–${String(endHour).padStart(2, '0')}:00] ` : '' @@ -206,17 +173,23 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav const checkOut = isHourly && room?.allowHourly ? hourlyDate : form.checkOut onSave({ - ...form, - source: 'direct', + roomId: form.roomId, + guestName, + guestEmail: form.guestEmail, + guestPhone: form.phone, checkIn, checkOut, - notes: hourlyPrefix + extraBedsPrefix + form.notes, + adults: form.adults, + children: form.children, + status: form.status, + notes: hourlyPrefix + extraBedsPrefix + form.notes, totalAmount: total, paidAmount, id: existing?.id ?? `b-${Date.now()}`, hotelId: 'hotel-1', guestId: existing?.guestId ?? `g-${Date.now()}`, createdAt: existing?.createdAt ?? format(new Date(), 'yyyy-MM-dd'), + source: 'direct', }) } @@ -232,41 +205,12 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav footer={ <> - } > - {/* ── Tabs ── */} -
- {([ - { id: 'booking' as const, label: 'Бронирование', icon: CalendarDays }, - { id: 'passport' as const, label: 'Документы гостя', icon: IdCard, - badge: passportFilled }, - ]).map(t => ( - - ))} -
- - {/* ── BOOKING TAB ── */} - {activeTab === 'booking' && (
{/* ── LEFT COLUMN ── */} @@ -330,18 +274,57 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav }
- {/* Guest name + email */} -
+ {/* ФИО гостя */} +
set('guestName', e.target.value)} + placeholder="Иванов" + value={form.lastName} + onChange={e => set('lastName', e.target.value)} + /> +
+
+ + set('firstName', e.target.value)} + /> +
+
+ + set('middleName', e.target.value)} + /> +
+
+ {/* Телефон + Email */} +
+
+ + set('phone', e.target.value)} />
@@ -801,159 +784,6 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav )}
- )} {/* end booking tab */} - - {/* ── PASSPORT TAB ── */} - {activeTab === 'passport' && ( -
- {/* Document type + scan */} -
- - -
-
-
-
- {DOC_TYPES.map(dt => ( - - ))} -
-
- - {/* Name fields */} -
-
- - setP('lastName', e.target.value)} /> -
-
- - setP('firstName', e.target.value)} /> -
-
- - setP('patronymic', e.target.value)} /> -
-
- - {/* Birth + nationality */} -
-
- - setP('birthDate', e.target.value)} /> -
-
- - setP('birthPlace', e.target.value)} /> -
-
- - setP('nationality', e.target.value)} /> -
-
- - {/* Document fields */} -
-
-

Реквизиты документа

-
-
- {passport.docType === 'rf_passport' && ( -
-
- - setP('series', e.target.value)} - /> -
-
- - setP('number', e.target.value)} - /> -
-
- )} - {passport.docType !== 'rf_passport' && ( -
- - setP('number', e.target.value)} - /> -
- )} - -
- - setP('issuedBy', e.target.value)} - /> -
- -
-
- - setP('issuedDate', e.target.value)} /> -
- {passport.docType === 'rf_passport' && ( -
- - setP('divisionCode', e.target.value)} - /> -
- )} -
-
-
- - {/* Registration address */} -
- - setP('registrationAddress', e.target.value)} - /> -
- - {/* Hint */} -

- Данные используются для формирования регистрационной карты и уведомления УМВД (при необходимости) -

-
- )} {/* end passport tab */} diff --git a/src/lib/api.ts b/src/lib/api.ts index a49c88e..78bda31 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -459,6 +459,7 @@ export interface GuestApiType { hotelId: string firstName: string lastName: string + middleName: string | null email: string | null phone: string | null passport: string | null @@ -493,6 +494,7 @@ export interface GuestApiType { export interface GuestPayload { first_name?: string last_name?: string + middle_name?: string email?: string phone?: string passport?: string diff --git a/src/pages/BookingsPage.tsx b/src/pages/BookingsPage.tsx index 01bf7ef..0aa532e 100644 --- a/src/pages/BookingsPage.tsx +++ b/src/pages/BookingsPage.tsx @@ -108,7 +108,7 @@ export function BookingsPage() { const handleCreateBooking = async (data: Partial) => { try { const created = await api.bookings.create(slug, { - roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail, + roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail, guestPhone: data.guestPhone, checkIn: data.checkIn, checkOut: data.checkOut, adults: data.adults, children: data.children, status: data.status, source: data.source, @@ -124,7 +124,7 @@ export function BookingsPage() { const handleUpdateBooking = async (id: string, data: Partial) => { try { const updated = await api.bookings.update(slug, id, { - guestName: data.guestName, guestEmail: data.guestEmail, + guestName: data.guestName, guestEmail: data.guestEmail, guestPhone: data.guestPhone, checkIn: data.checkIn, checkOut: data.checkOut, adults: data.adults, children: data.children, status: data.status, source: data.source, diff --git a/src/pages/CalendarPage.tsx b/src/pages/CalendarPage.tsx index d9073b6..eccd6bd 100644 --- a/src/pages/CalendarPage.tsx +++ b/src/pages/CalendarPage.tsx @@ -63,7 +63,7 @@ export function CalendarPage() { const handleCreate = async (data: Partial) => { try { const created = await api.bookings.create(slug, { - roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail, + roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail, guestPhone: data.guestPhone, checkIn: data.checkIn, checkOut: data.checkOut, adults: data.adults, children: data.children, status: data.status, source: data.source, diff --git a/src/types/index.ts b/src/types/index.ts index 84de813..fcd5ccb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -147,6 +147,7 @@ export interface Booking { guestId: string guestName: string guestEmail: string + guestPhone?: string checkIn: string // ISO date YYYY-MM-DD checkOut: string // ISO date YYYY-MM-DD status: BookingStatus