From afe7445d7eb222cbc1335a6b231032b853a615e2 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Sun, 15 Mar 2026 21:00:11 +0300 Subject: [PATCH] Redesign booking detail panel with 4 tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Бронь: room/dates/guests/contact (existing info) - Гость: passport data form + scan button - Оплата: discount selector, accept payment form, payment history - Документы: print buttons for reg card/contract/invoice/act - Footer: debt warning banner before check-in Co-Authored-By: Claude Sonnet 4.6 --- .../bookings/BookingDetailPanel.tsx | 591 ++++++++++++++---- 1 file changed, 454 insertions(+), 137 deletions(-) diff --git a/src/components/bookings/BookingDetailPanel.tsx b/src/components/bookings/BookingDetailPanel.tsx index d615aa4..c5030f4 100644 --- a/src/components/bookings/BookingDetailPanel.tsx +++ b/src/components/bookings/BookingDetailPanel.tsx @@ -1,7 +1,53 @@ -import { X, Mail, Phone, Calendar, Users, CreditCard, Tag, Edit2, CheckCircle, XCircle } from 'lucide-react' -import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../../lib/utils' +import { useState } from 'react' +import { + X, Mail, Calendar, Users, CreditCard, Tag, CheckCircle, XCircle, + Printer, ScanLine, Banknote, Building2, Plus, + FileText, FileCheck, Receipt, IdCard, AlertTriangle, +} 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 { 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 @@ -11,164 +57,435 @@ interface BookingDetailPanelProps { } export function BookingDetailPanel({ booking, room, onClose, onUpdate }: BookingDetailPanelProps) { - const nights = nightsCount(booking.checkIn, booking.checkOut) - const balance = booking.totalAmount - booking.paidAmount + const [tab, setTab] = useState('booking') + + // Passport data + const nameParts = booking.guestName.split(' ') + const [passport, setPassport] = useState({ + lastName: nameParts[0] ?? '', firstName: nameParts[1] ?? '', middleName: nameParts[2] ?? '', + dob: '', series: '', number: '', issuedBy: '', issueDate: '', regAddress: '', + }) + const setP = (k: K, v: PassportData[K]) => + setPassport(prev => ({ ...prev, [k]: v })) + + // Payments + const [payments, setPayments] = useState(() => + 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 ( <> - {/* Backdrop */}
- {/* Panel */} -
+
+ + {/* Toast notification */} + {toast && ( +
+ {toast} +
+ )} + {/* Header */} -
-
-

- {booking.guestName} -

-
- - {BOOKING_STATUS_LABELS[booking.status]} - - - {SOURCE_LABELS[booking.source]} - -
-
- -
- - {/* Body */} -
- {/* Room */} - {room && ( -
-

Номер

-

- №{room.number} — {room.type} -

-

Этаж {room.floor} · {room.bedType} bed

-
- )} - - {/* Dates */} -
+
+
-

- Заезд -

-

- {new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(booking.checkIn))} -

-
-
-

- Выезд -

-

- {new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(booking.checkOut))} -

-
-
-

- {nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} -

- - {/* Guests */} -
-

- Гости -

-

- {booking.adults} взр.{booking.children > 0 ? ` · ${booking.children} дет.` : ''} -

-
- - {/* Contact */} -
-

Контакты

-
- - {booking.guestEmail || '—'} -
-
- - {/* Payment */} -
-

- Оплата -

-
- Стоимость - - {formatCurrency(booking.totalAmount)} - -
-
- Оплачено - - {formatCurrency(booking.paidAmount)} - -
- {balance > 0 && ( -
- Остаток - - {formatCurrency(balance)} - +

+ {booking.guestName} +

+
+ + {BOOKING_STATUS_LABELS[booking.status]} + + + {SOURCE_LABELS[booking.source]} +
- )} +
+
- {/* Notes */} - {booking.notes && ( -
-

- Примечания -

-

- {booking.notes} + {/* Tab bar */} +

+ {TABS.map(t => ( + + ))} +
+
+ + {/* Tab content */} +
+ + {/* ── Бронь ────────────────────────────────────────────────────────── */} + {tab === 'booking' && ( +
+ {room && ( +
+

Номер

+

+ №{room.number} — {room.type} +

+

+ Этаж {room.floor} · {room.bedType} bed +

+
+ )} + +
+
+

+ Заезд +

+

+ {new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(booking.checkIn))} +

+
+
+

+ Выезд +

+

+ {new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(booking.checkOut))} +

+
+
+

+ {nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}

+ +
+

+ Гости +

+

+ {booking.adults} взр.{booking.children > 0 ? ` · ${booking.children} дет.` : ''} +

+
+ + {booking.guestEmail && ( +
+

Контакты

+
+ + {booking.guestEmail} +
+
+ )} + + {booking.notes && ( +
+

+ Примечания +

+

+ {booking.notes} +

+
+ )} + + {booking.channelBookingId && ( +
+

ID канала

+ + {booking.channelBookingId} + +
+ )}
)} - {/* Channel ID */} - {booking.channelBookingId && ( -
-

ID канала

- - {booking.channelBookingId} - + {/* ── Гость ────────────────────────────────────────────────────────── */} + {tab === 'guest' && ( +
+
+
+ + setP('lastName', e.target.value)} placeholder="Иванов" /> +
+
+
+ + setP('firstName', e.target.value)} placeholder="Иван" /> +
+
+ + setP('middleName', e.target.value)} placeholder="Иванович" /> +
+
+
+ + setP('dob', e.target.value)} /> +
+
+ +
+

Паспорт

+
+
+
+ + setP('series', e.target.value)} placeholder="4510" maxLength={4} /> +
+
+ + setP('number', e.target.value)} placeholder="123456" maxLength={6} /> +
+
+
+ + setP('issuedBy', e.target.value)} placeholder="УФМС России по г. Москве" /> +
+
+ + setP('issueDate', e.target.value)} /> +
+
+ + setP('regAddress', e.target.value)} placeholder="г. Москва, ул. Примерная, д. 1" /> +
+
+
+ + + + +
+ )} + + {/* ── Оплата ───────────────────────────────────────────────────────── */} + {tab === 'payment' && ( +
+ {/* Discount selector */} +
+ + +
+ + {/* Breakdown */} +
+
+ Стоимость + {formatCurrency(baseTotal)} +
+ {discountAmt > 0 && ( +
+ Скидка «{selDiscount?.name}» + −{formatCurrency(discountAmt)} +
+ )} +
+ Итого + {formatCurrency(finalTotal)} +
+
+ Оплачено + {formatCurrency(totalPaid)} +
+
0 ? 'text-red-600 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400', + )}> + {balance > 0 ? 'Остаток' : 'Переплата'} + {balance > 0 ? formatCurrency(balance) : `+${formatCurrency(-balance)}`} +
+
+ + {/* Accept payment */} + {!showPayForm ? ( + + ) : ( +
+

Новый платёж

+
+ + setPayAmount(e.target.value)} placeholder="0" min="0" /> +
+
+ +
+ {METHODS.map(m => ( + + ))} +
+
+
+ + setPayNote(e.target.value)} placeholder="Необязательно" /> +
+
+ + +
+
+ )} + + {/* Payment history */} + {payments.length > 0 && ( +
+

История платежей

+
+ {payments.map(p => { + const M = METHODS.find(m => m.id === p.method)! + return ( +
+ + {p.date} + {p.note || M.label} + {formatCurrency(p.amount)} +
+ ) + })} +
+
+ )} +
+ )} + + {/* ── Документы ────────────────────────────────────────────────────── */} + {tab === 'docs' && ( +
+

+ Распечатайте документы для гостя +

+ {DOCUMENTS + .filter(d => d.always || booking.status === 'checked_in' || booking.status === 'checked_out') + .map(doc => ( +
+
+ +
+
+

{doc.label}

+

{doc.desc}

+
+ +
+ )) + }
)}
- {/* Actions */} + {/* Footer */}
+ {balance > 0 && booking.status === 'confirmed' && ( +
+ +

+ Долг {formatCurrency(balance)} — получите оплату перед заселением +

+
+ )} + {booking.status === 'confirmed' && ( - )} {booking.status === 'checked_in' && ( - )} {(booking.status === 'confirmed' || booking.status === 'inquiry') && ( @@ -176,10 +493,10 @@ export function BookingDetailPanel({ booking, room, onClose, onUpdate }: Booking 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" > - - Отменить + Отменить )} +

ID: {booking.id} · Создано: {booking.createdAt}